Testing in Spring MVC

Web layer tests with MockMvc and @WebMvcTest; faking dependencies with @MockitoBean; verifying with model(), view(), and jsonPath(); testing request bodies, path variables/query params, validation errors, and multipart uploads; real tests of this project's own HomeController and TopicController.

Advanced 45 min
TR

Testing in Spring MVC

This is the last lesson in the Spring MVC category -- and in a way, it ties all the others together. So far we've written @Controller/ @RestControllers, @RequestBody/ResponseEntity, @Valid/ProblemDetail, Thymeleaf views, HandlerInterceptor/CORS/multipart, and DTO/pagination/ idempotency patterns -- but we never actually ran and verified any of it. This lesson covers how MockMvc and @WebMvcTest let us prove that code actually does what it claims, quickly and repeatably, without spinning up a real server.

What Are Test Layers in Spring MVC?

There's no single way to test a Spring MVC application -- there are a few layers, each with a different purpose:

// Three different tests, three different speed/realism trade-offs:
// 1) Pure unit test: new TopicController(...).show(...) -- no Spring at all.
// 2) Slice test: @WebMvcTest + MockMvc -- only the web layer is loaded.
// 3) Integration test: @SpringBootTest -- the real app, a real DB (or test container).

This lesson focuses mainly on the middle layer: MockMvc with @WebMvcTest. A pure unit test is very fast but never verifies HTTP itself (path matching, headers, serialization); @SpringBootTest is realistic but slow and needs a database. @WebMvcTest sits between the two -- it tests real HTTP request-handling mechanics without a real server or database.

Why Does It Exist?

Testing a controller by hand (with curl or a browser) is work that has to be repeated on every change, easy to forget, and doesn't scale to automation. Every controller we've written since spring-mvc-fundamentals -- path matching, model attributes, JSON serialization, validation, error bodies -- should be automatically re-verifiable on every code change. MockMvc makes that possible without opening a real HTTP server (no socket, no port) -- which keeps tests both fast and reliable in a CI environment.

History

Spring Test MVC started out (around 2012) as a separate spring-test-mvc project, outside Spring Framework's main codebase; that's where MockMvc and its andExpect chaining API came from. Spring 3.2 moved that project into Spring Framework itself (the spring-test module). Spring Boot 1.4 (2016) introduced "slice test" annotations like @WebMvcTest and its sibling @DataJpaTest -- the goal being to load only the slice of the ApplicationContext a test actually needs, not the whole thing. @MockBean was, for a long time, the standard way to fill in missing dependencies in these slices; Spring Boot 3.4 (2024) deprecated it in favor of @MockitoBean, which moved into Spring Framework's own test infrastructure -- since this project runs Spring Boot 4.1.0, we use only @MockitoBean here.

Unit Test vs. Slice Test vs. Integration Test: Where Does @WebMvcTest Sit?

@WebMvcTest actually runs the real HandlerMapping/HandlerAdapter/ ViewResolver machinery we saw in spring-mvc-fundamentals' "This Project's Own Controllers: A Real Spring MVC Example" section, together with DispatcherServlet's front controller pattern -- but it does NOT load the @Service/@Repository layer or a real database connection. That makes it a deliberate middle ground among the three options: more realistic than a pure unit test (real HTTP request/response mechanics run), faster than a full @SpringBootTest (no database, not every bean). The rest of this lesson focuses on exactly that middle ground: @WebMvcTest and MockMvc.

@WebMvcTest and MockMvc: Loading Only the Web Layer

Let's see concretely what @WebMvcTest does and doesn't load:

import org.junit.jupiter.api.Test;
import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest;
import org.springframework.stereotype.Controller;
import org.springframework.test.web.servlet.MockMvc;
import org.springframework.web.bind.annotation.GetMapping;

import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;

// @WebMvcTest loads a SLICE of the application, not the whole thing: DispatcherServlet,
// HandlerMapping/HandlerAdapter, message converters, the given controller (and other
// @Controller/@ControllerAdvice/@Converter/HandlerInterceptor/WebMvcConfigurer beans) --
// but NOT @Service/@Repository/@Component beans, and NOT a real database connection.
// This is a "run via `mvn test`" example, not a plain main() program -- @WebMvcTest
// needs JUnit's test runner and a Spring TestContext to do its work.
@WebMvcTest(WebMvcTestSliceExample.PingController.class)
class WebMvcTestSliceExample {

    // A tiny controller defined right here, just to keep this example self-contained --
    // in a real test this would be an existing @Controller/@RestController class.
    @Controller
    static class PingController {
        @GetMapping("/ping")
        @org.springframework.web.bind.annotation.ResponseBody
        String ping() {
            return "pong";
        }
    }

    private final MockMvc mockMvc;

    WebMvcTestSliceExample(MockMvc mockMvc) {
        // MockMvc is one of the few beans @WebMvcTest auto-configures and lets you
        // inject directly -- no manual setup needed.
        this.mockMvc = mockMvc;
    }

    @Test
    void pingReturnsPong() throws Exception {
        mockMvc.perform(get("/ping"))
                .andExpect(status().isOk());
        // If PingController depended on a @Service, this test would fail at context
        // startup with "no qualifying bean" -- @WebMvcTest deliberately does NOT wire
        // up @Service/@Repository beans; see "@MockitoBean ile Bağımlılıkları
        // Sahtelemek" for how to supply the ones a real controller needs.
    }
}

@WebMvcTest(PingController.class) loads DispatcherServlet, message converters, and the given controller (plus any @ControllerAdvice/ HandlerInterceptor/WebMvcConfigurer beans) -- but if it had a @Service dependency, context startup would fail with "no qualifying bean". MockMvc is one of the few beans this narrowed-down context can inject automatically.

Your First MockMvc Test: perform, andExpect, status()

Let's see MockMvc at its simplest, without even needing a Spring context:

import org.springframework.stereotype.Controller;
import org.springframework.test.web.servlet.MockMvc;
import org.springframework.test.web.servlet.setup.MockMvcBuilders;
import org.springframework.web.bind.annotation.GetMapping;

import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.content;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;

// MockMvcBuilders.standaloneSetup(...) inşa eder MockMvc'yi bir Spring ApplicationContext
// OLMADAN -- sadece verilen controller(lar)ı DispatcherServlet benzeri bir pipeline'a
// elle bağlar. Bu yüzden bu dosya, projenin main()-ile-çalıştırılabilir kuralına uyarak
// plain main() ile çalışabilir; gerçek @WebMvcTest/@SpringBootTest testleri (aşağıdaki
// diğer örneklerde) bir JUnit runner + Spring TestContext gerektirir.
public class FirstMockMvcTestExample {

    @Controller
    static class GreetingController {
        @GetMapping("/greeting")
        @org.springframework.web.bind.annotation.ResponseBody
        String greeting() {
            return "Merhaba, MockMvc!";
        }
    }

    public static void main(String[] args) throws Exception {
        MockMvc mockMvc = MockMvcBuilders.standaloneSetup(new GreetingController()).build();

        // perform(): sahte bir HTTP isteği gönderir (gerçek soket açılmaz, gerçek port
        // dinlenmez -- her şey aynı JVM içinde, servlet API'sinin sahte implementasyonlarıyla
        // çalışır). andExpect(): zincirlenebilir doğrulamalar; biri başarısız olursa
        // AssertionError fırlatır ve zincirin geri kalanı çalışmaz.
        mockMvc.perform(get("/greeting"))
                .andExpect(status().isOk())
                .andExpect(content().string("Merhaba, MockMvc!"));

        System.out.println("Tum andExpect() dogrulamalari basarili -- gercek bir HTTP");
        System.out.println("sunucusu hic acilmadi.");

        // status(): HTTP durum kodunu doğrular -- isOk() (200), isNotFound() (404),
        // isBadRequest() (400) gibi okunabilir yardımcı metotlarla.
        // content(): yanıt gövdesini doğrular -- string(), contentType(), json() vb.
    }
}

MockMvcBuilders.standaloneSetup(...) wires the given controller(s) into a mini pipeline by hand, WITHOUT a Spring ApplicationContext -- that's why this example can run as a plain main(), following this project's run-with-main() convention. perform(...) sends a fake request (no real socket opens), and andExpect(...) runs chainable assertions, throwing an AssertionError if one fails.

Faking Dependencies with @MockitoBean

We saw that @WebMvcTest doesn't load @Service/@Repository beans -- so what happens when a controller genuinely depends on one?

import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest;
import org.springframework.stereotype.Controller;
import org.springframework.stereotype.Service;
import org.springframework.test.context.bean.override.mockito.MockitoBean;
import org.springframework.test.web.servlet.MockMvc;
import org.springframework.ui.Model;
import org.springframework.web.bind.annotation.GetMapping;

import static org.mockito.Mockito.when;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.content;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;

// NOT: @MockBean, Spring Boot 3.4'ten beri deprecated ve 4.0'da kaldırılması planlanıyordu;
// bu proje Spring Boot 4.1.0 kullandığı için burada ve sonraki tüm örneklerde
// SADECE @MockitoBean (org.springframework.test.context.bean.override.mockito.MockitoBean)
// kullanılıyor. Bu, bir JUnit test sınıfıdır -- main() ile çalışmaz, `mvn test` gerektirir.
@WebMvcTest(MockitoBeanExample.GreeterController.class)
class MockitoBeanExample {

    // Gerçek uygulamada bir @Service olurdu; burada örneği self-contained tutmak için
    // dosyanın içinde tanımlı.
    interface GreetingService {
        String greetingFor(String name);
    }

    @Service
    static class RealGreetingService implements GreetingService {
        @Override
        public String greetingFor(String name) {
            throw new UnsupportedOperationException("Gerçek implementasyon burada önemli değil");
        }
    }

    @Controller
    static class GreeterController {
        private final GreetingService greetingService;

        GreeterController(GreetingService greetingService) {
            this.greetingService = greetingService;
        }

        @GetMapping("/greet")
        @org.springframework.web.bind.annotation.ResponseBody
        String greet() {
            return greetingService.greetingFor("Cem");
        }
    }

    @Autowired
    private MockMvc mockMvc;

    // @MockitoBean: context'e GreetingService türünde bir Mockito sahtesi ekler (veya
    // varsa gerçek bean'in yerine geçirir). @WebMvcTest zaten @Service'leri yüklemediği
    // için, GreeterController'ın bağımlılığı bu olmadan "no qualifying bean" hatasıyla
    // context başlatma anında patlardı.
    @MockitoBean
    private GreetingService greetingService;

    @Test
    void greetUsesMockedService() throws Exception {
        when(greetingService.greetingFor("Cem")).thenReturn("Merhaba, Cem!");

        mockMvc.perform(get("/greet"))
                .andExpect(status().isOk())
                .andExpect(content().string("Merhaba, Cem!"));

        // RealGreetingService hiç çalışmadı -- sadece sahte nesnenin döndürdüğü değer
        // kullanıldı. Bu, testi RealGreetingService'in implementasyon detaylarından
        // (örn. bir veritabanı çağrısından) tamamen izole eder.
    }
}

@MockitoBean adds a Mockito mock of the given type to the context (or replaces a real bean if one exists) -- without it, GreeterController's GreetingService dependency would fail context startup with "no qualifying bean". Note: instead of @MockBean (deprecated since Spring Boot 3.4, removed in the 4.1.0 this project runs), we use @MockitoBean exclusively here and throughout the rest of this lesson.

Testing This Project's Own HomeController: A Real Example

Not a made-up controller -- let's test the real HomeController we introduced in spring-mvc-fundamentals' "This Project's Own Controllers: A Real Spring MVC Example" section:

import com.cdurgun.learning.controller.HomeController;
import com.cdurgun.learning.service.NavigationService;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest;
import org.springframework.test.context.bean.override.mockito.MockitoBean;
import org.springframework.test.web.servlet.MockMvc;

import java.util.List;

import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.model;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.view;

// Bu projenin GERÇEK HomeController'ının gerçek bir @WebMvcTest testi -- kurgu bir
// controller değil. HomeController'ın tek bağımlılığı NavigationService, bu yüzden
// tek bir @MockitoBean yeterli. JUnit test sınıfı, `mvn test` ile çalışır.
@WebMvcTest(HomeController.class)
class HomeControllerTest {

    @Autowired
    private MockMvc mockMvc;

    @MockitoBean
    private NavigationService navigationService;

    @Test
    void indexReturnsIndexViewWithNavigationModel() throws Exception {
        // buildNavigation gerçek DB'ye gitmiyor -- boş liste dönmesi bile yeterli,
        // çünkü burada test edilen şey NavigationService'in DAVRANIŞI değil,
        // HomeController'ın onu nasıl ÇAĞIRDIĞI ve model'e nasıl koyduğu.
        when(navigationService.buildNavigation(org.mockito.ArgumentMatchers.any())).thenReturn(List.of());

        mockMvc.perform(get("/"))
                .andExpect(status().isOk())
                .andExpect(view().name("index"))
                .andExpect(model().attributeExists("language"))
                .andExpect(model().attributeExists("nav"));

        // Gerçek controller kodunu okuyunca görürsünüz: `language`,
        // LocaleContextHolder.getLocale() üzerinden çözülüyor -- bu test bunu HTTP
        // isteğinin kendi varsayılan locale'iyle (test ortamında genelde Locale.getDefault())
        // doğal olarak sağlıyor; belirli bir dili zorlamak isterseniz `.locale(Locale.forLanguageTag("tr"))`
        // ekleyebilirsiniz.
        verify(navigationService).buildNavigation(org.mockito.ArgumentMatchers.any());
    }
}

HomeController's only dependency is NavigationService, so a single @MockitoBean is enough. We don't care about the actual list buildNavigation(...) returns -- what's being tested here isn't NavigationService's behavior, it's whether HomeController calls it correctly and puts the right attributes into the model.

Verifying the Model and View Name: model(), view()

Two matchers that matter more than content() for a classic (non-JSON) @Controller:

import org.springframework.stereotype.Controller;
import org.springframework.test.web.servlet.MockMvc;
import org.springframework.test.web.servlet.setup.MockMvcBuilders;
import org.springframework.ui.Model;
import org.springframework.web.bind.annotation.GetMapping;

import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.model;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.view;

// model()/view(): sadece HTTP durumuna ve gövdesine değil, controller'ın Model'e
// hangi attribute'ları koyduğuna ve hangi view adını döndürdüğüne bakar -- klasik
// (JSON döndürmeyen, Thymeleaf ile render edilen) bir @Controller için bu genelde
// content()'ten daha anlamlıdır, çünkü render edilmiş HTML'i değil, controller'ın
// SÖZLEŞMESİNİ (hangi view, hangi veriyle) doğrular.
public class ModelAndViewAssertionExample {

    @Controller
    static class ProfileController {
        @GetMapping("/profile")
        String profile(Model model) {
            model.addAttribute("username", "cdurgun");
            model.addAttribute("topicCount", 25);
            return "profile";
        }
    }

    public static void main(String[] args) throws Exception {
        MockMvc mockMvc = MockMvcBuilders.standaloneSetup(new ProfileController()).build();

        mockMvc.perform(get("/profile"))
                .andExpect(status().isOk())
                // view().name(): dönen mantıksal view adını doğrular (fiziksel
                // profile.html dosyasının render edilip edilmediğini DEĞİL --
                // standaloneSetup'ta bir ViewResolver/template motoru yok).
                .andExpect(view().name("profile"))
                // model().attribute(...): bir attribute'ın DEĞERİNİ doğrular.
                .andExpect(model().attribute("username", "cdurgun"))
                .andExpect(model().attribute("topicCount", 25))
                // model().attributeExists(...): sadece VARLIĞINI doğrular, değerini değil --
                // değeri test edip etmeyeceğinize önem vermediğiniz durumlarda kullanışlı.
                .andExpect(model().attributeExists("username", "topicCount"));

        System.out.println("Model ve view dogrulamalari basarili.");
    }
}

view().name(...) verifies the logical view name that's returned -- not whether the physical HTML file got rendered (standaloneSetup has no ViewResolver/template engine at all). model().attribute(...) verifies an attribute's value, while model().attributeExists(...) verifies only its presence.

Testing a @RestController: Verifying a JSON Body with jsonPath

@RestControllers have no view/model -- the response is straight JSON, and the tool for verifying it is jsonPath(...):

import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.test.web.servlet.MockMvc;
import org.springframework.test.web.servlet.setup.MockMvcBuilders;

import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;

// @RestController'lar JSON döndürür; view()/model() burada anlamsızdır (view yok).
// jsonPath(...) yanıt gövdesinin İÇİNE, bir JSONPath ifadesiyle bakar -- tüm gövdeyi
// elle string karşılaştırmaya (content().json(...)) tercihen, tek tek alan doğrulamak
// için kullanışlıdır; özellikle gövdenin bir kısmını (örn. sunucu tarafından üretilen
// bir zaman damgasını) görmezden gelmek istediğinizde.
public class JsonPathAssertionExample {

    record BookResponse(String title, String author, int pageCount, boolean available) {
    }

    @RestController
    static class BookController {
        @GetMapping("/books/{id}")
        BookResponse book(@PathVariable String id) {
            return new BookResponse("Effective Java", "Joshua Bloch", 412, true);
        }
    }

    public static void main(String[] args) throws Exception {
        MockMvc mockMvc = MockMvcBuilders.standaloneSetup(new BookController()).build();

        mockMvc.perform(get("/books/1"))
                .andExpect(status().isOk())
                // "$.alan": kök nesnenin bir alanı.
                .andExpect(jsonPath("$.title").value("Effective Java"))
                .andExpect(jsonPath("$.author").value("Joshua Bloch"))
                .andExpect(jsonPath("$.pageCount").value(412))
                .andExpect(jsonPath("$.available").value(true))
                // jsonPath(...).exists() / doesNotExist(): alanın varlığını, değerine
                // hiç bakmadan doğrular.
                .andExpect(jsonPath("$.isbn").doesNotExist());

        System.out.println("JSON gövde alanlari jsonPath ile dogrulandi.");

        // Not: bir liste dönseydi (örn. List<BookResponse>), "$[0].title" gibi bir
        // dizi indeksleme ifadesi, "$.length()" ise eleman sayısı için kullanılabilir.
    }
}

jsonPath("$.field") looks INSIDE the response body -- verifying fields one at a time, rather than comparing the whole body as a string (content().json(...)), is useful especially when you want to ignore part of the body (like a server-generated timestamp). jsonPath(...).exists()/ doesNotExist() verify a field's presence without looking at its value at all.

Sending a Request Body: content() and contentType()

Sending a POST/PUT/PATCH body takes two pieces: the body itself, and its type:

import com.fasterxml.jackson.databind.ObjectMapper;
import org.springframework.http.MediaType;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.test.web.servlet.MockMvc;
import org.springframework.test.web.servlet.setup.MockMvcBuilders;

import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;

// POST/PUT/PATCH gövdesi göndermek için content(...) ile ham baytları/string'i, ve
// contentType(...) ile Content-Type header'ını vermeniz gerekir -- Content-Type
// verilmezse Spring, hangi HttpMessageConverter'ın kullanılacağını bilemez ve
// isteği reddedebilir (415 Unsupported Media Type).
public class RequestBodyTestExample {

    record CreateNoteRequest(String title, String body) {
    }

    record NoteResponse(long id, String title) {
    }

    @RestController
    static class NoteController {
        @PostMapping("/notes")
        NoteResponse create(@RequestBody CreateNoteRequest request) {
            return new NoteResponse(1L, request.title());
        }
    }

    public static void main(String[] args) throws Exception {
        MockMvc mockMvc = MockMvcBuilders.standaloneSetup(new NoteController()).build();
        ObjectMapper objectMapper = new ObjectMapper();

        String requestJson = objectMapper.writeValueAsString(
                new CreateNoteRequest("Toplantı Notu", "Spring MVC testing bölümünü bitir"));

        mockMvc.perform(post("/notes")
                        .contentType(MediaType.APPLICATION_JSON)
                        .content(requestJson))
                .andExpect(status().isOk())
                .andExpect(jsonPath("$.id").value(1))
                .andExpect(jsonPath("$.title").value("Toplantı Notu"));

        System.out.println("POST govdesi gonderildi ve yanit dogrulandi.");

        // content(requestJson) burada elle ObjectMapper ile serileştirildi -- gerçek
        // projelerde bu genelde küçük bir yardımcı metoda (örn. asJsonString(Object))
        // çıkarılır, çünkü hemen her yazma testinde tekrar eder.
    }
}

content(requestJson) supplies the raw bytes/string, contentType(...) supplies the Content-Type header -- without a Content-Type, Spring can't tell which HttpMessageConverter to use, and the request can be rejected (415 Unsupported Media Type). Hand-serializing with ObjectMapper is usually pulled out into a small helper method in real projects, since it repeats in almost every write test.

Testing Path Variables and Query Parameters

Path variables live inside the URL itself; query parameters are added with .param(...):

import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.test.web.servlet.MockMvc;
import org.springframework.test.web.servlet.setup.MockMvcBuilders;

import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;

// Path variable'lar URL'in kendi içinde ({id} gibi) verilir; query parametreleri ise
// .param(ad, deger) ile eklenir -- ?page=0&size=10 formatındaki gerçek query string'i
// elle oluşturmaya gerek yoktur, MockMvc bunu sizin için kurar.
public class PathVariableQueryParamTestExample {

    record TopicSummary(String slug, int page, int size, String difficulty) {
    }

    @RestController
    static class TopicSearchController {
        @GetMapping("/api/categories/{categorySlug}/topics")
        TopicSummary search(@PathVariable String categorySlug,
                             @RequestParam(defaultValue = "0") int page,
                             @RequestParam(defaultValue = "20") int size,
                             @RequestParam(required = false) String difficulty) {
            return new TopicSummary(categorySlug, page, size, difficulty);
        }
    }

    public static void main(String[] args) throws Exception {
        MockMvc mockMvc = MockMvcBuilders.standaloneSetup(new TopicSearchController()).build();

        mockMvc.perform(get("/api/categories/{categorySlug}/topics", "spring-mvc")
                        .param("page", "1")
                        .param("size", "5")
                        .param("difficulty", "ADVANCED"))
                .andExpect(status().isOk())
                .andExpect(jsonPath("$.slug").value("spring-mvc"))
                .andExpect(jsonPath("$.page").value(1))
                .andExpect(jsonPath("$.size").value(5))
                .andExpect(jsonPath("$.difficulty").value("ADVANCED"));

        // difficulty verilmeden de çalıştığını doğrula -- @RequestParam(required = false)
        // olduğu için 400 değil, null ile controller'a girer.
        mockMvc.perform(get("/api/categories/{categorySlug}/topics", "spring-mvc"))
                .andExpect(status().isOk())
                .andExpect(jsonPath("$.page").value(0))
                .andExpect(jsonPath("$.size").value(20))
                .andExpect(jsonPath("$.difficulty").doesNotExist());

        System.out.println("Path variable ve query parametre testleri basarili.");
    }
}

The placeholder-filling in get("/api/categories/{categorySlug}/topics", "spring-mvc") matches @PathVariable; calls like .param("page", "1") build the actual query string (?page=1&size=5) for you. We also verify that an @RequestParam(required = false) parameter that isn't supplied reaches the controller as null, not as a 400.

Testing Validation Errors: 400 and ProblemDetail

standaloneSetup(...) sets up a default validator automatically, since Bean Validation is on the classpath -- but as we saw in Validation & Exception Handling's "Global Error Handling: @RestControllerAdvice" section, @ControllerAdvice classes aren't scanned automatically:

import com.fasterxml.jackson.databind.ObjectMapper;
import jakarta.validation.Valid;
import jakarta.validation.constraints.Min;
import jakarta.validation.constraints.NotBlank;
import org.springframework.http.HttpStatus;
import org.springframework.http.MediaType;
import org.springframework.http.ProblemDetail;
import org.springframework.web.bind.MethodArgumentNotValidException;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.bind.annotation.RestControllerAdvice;
import org.springframework.test.web.servlet.MockMvc;
import org.springframework.test.web.servlet.setup.MockMvcBuilders;

import java.util.stream.Collectors;

import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;

// standaloneSetup(...), Bean Validation classpath'te olduğu için VARSAYILAN OLARAK bir
// validator kurar -- yani @Valid ek bir .setValidator(...) çağrısı olmadan çalışır.
// Ancak @ControllerAdvice sınıfları OTOMATİK taranmaz: geçerli bir hata gövdesi (400 +
// ProblemDetail) almak için advice'ı .setControllerAdvice(...) ile elle eklemeniz gerekir
// -- bkz. "Validation ve Exception Handling" dersindeki "@RestControllerAdvice: Global
// Hata Yönetimi" bölümü.
public class ValidationErrorTestExample {

    record CreateTopicRequest(@NotBlank String slug, @Min(1) int estimatedMinutes) {
    }

    @RestController
    static class TopicCreationController {
        @PostMapping("/api/topics")
        String create(@Valid @RequestBody CreateTopicRequest request) {
            return "created: " + request.slug();
        }
    }

    @RestControllerAdvice
    static class ValidationAdvice {
        @ExceptionHandler(MethodArgumentNotValidException.class)
        ProblemDetail handleValidation(MethodArgumentNotValidException e) {
            ProblemDetail problem = ProblemDetail.forStatusAndDetail(HttpStatus.BAD_REQUEST, "Validation failed");
            problem.setProperty("errors", e.getBindingResult().getFieldErrors().stream()
                    .map(fe -> fe.getField() + ": " + fe.getDefaultMessage())
                    .collect(Collectors.toList()));
            return problem;
        }
    }

    public static void main(String[] args) throws Exception {
        MockMvc mockMvc = MockMvcBuilders.standaloneSetup(new TopicCreationController())
                .setControllerAdvice(new ValidationAdvice())
                .build();
        ObjectMapper objectMapper = new ObjectMapper();

        // slug boş VE estimatedMinutes 0 -- iki alan da ihlalde.
        String invalidJson = objectMapper.writeValueAsString(new CreateTopicRequest("", 0));

        mockMvc.perform(post("/api/topics")
                        .contentType(MediaType.APPLICATION_JSON)
                        .content(invalidJson))
                .andExpect(status().isBadRequest())
                .andExpect(jsonPath("$.detail").value("Validation failed"))
                .andExpect(jsonPath("$.errors.length()").value(2));

        System.out.println("Gecersiz govde 400 + ProblemDetail ile reddedildi.");

        // Geçerli bir istekle karşılaştır: aynı controller, aynı advice, farklı sonuç.
        String validJson = objectMapper.writeValueAsString(new CreateTopicRequest("spring-mvc-testing", 45));
        mockMvc.perform(post("/api/topics")
                        .contentType(MediaType.APPLICATION_JSON)
                        .content(validJson))
                .andExpect(status().isOk());

        System.out.println("Gecerli govde ise 200 ile kabul edildi.");
    }
}

Getting a proper error body requires adding the advice by hand with .setControllerAdvice(...). The ValidationAdvice here catches MethodArgumentNotValidException and builds a ProblemDetail using the same pattern from that lesson's "ProblemDetail: A Standard Error Body with RFC 7807" section -- with one invalid and one valid request, we compare two different outcomes from the same controller plus the same advice.

Testing a Multipart File Upload: MockMultipartFile

Advanced Spring MVC's MultipartUploadControllerExample, from the "Multipart File Upload: Taking a MultipartFile with @RequestParam" section, hand-implemented MultipartFile because it lives in main scope -- here, being in test scope, we can use the real MockMultipartFile directly:

import org.springframework.mock.web.MockMultipartFile;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.multipart.MultipartFile;
import org.springframework.test.web.servlet.MockMvc;
import org.springframework.test.web.servlet.setup.MockMvcBuilders;

import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.multipart;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;

// MockMultipartFile, spring-test'in kendisidir (spring-boot-starter-test ile gelir,
// test scope) -- "Advanced Spring MVC" dersindeki MultipartUploadControllerExample'ın
// aksine (o örnek main-scope olduğu için MultipartFile'ı elle implemente etmişti),
// burada test scope olduğumuz için gerçek MockMultipartFile'ı doğrudan kullanabiliyoruz.
public class MultipartUploadTestExample {

    record UploadResult(String filename, long size, String contentType) {
    }

    @RestController
    static class UploadController {
        @PostMapping("/api/uploads")
        UploadResult upload(@RequestParam("file") MultipartFile file) {
            return new UploadResult(file.getOriginalFilename(), file.getSize(), file.getContentType());
        }
    }

    public static void main(String[] args) throws Exception {
        MockMvc mockMvc = MockMvcBuilders.standaloneSetup(new UploadController()).build();

        MockMultipartFile file = new MockMultipartFile(
                "file",                          // @RequestParam adıyla eşleşen part adı
                "notes.txt",                      // orijinal dosya adı
                "text/plain",                     // content type
                "spring-mvc-testing notlari".getBytes());

        // multipart(...): normal get()/post() yerine, multipart/form-data gövdesi
        // kuran özel bir request builder.
        mockMvc.perform(multipart("/api/uploads").file(file))
                .andExpect(status().isOk())
                .andExpect(jsonPath("$.filename").value("notes.txt"))
                .andExpect(jsonPath("$.contentType").value("text/plain"))
                .andExpect(jsonPath("$.size").value(file.getSize()));

        System.out.println("Multipart dosya yukleme testi basarili.");

        // Boyut sınırı ihlali gibi senaryolar için (bkz. Advanced Spring MVC dersindeki
        // "Multipart Boyut Sınırları" bölümü), MaxUploadSizeExceededException'ı yakalayan
        // bir @RestControllerAdvice, ValidationErrorTestExample'daki desenin aynısıyla
        // eklenebilir.
    }
}

MockMultipartFile is a real spring-test class that comes with spring-boot-starter-test (test scope). multipart(...) is a special request builder that builds a multipart/form-data body instead of the usual get()/post() -- the file added with .file(file) matches the controller's @RequestParam("file") MultipartFile parameter. Scenarios like exceeding a size limit (see that same lesson's "Multipart Configuration and Size Limits" section) can be tested the same way as ValidationErrorTestExample, by adding an advice that catches the relevant exception.

Best Practices

  • Always use @MockitoBean instead of @MockBean -- this project's Spring Boot 4.1.0 removed @MockBean; @MockitoBean does the same job and lives in Spring Framework's own test infrastructure (see "Faking Dependencies with @MockitoBean").
  • Scope @WebMvcTest to the specific controller you're testing (@WebMvcTest(HomeController.class), not left empty) -- leaving it empty loads every controller and slows the test down, while also making it unclear which dependency needs mocking (see "Testing This Project's Own HomeController: A Real Example").
  • Verify JSON responses field-by-field with jsonPath(...) instead of comparing the whole body as a string -- this keeps the test from breaking when the body shape changes slightly, like when a new field is added (see "Testing a @RestController: Verifying a JSON Body with jsonPath").
  • Don't forget to add @ControllerAdvice by hand when using standaloneSetup(...) -- otherwise error scenarios end up as a raw exception instead of the ProblemDetail you'd see in the real application (see "Testing Validation Errors: 400 and ProblemDetail").

Common Mistakes

1. Forgetting to mock a @Service dependency (@MockitoBean) with @WebMvcTest. The context fails at startup with "no qualifying bean" -- forgetting that @WebMvcTest never loads the @Service/@Repository layer is the single most common mistake in this lesson (see "@WebMvcTest and MockMvc: Loading Only the Web Layer").

2. Forgetting contentType(...) on POST/PUT requests. Even with a body supplied via content(...), without a Content-Type header Spring can't tell which HttpMessageConverter to use, and the request can be rejected with 415 (see "Sending a Request Body: content() and contentType()").

3. Writing jsonPath(...) without checking whether the response is an array or an object. A list needs $[0].title, not $.title -- the wrong expression leads to a confusing failure where the field simply can't be found (see "Testing a @RestController: Verifying a JSON Body with jsonPath").

4. Assuming @Valid works with standaloneSetup(...) and skipping the @ControllerAdvice. The validator is set up by default and MethodArgumentNotValidException does get thrown, but unless an advice that turns it into a proper ProblemDetail is added by hand, the test hits an unexpected 500 instead (see "Testing Validation Errors: 400 and ProblemDetail").

5. Trying to reach a real database inside a @WebMvcTest. This slice deliberately doesn't load @Repository beans -- a controller that needs a repository won't work unless that repository is mocked with @MockitoBean (see "Testing This Project's Own HomeController: A Real Example").

Summary, Cheat Sheet, and Glossary

Testing in Spring MVC starts with a deliberate choice between three layers -- a pure unit test, a @WebMvcTest slice test, or a full @SpringBootTest integration test. Key points:

  • @WebMvcTest: a slice test annotation that loads only the web layer (DispatcherServlet, controllers, converters), excluding @Service/ @Repository
  • MockMvc: a test tool that sends fake requests without opening a real HTTP server
  • MockMvcBuilders.standaloneSetup(...): an alternative setup that wires a controller pipeline by hand, without a Spring context
  • @MockitoBean: an annotation that adds a Mockito mock to the context, or replaces a real bean (the replacement for @MockBean)
  • perform()/andExpect(): the MockMvc methods that send a request and run chainable assertions, respectively
  • status()/view()/model()/jsonPath()/content()/header(): matcher families that verify different response aspects (status code, view name, model attributes, JSON fields, body, headers)
  • MockMultipartFile: a real spring-test class for multipart/form-data tests (test scope)

Quick reference:

@WebMvcTest(TopicController.class)
class TopicControllerTest {

    @Autowired
    private MockMvc mockMvc;

    @MockitoBean
    private TopicRepository topicRepository;

    @Test
    void unknownSlugReturns404() throws Exception {
        when(topicRepository.findBySlugWithCategoryAndCourse("x"))
                .thenReturn(Optional.empty());

        mockMvc.perform(get("/topics/x"))
                .andExpect(status().isNotFound());
    }
}

Glossary

@WebMvcTest — A Spring Boot test slice annotation that loads only the Spring MVC web layer, excluding @Service/@Repository beans.

MockMvc — A test tool for sending fake HTTP requests and verifying responses without opening a real server or socket.

standaloneSetup — A setup method that wires given controllers into a MockMvc pipeline by hand, without a Spring ApplicationContext.

@MockitoBean — An annotation that adds a Mockito mock to the test context, or replaces a real bean; the replacement for @MockBean.

jsonPath — A matcher that verifies a specific field of a JSON response body using a JSONPath expression.

MockMultipartFile — A fake file class provided by the spring-test library, used for multipart file upload tests.

Appendix: Mini Project — A Comprehensive Test Suite for This Project's Own TopicController

Bringing every technique from this lesson together, on this project's real TopicController (all six dependencies mocked with @MockitoBean):

import com.cdurgun.learning.domain.Category;
import com.cdurgun.learning.domain.Course;
import com.cdurgun.learning.domain.Difficulty;
import com.cdurgun.learning.domain.Language;
import com.cdurgun.learning.domain.Topic;
import com.cdurgun.learning.domain.TopicTranslation;

// Bu projenin gerçek entity'leri (Topic, Category, Course, TopicTranslation) Lombok
// @Builder kullanıyor -- ekstra bir test kütüphanesi gerekmeden, okunabilir "fixture"
// (test verisi) üretmek için doğrudan kullanılabilirler. Bu sınıf, TR/EN çeviri ve
// tüm ManyToOne ilişkileriyle birlikte KENDİ İÇİNDE TUTARLI bir Topic ağacı kurar --
// aşağıdaki TopicControllerWebMvcTest bu yardımcıları kullanır.
public class TopicTestFixtures {

    public static Course sampleCourse() {
        return Course.builder()
                .id(1L)
                .name("Java")
                .slug("java")
                .build();
    }

    public static Category sampleCategory(Course course) {
        return Category.builder()
                .id(1L)
                .course(course)
                .name("Spring MVC")
                .slug("spring-mvc")
                .sortOrder(1)
                .build();
    }

    public static Topic sampleTopic(Category category) {
        return Topic.builder()
                .id(9L)
                .category(category)
                .slug("spring-mvc-testing")
                .difficulty(Difficulty.ADVANCED)
                .estimatedMinutes(40)
                .sortOrder(9)
                .build();
    }

    public static TopicTranslation sampleTranslation(Topic topic, Language language, boolean published) {
        return TopicTranslation.builder()
                .id(language == Language.TR ? 91L : 92L)
                .topic(topic)
                .language(language)
                .title(language == Language.TR ? "Spring MVC'de Test Yazmak" : "Testing in Spring MVC")
                .summary(language == Language.TR ? "MockMvc ve @WebMvcTest ile web katmanı testleri." : "Web layer testing with MockMvc and @WebMvcTest.")
                .published(published)
                .build();
    }

    public static void main(String[] args) {
        Course course = sampleCourse();
        Category category = sampleCategory(course);
        Topic topic = sampleTopic(category);
        TopicTranslation translation = sampleTranslation(topic, Language.TR, true);

        // Zincirleme ilişkinin gerçekten kurulduğunu doğrula -- entity'lerin builder'la
        // üretilmesi, aralarındaki referansları ELLE bağlamayı ortadan kaldırmaz.
        System.out.println(translation.getTopic().getCategory().getCourse().getName());
        // Java
        System.out.println(translation.getTopic().getSlug() + " -> " + translation.getTitle());
        // spring-mvc-testing -> Spring MVC'de Test Yazmak
    }
}
import com.cdurgun.learning.controller.TopicController;
import com.cdurgun.learning.domain.Category;
import com.cdurgun.learning.domain.Course;
import com.cdurgun.learning.domain.Language;
import com.cdurgun.learning.domain.Topic;
import com.cdurgun.learning.domain.TopicTranslation;
import com.cdurgun.learning.repository.TopicRepository;
import com.cdurgun.learning.repository.TopicTranslationRepository;
import com.cdurgun.learning.service.ContentResolver;
import com.cdurgun.learning.service.MarkdownService;
import com.cdurgun.learning.service.NavigationService;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest;
import org.springframework.context.MessageSource;
import org.springframework.test.context.bean.override.mockito.MockitoBean;
import org.springframework.test.web.servlet.MockMvc;

import java.util.List;
import java.util.Optional;

import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.when;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.model;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.view;

// Bu projenin GERÇEK TopicController'ı için @WebMvcTest -- 6 bağımlılığın TAMAMI
// @MockitoBean ile sahtelenir (bkz. TopicController'ın constructor'ı). TopicTestFixtures
// bu testin fixture'larını (Course/Category/Topic/TopicTranslation) üretmek için kullanılıyor.
@WebMvcTest(TopicController.class)
class TopicControllerWebMvcTest {

    @Autowired
    private MockMvc mockMvc;

    @MockitoBean
    private TopicRepository topicRepository;
    @MockitoBean
    private TopicTranslationRepository topicTranslationRepository;
    @MockitoBean
    private ContentResolver contentResolver;
    @MockitoBean
    private MarkdownService markdownService;
    @MockitoBean
    private NavigationService navigationService;
    @MockitoBean
    private MessageSource messageSource;

    @Test
    void unknownSlugReturns404() throws Exception {
        when(topicRepository.findBySlugWithCategoryAndCourse("does-not-exist"))
                .thenReturn(Optional.empty());

        // Controller kodunda bkz: ResponseStatusException(HttpStatus.NOT_FOUND, ...) --
        // gerçek bir @ControllerAdvice olmadan bile, Spring'in varsayılan exception
        // çözümü ResponseStatusException'ı doğru HTTP durumuna çevirir.
        mockMvc.perform(get("/topics/does-not-exist"))
                .andExpect(status().isNotFound());
    }

    @Test
    void invalidLangParamReturns400() throws Exception {
        Course course = TopicTestFixtures.sampleCourse();
        Category category = TopicTestFixtures.sampleCategory(course);
        Topic topic = TopicTestFixtures.sampleTopic(category);

        when(topicRepository.findBySlugWithCategoryAndCourse("spring-mvc-testing"))
                .thenReturn(Optional.of(topic));

        // Anasayfa'nın aksine, TopicController açıkça verilmiş ama bilinmeyen bir `lang`
        // için kasıtlı olarak 400 döndürür (bkz. controller'daki Language.fromCode
        // catch bloğu) -- bu davranış farkı, "Path Variable ve Query Parametrelerini
        // Test Etmek" bölümündeki required=false senaryosundan kasıtlı olarak farklıdır.
        mockMvc.perform(get("/topics/spring-mvc-testing").param("lang", "fr"))
                .andExpect(status().isBadRequest());
    }

    @Test
    void publishedTopicRendersWithContent() throws Exception {
        Course course = TopicTestFixtures.sampleCourse();
        Category category = TopicTestFixtures.sampleCategory(course);
        Topic topic = TopicTestFixtures.sampleTopic(category);
        TopicTranslation trTranslation = TopicTestFixtures.sampleTranslation(topic, Language.TR, true);

        when(topicRepository.findBySlugWithCategoryAndCourse("spring-mvc-testing"))
                .thenReturn(Optional.of(topic));
        when(topicTranslationRepository.findByTopicIdAndLanguage(topic.getId(), Language.TR))
                .thenReturn(Optional.of(trTranslation));
        when(topicTranslationRepository.findByTopicIdAndLanguage(topic.getId(), Language.EN))
                .thenReturn(Optional.empty());
        when(contentResolver.resolve(eq("spring-mvc-testing"), eq(Language.TR)))
                .thenReturn(Optional.of("# Spring MVC'de Test Yazmak\n\nIcerik burada."));
        when(markdownService.render(any(), eq("spring-mvc-testing")))
                .thenReturn(new MarkdownService.MarkdownRenderResult("<h1>Spring MVC'de Test Yazmak</h1>", List.of()));
        when(navigationService.buildNavigation(Language.TR)).thenReturn(List.of());
        when(navigationService.buildCourseSequence(course.getId(), Language.TR)).thenReturn(List.of());

        mockMvc.perform(get("/topics/spring-mvc-testing"))
                .andExpect(status().isOk())
                .andExpect(view().name("topic"))
                .andExpect(model().attribute("contentAvailable", true))
                .andExpect(model().attribute("otherLanguageAvailable", false));

        // Not: burada mock'lanan her değer, TopicController'ın PRODUCTION'da gerçek
        // servislerden aldığı değerlerle aynı tiptedir (gerçek Topic, gerçek
        // MarkdownRenderResult record'u) -- bu yüzden templates/topic.html, gerçek bir
        // isteği işliyormuş gibi normal şekilde render edilir.
    }
}

TopicTestFixtures provides helper methods that build the real Course/ Category/Topic/TopicTranslation entities (all using Lombok @Builder) as a consistent tree. TopicControllerWebMvcTest covers three scenarios: 404 for an unknown slug, 400 for an invalid lang parameter, and 200 through the real topic.html template for a fully published topic -- in that last scenario, every mocked value is the same type the controller receives from real services in production (a real Topic, a real MarkdownService.MarkdownRenderResult), so the template renders normally, as if it were handling a real request.

Appendix: Mini Project — Testing an Interceptor with MockMvc

The last mini project tests the lifecycle from Advanced Spring MVC's "The HandlerInterceptor Interface: preHandle, postHandle, afterCompletion" section, in isolation with MockMvc, without ever writing a configuration class like the one in "WebMvcConfigurer: Registering an Interceptor":

import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import org.springframework.web.servlet.HandlerInterceptor;

// Küçük, gerçekçi bir HandlerInterceptor -- "Advanced Spring MVC" dersindeki
// HandlerInterceptorLifecycleExample ile aynı yaşam döngüsünü (preHandle/postHandle/
// afterCompletion) kullanır, ama burada amaç interceptor'ı kendi başına test etmek
// olduğu için mümkün olduğunca sade tutuldu: her istekte X-Response-Time-Ms header'ı
// ekler.
public class TimingInterceptorForTest implements HandlerInterceptor {

    private static final String START_ATTRIBUTE = "requestStartNanos";

    @Override
    public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) {
        request.setAttribute(START_ATTRIBUTE, System.nanoTime());
        return true;
    }

    @Override
    public void afterCompletion(HttpServletRequest request, HttpServletResponse response,
                                 Object handler, Exception ex) {
        Object startValue = request.getAttribute(START_ATTRIBUTE);
        if (startValue instanceof Long startNanos) {
            long elapsedMs = (System.nanoTime() - startNanos) / 1_000_000;
            response.setHeader("X-Response-Time-Ms", String.valueOf(elapsedMs));
        }
        // Not: header'ı postHandle yerine afterCompletion'da eklemek kasıtlı -- afterCompletion
        // handler bir exception fırlatsa BİLE her zaman çalışır, postHandle ise çalışmaz
        // (bkz. "Advanced Spring MVC" dersindeki "HandlerInterceptor Yaşam Döngüsü" bölümü).
    }
}
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.test.web.servlet.MockMvc;
import org.springframework.test.web.servlet.setup.MockMvcBuilders;

import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.header;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;

// standaloneSetup(...).addInterceptors(...): bir interceptor'ı, onu kayıt eden
// WebMvcConfigurer'ı (InterceptorRegistrationExample'daki gibi) hiç yazmadan, doğrudan
// MockMvc'ye takar -- interceptor'ı İZOLE olarak test etmek için idealdir, çünkü tüm
// uygulamanın konfigürasyonunu (path pattern'ler, diğer interceptor'lar) devreye
// sokmaz.
public class TimingInterceptorMockMvcTest {

    @RestController
    static class PingController {
        @GetMapping("/ping")
        String ping() {
            return "pong";
        }
    }

    public static void main(String[] args) throws Exception {
        MockMvc mockMvc = MockMvcBuilders.standaloneSetup(new PingController())
                .addInterceptors(new TimingInterceptorForTest())
                .build();

        mockMvc.perform(get("/ping"))
                .andExpect(status().isOk())
                // header().exists(...): yanıtta bu header'ın var olduğunu doğrular --
                // değeri her koşuda değişeceği (gerçek geçen süre) için exists() burada
                // string(...) ile tam eşleşme aramaktan daha doğru bir seçimdir.
                .andExpect(header().exists("X-Response-Time-Ms"));

        System.out.println("Interceptor, standaloneSetup ile izole test edildi.");

        // addInterceptors(...) burada TÜM path'lere uygulanır -- gerçek uygulamada
        // olduğu gibi belirli bir path pattern'ine sınırlamak isterseniz
        // MockMvcBuilders'ın standalone API'si bunu doğrudan desteklemez; bu durumda
        // interceptor'ın kendi içindeki path kontrolünü (varsa) test etmeniz gerekir.
    }
}

TimingInterceptorForTest is a small, realistic HandlerInterceptor that adds an X-Response-Time-Ms header to every request. TimingInterceptorMockMvcTest attaches it directly to MockMvc with standaloneSetup(...).addInterceptors(...) -- verifying the interceptor ON ITS OWN, without ever bringing in the whole application's configuration (path patterns, other interceptors).