Spring Boot

MockMvc

MockMvc lets you exercise Spring MVC controllers with real request mapping, argument binding, validation, JSON conversion, exception handlers and (optionally) the Spring Security filter chain, without opening a network port. This guide explains the three ways to set it up and what each one loads, how to build requests and assert on responses, the CSRF and authentication details that make security tests pass for the wrong reason, validation and error-handler testing, file uploads, async handlers, view and redirect assertions, the newer AssertJ-style MockMvcTester, and the mistakes that make controller tests either brittle or meaningless.

Choosing a MockMvc Test Style

There are three ways to get a MockMvc instance, and the right one depends on what you want the test to prove. @WebMvcTest starts a slice of the application context containing only the web layer: controllers, controller advice, filters, converters, argument resolvers and WebMvcConfigurer beans. Services and repositories are absent, so you provide mocks for what the controller needs. It is the default choice, because it tests the real mapping, binding, validation and serialization, including the ObjectMapper configured by Spring Boot, in a fraction of the time of a full start. standaloneSetup builds MockMvc around one controller instance you create yourself, with no Spring context at all. It is very fast and good for pure controller logic, but it has no Spring Boot configuration. Jackson uses its plain defaults rather than your customised ObjectMapper, so date formats and property naming can differ from production, and there is no security unless you add filters by hand. @SpringBootTest with @AutoConfigureMockMvc starts the full application context and gives you MockMvc on top of it. Use it for a handful of tests that need the real service layer, security configuration and database together. Choose it sparingly, since each distinct configuration adds a context start to the build. One more option exists for completeness: @SpringBootTest with a real port and WebTestClient or TestRestTemplate sends actual HTTP requests. Reach for it only when you need the real servlet container, for example to test compression, HTTP/2 or streaming responses, which MockMvc cannot reproduce.
Java
// Typical static imports
// import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.*;
// import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*;
// import static org.springframework.test.web.servlet.result.MockMvcResultHandlers.*;
// import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.*;
// import static org.mockito.Mockito.*;
// import static org.hamcrest.Matchers.*;

// 1. Web slice (default choice)
@WebMvcTest(OrderController.class)
class OrderControllerTest {

    @Autowired private MockMvc mockMvc;
    @Autowired private ObjectMapper objectMapper;          // Spring Boot's configured mapper

    @MockitoBean private OrderService orderService;        // @MockBean before Spring Boot 3.4
}

// 2. Standalone: no Spring context, no Boot configuration, no security
class OrderControllerStandaloneTest {

    private MockMvc mockMvc;

    @BeforeEach
    void setUp() {
        mockMvc = MockMvcBuilders
            .standaloneSetup(new OrderController(mock(OrderService.class)))
            .setControllerAdvice(new GlobalExceptionHandler())
            .setMessageConverters(new MappingJackson2HttpMessageConverter(configuredMapper()))
            .addFilters(new CorrelationIdFilter())
            .build();
    }
}

// 3. Full application context
@SpringBootTest
@AutoConfigureMockMvc
class OrderFullStackTest {

    @Autowired private MockMvc mockMvc;      // real services, security and database
}

// What each loads
//                      controllers  advice  filters  security  Boot ObjectMapper  services
//   @WebMvcTest            yes       yes     yes      yes*          yes             mocks
//   standaloneSetup        one       manual  manual   no            no              mocks
//   @SpringBootTest        yes       yes     yes      yes           yes             real
// * your security configuration must be imported, see the next section

Setting Up a @WebMvcTest Slice

A web slice contains only web components, so every collaborator of the controller must be supplied. Declare each as @MockitoBean (Spring Framework 6.2 and Spring Boot 3.4 onward) or @MockBean on earlier versions, which is deprecated from 3.4. Beans of the same types the slice includes, such as a Filter you wrote and annotated @Component, are loaded too, and their own dependencies must therefore be mocked or the context fails to start. That is why a custom JWT filter drags JwtService and UserDetailsService into the test even when the test does not care about authentication. Slice tests do not scan @Configuration classes. If your security rules live in a SecurityConfig class and you do not import it with @Import, the test runs with Spring Boot's default security (everything authenticated, HTTP Basic and form login), which produces confusing 401 and 403 results that do not match your real rules. Import the configuration explicitly, along with any other configuration the controllers rely on, such as a custom Jackson module or method security. These mocks only need stubbing for the behaviour a test exercises. A mocked JwtService that is never asked anything does not need stubs, so avoid copying a block of @BeforeEach stubs into every test class; stub in the tests that need it, or provide authentication with the helpers shown in the security section.
Java
@WebMvcTest(OrderController.class)
@Import({SecurityConfig.class, JacksonConfig.class})   // slices do not scan @Configuration classes
class OrderControllerWebMvcTest {

    @Autowired private MockMvc mockMvc;

    @MockitoBean private OrderService orderService;

    // Needed only because JwtAuthenticationFilter is a @Component Filter,
    // which the slice loads. They exist to satisfy injection; stub only if a test needs to.
    @MockitoBean private JwtService jwtService;
    @MockitoBean private CustomUserDetailsService userDetailsService;

    @Test
    @WithMockUser
    void findById_returnsOrder() throws Exception {
        when(orderService.findById(eq(1L), any())).thenReturn(buildOrderResponse(1L));

        mockMvc.perform(get("/api/v1/orders/1"))
            .andExpect(status().isOk())
            .andExpect(jsonPath("$.id").value(1));
    }
}

// Several controllers? List them: @WebMvcTest({OrderController.class, ProductController.class})
// No controller named: ALL controllers are loaded and ALL their dependencies need mocks.

// Disable security filters entirely (rarely a good idea, hides real behaviour):
//   @AutoConfigureMockMvc(addFilters = false)
// Prefer keeping security on and authenticating the request.

Building Requests

Requests are assembled with the static builders in MockMvcRequestBuilders: get, post, put, patch, delete, multipart and the generic request. Everything a real request carries can be set on the builder: query parameters, path variables, headers, cookies, session attributes, request attributes, locale, content type and body. A path variable goes in the URI template, and the builder encodes it for you. Build JSON bodies by serialising a request object with the injected ObjectMapper rather than hand-writing strings. It keeps the test in step with renamed fields and exercises the same configuration production uses. Hand-written JSON is the right choice when you specifically want to send malformed or unusual input, such as a missing field, an unknown property or the wrong type. For repeated setup, use a RequestPostProcessor, which can modify the request after it is built, as the Spring Security helpers do. Writing one for something like a tenant header keeps tests short.
Java
// Query params, path variables, headers
mockMvc.perform(get("/api/v1/products/{id}/reviews", 42)
        .param("page", "0")
        .param("size", "20")
        .param("sort", "createdAt,desc")
        .header("Accept-Language", "fr")
        .header("X-Tenant", "acme")
        .accept(MediaType.APPLICATION_JSON));

// JSON body from an object
String json = objectMapper.writeValueAsString(
    new CreateProductRequest("Widget", new BigDecimal("9.99"), "electronics"));

mockMvc.perform(post("/api/v1/products")
        .contentType(MediaType.APPLICATION_JSON)
        .content(json)
        .with(csrf()));                         // see the security section

// Raw JSON, to test malformed or unexpected input on purpose
mockMvc.perform(post("/api/v1/products")
        .contentType(MediaType.APPLICATION_JSON)
        .content("{\"name\": 123, \"unknown\": true}")
        .with(csrf()))
    .andExpect(status().isBadRequest());

// Cookies, session, request attributes
mockMvc.perform(get("/cart")
        .cookie(new Cookie("cartId", "abc"))
        .sessionAttr("visits", 3)
        .requestAttr("traceId", "t-1")
        .locale(Locale.GERMANY));

// Form submission
mockMvc.perform(post("/register")
        .contentType(MediaType.APPLICATION_FORM_URLENCODED)
        .param("username", "alice")
        .param("email", "alice@example.com")
        .with(csrf()));

// A reusable post-processor
static RequestPostProcessor tenant(String id) {
    return request -> {
        request.addHeader("X-Tenant", id);
        return request;
    };
}
// mockMvc.perform(get("/api/v1/orders").with(tenant("acme")))

Asserting on the Response

Chain andExpect calls after perform to check the status, headers, cookies, content type and body. For JSON bodies there are two complementary tools. jsonPath extracts a value with a path expression and compares it with a value or a Hamcrest matcher, which is ideal for spot checks. content().json(expected) compares a whole document with the expected JSON and, in its default lenient mode, ignores extra fields and array order, which suits a "this subset must be present" check; pass true as the second argument for strict comparison. Test what the API promises, not everything the object contains. A test that asserts every field of a large response breaks whenever someone adds a harmless field. Cover the identifying fields, the shape clients depend on, and, equally important, the absence of fields that must never be exposed, such as password hashes. Use andDo(print()) while you are debugging and remove it afterwards, because it floods the build log. If you want output only on failure, andDo(log()) is quieter. The MvcResult from andReturn gives access to the raw request, response and resolved exception when a matcher is not enough. For pagination, assert on your own response type. Serialising Spring Data's PageImpl directly produces a structure (number, size, totalElements, first, last, and so on, with no page field) that Spring Data now warns is unstable, and recent versions recommend a stable DTO or PagedModel. A small PageResponse record that you own is easier to assert on and to keep compatible.
Java
// Your own page DTO: stable shape, easy to assert on
public record PageResponse<T>(List<T> content, int page, int size,
                              long totalElements, int totalPages,
                              boolean first, boolean last) { }

@Test
@WithMockUser
void findAll_returnsPagedProducts() throws Exception {
    when(productService.findAll(any(Pageable.class))).thenReturn(
        new PageResponse<>(List.of(productResponse(1L, "Widget"), productResponse(2L, "Gadget")),
                           0, 20, 2, 1, true, true));

    mockMvc.perform(get("/api/v1/products").accept(MediaType.APPLICATION_JSON))
        .andExpect(status().isOk())
        .andExpect(content().contentTypeCompatibleWith(MediaType.APPLICATION_JSON))

        // spot checks with JSONPath
        .andExpect(jsonPath("$.content.length()").value(2))
        .andExpect(jsonPath("$.content[0].name").value("Widget"))
        .andExpect(jsonPath("$.content[*].id", contains(1, 2)))
        .andExpect(jsonPath("$.totalElements").value(2))
        .andExpect(jsonPath("$.first").value(true))

        // types and existence
        .andExpect(jsonPath("$.content").isArray())
        .andExpect(jsonPath("$.totalPages").isNumber())

        // fields that must never leak
        .andExpect(jsonPath("$.content[0].internalCost").doesNotExist());
}

// Whole-document comparison, lenient by default (extra fields and array order ignored)
mockMvc.perform(get("/api/v1/products/1"))
    .andExpect(content().json("{\"id\":1,\"name\":\"Widget\"}"));

// Strict: exact match
//   .andExpect(content().json(expected, true))

// Headers, cookies
.andExpect(header().string("Cache-Control", "no-store"))
.andExpect(header().exists("ETag"))
.andExpect(cookie().exists("SESSION"))

// Reading the result for extra assertions
MvcResult result = mockMvc.perform(get("/api/v1/products/1")).andReturn();
ProductResponse body = objectMapper.readValue(
    result.getResponse().getContentAsString(), ProductResponse.class);
assertThat(body.price()).isEqualByComparingTo("9.99");

// Debugging only: andDo(print()). Quieter alternative: andDo(log())

Creating Resources: Status, Location and Body

A create endpoint has a small contract worth asserting in full: a 201 status, a Location header pointing at the new resource, and the body. Check the Location header's path rather than the full URL, since MockMvc requests use http://localhost as the host. Also verify the interaction that matters, that the service received what the client sent, by capturing the argument and asserting on it. That confirms the JSON was bound to the request object correctly, which is something a mocked service can otherwise hide. Remember that state-changing methods need a CSRF token when CSRF protection is on, covered in the next section; a missing token fails the test with 403 before the controller is reached.
Java
@Test
@WithMockUser
void create_returns201WithLocation() throws Exception {
    when(productService.create(any())).thenReturn(productResponse(42L, "Widget"));

    String body = objectMapper.writeValueAsString(
        new CreateProductRequest("Widget", new BigDecimal("9.99"), "electronics"));

    mockMvc.perform(post("/api/v1/products")
            .with(csrf())
            .contentType(MediaType.APPLICATION_JSON)
            .content(body))
        .andExpect(status().isCreated())
        .andExpect(header().string("Location", endsWith("/api/v1/products/42")))
        .andExpect(jsonPath("$.id").value(42))
        .andExpect(jsonPath("$.name").value("Widget"));

    // The JSON was bound correctly into the request object
    ArgumentCaptor<CreateProductRequest> captor = ArgumentCaptor.forClass(CreateProductRequest.class);
    verify(productService).create(captor.capture());
    assertThat(captor.getValue().name()).isEqualTo("Widget");
    assertThat(captor.getValue().price()).isEqualByComparingTo("9.99");
}

@Test
@WithMockUser
void delete_returns204() throws Exception {
    mockMvc.perform(delete("/api/v1/products/42").with(csrf()))
        .andExpect(status().isNoContent())
        .andExpect(content().string(""));

    verify(productService).delete(42L);
}

Security Testing: CSRF, Authentication and Roles

Security tests are where MockMvc suites most often go wrong, because several failures look alike. The first is CSRF. If CSRF protection is on (the default for applications with sessions), every POST, PUT, PATCH and DELETE without a valid token is rejected with 403 before reaching your controller. Add .with(csrf()) to such requests. This also means that a test asserting 403 for "a user without the right role" can pass for the wrong reason: the 403 came from the missing token, not from the role check. Always include csrf() on state-changing requests when testing authorization, so a 403 can only mean the role was insufficient. The second is the difference between 401 and a redirect. An unauthenticated request gets 401 only if the application is configured with an entry point that returns it, as HTTP Basic or a bearer-token setup does. With form login, the response is a 302 redirect to the login page. Assert what your configuration produces. Authenticate requests with the helpers from spring-security-test. @WithMockUser sets a simple principal for the whole test. The user() post-processor does so per request, accepts a full UserDetails, and is useful when the controller injects your own principal type through @AuthenticationPrincipal. For a resource server using JWTs, the jwt() post-processor builds an authenticated JwtAuthenticationToken with claims and authorities you choose, so there is no need to sign real tokens or stub a JwtDecoder; oauth2Login() and oidcLogin() do the same for OAuth2 Login. Use authentication(...) to supply any Authentication object. Avoid @WithUserDetails in a slice test where the UserDetailsService is a mock. It loads the user through that bean, and a mock returns null, so the test fails in setup. It suits full-context tests with a real user store. If the controller uses method security such as @PreAuthorize, import the configuration that enables it (@EnableMethodSecurity), or the annotations are silently inactive in the slice and a test of a forbidden call will wrongly succeed.
Java
@WebMvcTest(AdminController.class)
@Import(SecurityConfig.class)
class AdminControllerSecurityTest {

    @Autowired private MockMvc mockMvc;
    @MockitoBean private AdminService adminService;
    @MockitoBean private JwtService jwtService;                       // only to satisfy the filter
    @MockitoBean private CustomUserDetailsService userDetailsService;

    // 401 when no credentials (this app uses bearer tokens, so it returns 401, not a redirect)
    @Test
    void stats_isUnauthorized_withoutCredentials() throws Exception {
        mockMvc.perform(get("/api/v1/admin/stats"))
            .andExpect(status().isUnauthorized());
    }

    // 403 because of the ROLE, not because of CSRF: csrf() is included on purpose
    @Test
    @WithMockUser(roles = "USER")
    void deleteUser_isForbidden_forRegularUser() throws Exception {
        mockMvc.perform(delete("/api/v1/admin/users/1").with(csrf()))
            .andExpect(status().isForbidden());

        verifyNoInteractions(adminService);
    }

    @Test
    @WithMockUser(roles = "ADMIN")
    void deleteUser_isAllowed_forAdmin() throws Exception {
        mockMvc.perform(delete("/api/v1/admin/users/1").with(csrf()))
            .andExpect(status().isNoContent());

        verify(adminService).deleteUser(1L);
    }

    // Without csrf() this would be 403 for the wrong reason
    @Test
    @WithMockUser(roles = "ADMIN")
    void missingCsrfToken_isRejected() throws Exception {
        mockMvc.perform(delete("/api/v1/admin/users/1"))
            .andExpect(status().isForbidden());
    }

    // Per-request principal: needed when the controller uses @AuthenticationPrincipal AppUser
    @Test
    void profile_usesTheAuthenticatedPrincipal() throws Exception {
        AppUser principal = new AppUser(7L, "alice@example.com", Set.of("ROLE_USER"));
        when(adminService.profile(7L)).thenReturn(profileResponse(7L));

        mockMvc.perform(get("/api/v1/me").with(user(principal)))     // principal must be a UserDetails
            .andExpect(status().isOk())
            .andExpect(jsonPath("$.id").value(7));
    }

    // Resource server with JWTs: no real token, no JwtDecoder stubbing
    @Test
    void reports_requiresScope() throws Exception {
        mockMvc.perform(get("/api/v1/reports")
                .with(jwt().jwt(j -> j.subject("alice").claim("scope", "reports:read"))
                           .authorities(new SimpleGrantedAuthority("SCOPE_reports:read"))))
            .andExpect(status().isOk());

        mockMvc.perform(get("/api/v1/reports").with(jwt()))          // no scope
            .andExpect(status().isForbidden());
    }
}

// Other helpers
//   .with(httpBasic("user", "password"))
//   .with(oauth2Login()) / .with(oidcLogin())
//   .with(authentication(customAuthentication))
//   .with(anonymous())
//   .with(csrf().asHeader())    // token as X-CSRF-TOKEN header instead of parameter
//
// Stateless APIs that disable CSRF in production do not need csrf(); the test then
// matches the configuration it imports.

Testing Validation and Error Handling

Validation and exception mapping are part of the web layer, so this is the right place to test them. In a @WebMvcTest, your @ControllerAdvice is loaded and constraint annotations on @Valid request bodies are enforced, so a test can submit an invalid payload and check the 400 response and its error structure. Verify that the service is not called when validation fails, since preventing that call is the purpose of validating. For failures originating in a service, stub the mock to throw the domain exception and assert on the status and body your exception handler produces. If an exception is not handled by any advice, MockMvc does not turn it into a 500 response; it propagates out of perform wrapped in a ServletException, which is a useful signal that a mapping is missing. When the application returns RFC 9457 problem details, assert on the standard members: status, title, detail and type. For an unexpected failure, check that the response does not leak internals such as stack traces or exception class names.
Java
@Test
@WithMockUser
void create_returns400_whenNameIsBlank() throws Exception {
    String invalid = objectMapper.writeValueAsString(
        new CreateProductRequest("", new BigDecimal("9.99"), "electronics"));

    mockMvc.perform(post("/api/v1/products")
            .with(csrf())
            .contentType(MediaType.APPLICATION_JSON)
            .content(invalid))
        .andExpect(status().isBadRequest())
        .andExpect(jsonPath("$.fieldErrors.name").isNotEmpty())
        .andExpect(result -> assertThat(result.getResolvedException())
            .isInstanceOf(MethodArgumentNotValidException.class));

    verifyNoInteractions(productService);
}

@Test
@WithMockUser
void create_returns400_forMalformedJson() throws Exception {
    mockMvc.perform(post("/api/v1/products")
            .with(csrf())
            .contentType(MediaType.APPLICATION_JSON)
            .content("{not json"))
        .andExpect(status().isBadRequest());
}

// Domain exception mapped by @ControllerAdvice (ProblemDetail response)
@Test
@WithMockUser
void findById_returns404_whenMissing() throws Exception {
    when(productService.findById(99L)).thenThrow(new ProductNotFoundException(99L));

    mockMvc.perform(get("/api/v1/products/99"))
        .andExpect(status().isNotFound())
        .andExpect(content().contentTypeCompatibleWith(MediaType.APPLICATION_PROBLEM_JSON))
        .andExpect(jsonPath("$.status").value(404))
        .andExpect(jsonPath("$.title").value("Product not found"))
        .andExpect(jsonPath("$.trace").doesNotExist());       // no stack trace in the body
}

// An exception no advice handles propagates out of perform()
@Test
@WithMockUser
void unmappedException_propagates() {
    when(productService.findById(1L)).thenThrow(new IllegalStateException("boom"));

    assertThatThrownBy(() -> mockMvc.perform(get("/api/v1/products/1")))
        .hasCauseInstanceOf(IllegalStateException.class);
}

// Wrong HTTP method / media type
mockMvc.perform(put("/api/v1/products").with(csrf()))
    .andExpect(status().isMethodNotAllowed());
mockMvc.perform(post("/api/v1/products").with(csrf()).contentType(MediaType.TEXT_PLAIN).content("x"))
    .andExpect(status().isUnsupportedMediaType());

File Uploads and Multipart Requests

Use the multipart builder with MockMultipartFile to simulate uploads. The first constructor argument is the form field name and must match the @RequestParam or @RequestPart name in the controller. Add further file calls with the same name for a list of files, and ordinary form fields with param. When an endpoint accepts a JSON part alongside a file (@RequestPart), send the JSON as a MockMultipartFile with the application/json content type, because that is how the browser's FormData encodes it. Know what MockMvc does not test. Container-level limits such as spring.servlet.multipart.max-file-size are enforced by the servlet container's multipart handling, which MockMvc bypasses, so an oversized file will not be rejected by a MockMvc test. Test any limit you check in your own code with a controller or service test, and verify the container property with an integration test against a real server. The same applies to content sniffing: assert the validation you wrote, such as rejecting an empty file or a disallowed type, not Spring's built-in limits. Multipart requests are POST by default. For PUT or PATCH, post-process the request to change the method.
Java
@WebMvcTest(FileController.class)
@Import(SecurityConfig.class)
class FileControllerTest {

    @Autowired private MockMvc mockMvc;
    @MockitoBean private FileService fileService;

    @Test
    @WithMockUser
    void upload_returns201() throws Exception {
        MockMultipartFile file = new MockMultipartFile(
            "file",                                  // must match @RequestParam("file")
            "document.pdf",
            MediaType.APPLICATION_PDF_VALUE,
            "PDF content".getBytes(StandardCharsets.UTF_8));

        when(fileService.store(any(MultipartFile.class), any()))
            .thenReturn(new FileUploadResponse("uuid-1", "document.pdf", "application/pdf", 11L));

        mockMvc.perform(multipart("/api/v1/files")
                .file(file)
                .param("description", "My document")
                .with(csrf()))
            .andExpect(status().isCreated())
            .andExpect(jsonPath("$.fileId").value("uuid-1"))
            .andExpect(jsonPath("$.filename").value("document.pdf"));
    }

    @Test
    @WithMockUser
    void upload_multipleFilesWithSameName() throws Exception {
        MockMultipartFile a = new MockMultipartFile("files", "a.jpg", "image/jpeg", new byte[] {1});
        MockMultipartFile b = new MockMultipartFile("files", "b.jpg", "image/jpeg", new byte[] {2});

        mockMvc.perform(multipart("/api/v1/files/batch").file(a).file(b).with(csrf()))
            .andExpect(status().isCreated());
    }

    // File plus a JSON part (@RequestPart("meta") FileMeta meta)
    @Test
    @WithMockUser
    void upload_withJsonPart() throws Exception {
        MockMultipartFile file = new MockMultipartFile("file", "a.txt", "text/plain", "hi".getBytes());
        MockMultipartFile meta = new MockMultipartFile(
            "meta", "", MediaType.APPLICATION_JSON_VALUE,
            "{\"folder\":\"reports\"}".getBytes(StandardCharsets.UTF_8));

        mockMvc.perform(multipart("/api/v1/files").file(file).file(meta).with(csrf()))
            .andExpect(status().isCreated());
    }

    // Validation YOU wrote: empty files rejected
    @Test
    @WithMockUser
    void upload_returns400_forEmptyFile() throws Exception {
        MockMultipartFile empty = new MockMultipartFile("file", "empty.txt", "text/plain", new byte[0]);

        mockMvc.perform(multipart("/api/v1/files").file(empty).with(csrf()))
            .andExpect(status().isBadRequest());

        verifyNoInteractions(fileService);
    }

    // PUT with multipart: change the method of the built request
    @Test
    @WithMockUser
    void replace_usesPut() throws Exception {
        MockMultipartFile file = new MockMultipartFile("file", "a.txt", "text/plain", "v2".getBytes());

        mockMvc.perform(multipart("/api/v1/files/uuid-1")
                .file(file)
                .with(request -> { request.setMethod("PUT"); return request; })
                .with(csrf()))
            .andExpect(status().isOk());
    }
}

// Not enforced by MockMvc: spring.servlet.multipart.max-file-size / max-request-size.
// Test those against a real server (@SpringBootTest with a port).

Async Handlers

When a controller returns a Callable, DeferredResult or CompletableFuture, the servlet request is put into asynchronous mode and the first perform call returns before the result exists. A test must assert that async processing started, then dispatch the async result and assert on the final response. Skipping the second step is a classic mistake: the test appears to pass because the first response has a 200 status and an empty body. Streaming formats such as Server-Sent Events and long-lived response streams are not well suited to MockMvc. It can show that streaming started, but cannot read a response as it arrives. Use WebTestClient against a running server for those.
Java
// Controller: Callable / DeferredResult / CompletableFuture
@GetMapping("/api/v1/reports/{id}")
public CompletableFuture<ReportResponse> report(@PathVariable Long id) {
    return reportService.generateAsync(id);
}

@Test
@WithMockUser
void report_completesAsynchronously() throws Exception {
    when(reportService.generateAsync(1L))
        .thenReturn(CompletableFuture.completedFuture(new ReportResponse(1L, "Q3")));

    // 1. The request starts async processing
    MvcResult started = mockMvc.perform(get("/api/v1/reports/1"))
        .andExpect(request().asyncStarted())
        .andReturn();

    // 2. Dispatch the async result and assert the real response
    mockMvc.perform(asyncDispatch(started))
        .andExpect(status().isOk())
        .andExpect(jsonPath("$.title").value("Q3"));
}

// A future that fails: assert the mapped error
//   when(reportService.generateAsync(2L)).thenReturn(CompletableFuture.failedFuture(new ReportException()))
//   ... same two steps, then .andExpect(status().isInternalServerError())

// SSE / streaming: use WebTestClient with @SpringBootTest(webEnvironment = RANDOM_PORT)

Server-Rendered Views, Redirects and Flash Attributes

For controllers that return view names rather than JSON, the model, view and redirect matchers check the controller's decisions without rendering a template. view().name verifies which template would be used, model() verifies the attributes passed to it including binding errors, redirectedUrl and flash() check Post/Redirect/Get behaviour. MockMvc does not render the template itself in a slice test with a mocked view technology, so test the markup of Thymeleaf pages with a separate approach if that matters.
Java
@Test
@WithMockUser
void showForm_populatesModel() throws Exception {
    mockMvc.perform(get("/register"))
        .andExpect(status().isOk())
        .andExpect(view().name("register"))
        .andExpect(model().attributeExists("form"));
}

@Test
@WithMockUser
void submit_withErrors_redisplaysForm() throws Exception {
    mockMvc.perform(post("/register").with(csrf())
            .param("username", "ab")                       // too short
            .param("email", "nope"))
        .andExpect(status().isOk())
        .andExpect(view().name("register"))
        .andExpect(model().attributeHasFieldErrors("form", "username", "email"))
        .andExpect(model().attributeHasFieldErrorCode("form", "email", "Email"));
}

@Test
@WithMockUser
void submit_success_redirectsWithFlashMessage() throws Exception {
    mockMvc.perform(post("/register").with(csrf())
            .param("username", "alice")
            .param("email", "alice@example.com")
            .param("password", "Passw0rd!x"))
        .andExpect(status().is3xxRedirection())
        .andExpect(redirectedUrl("/login"))
        .andExpect(flash().attribute("message", "Account created"));
}

MockMvcTester: AssertJ-Style Assertions

Spring Framework 6.2 adds MockMvcTester, a wrapper around MockMvc whose results are asserted with AssertJ instead of the andExpect chain, and Spring Boot 3.4 can inject it directly in @WebMvcTest tests. The AssertJ style reads more like the rest of a modern test suite, gives better failure messages, and treats unhandled exceptions as part of the result so you can assert them without wrapping the call. The classic MockMvc API remains fully supported, so adopting it is optional and can be done gradually, one test class at a time. Both approaches test the same machinery. Pick one style per class to keep tests consistent.
Java
// Spring Framework 6.2+ / Spring Boot 3.4+
@WebMvcTest(ProductController.class)
class ProductControllerTesterTest {

    @Autowired private MockMvcTester mvc;
    @MockitoBean private ProductService productService;

    @Test
    @WithMockUser
    void findById_returnsProduct() {
        when(productService.findById(1L)).thenReturn(productResponse(1L, "Widget"));

        assertThat(mvc.get().uri("/api/v1/products/{id}", 1L).accept(MediaType.APPLICATION_JSON))
            .hasStatusOk()
            .hasContentTypeCompatibleWith(MediaType.APPLICATION_JSON)
            .bodyJson()
            .extractingPath("$.name").isEqualTo("Widget");
    }

    @Test
    @WithMockUser
    void create_withBlankName_isBadRequest() {
        assertThat(mvc.post().uri("/api/v1/products")
                .with(csrf())
                .contentType(MediaType.APPLICATION_JSON)
                .content("{\"name\":\"\",\"price\":9.99}"))
            .hasStatus(HttpStatus.BAD_REQUEST);
    }

    // Unhandled exceptions can be asserted directly, with no assertThatThrownBy wrapper
    @Test
    @WithMockUser
    void unmappedException() {
        when(productService.findById(1L)).thenThrow(new IllegalStateException("boom"));

        assertThat(mvc.get().uri("/api/v1/products/1"))
            .failure().hasCauseInstanceOf(IllegalStateException.class);
    }
}

Common Pitfalls

Most weak MockMvc tests share a few causes. They assert 403 without a CSRF token, so the check says nothing about roles. They run without the real security configuration, so access rules are never exercised. They stub a mock and assert on the stub's value, proving nothing about the controller. They check only the status code and skip the body or the interaction with the service. Or they rely on the wrong JSON structure, for instance asserting top-level paging fields on a serialised PageImpl. On the practical side, remember that a slice test only knows about beans of web components, that @MockitoBean replacements count towards the context cache key, and that a controller test should not need a database. If you find yourself wanting one, you are testing more than the controller; move the test to an integration test with the real service layer.
Shell
# Symptom -> cause -> fix

# 403 on every POST/PUT/DELETE in tests
#   CSRF protection is on and the request has no token.
#   -> .with(csrf()), or disable CSRF in the real config if the API is stateless.

# A "forbidden for wrong role" test passes but the role check is broken
#   The 403 was caused by the missing CSRF token.
#   -> add csrf(), and assert verifyNoInteractions(service) to prove the controller was not reached.

# 401 expected but got 302 (or the reverse)
#   Form login redirects; bearer/basic returns 401.
#   -> assert what your entry point actually produces.

# Security rules do not match production
#   SecurityConfig was not imported into the slice (slices skip @Configuration scanning),
#   so Boot's default security applies.
#   -> @Import(SecurityConfig.class); also import @EnableMethodSecurity configuration.

# Context fails to load: No qualifying bean of type JwtService / UserDetailsService
#   A @Component Filter is loaded by the slice and needs its dependencies.
#   -> @MockitoBean them (stubs are only needed when a test depends on them).

# @WithUserDetails fails with a mocked UserDetailsService
#   The mock returns null.
#   -> use @WithMockUser, user(...), jwt(), or a full-context test with a real user store.

# jsonPath returns unexpected structure for pages
#   Spring Data PageImpl has no "page" field and its JSON shape is not guaranteed.
#   -> return your own PageResponse DTO and assert on it.

# Dates or enums differ from production
#   standaloneSetup uses plain Jackson defaults, not the Boot-configured ObjectMapper.
#   -> prefer @WebMvcTest, or pass your mapper to setMessageConverters(...).

# Async endpoint test "passes" with an empty body
#   Only the first dispatch was asserted.
#   -> assert asyncStarted(), then perform(asyncDispatch(result)).

# Oversized upload accepted
#   MockMvc bypasses the container's multipart limits.
#   -> test your own checks in the slice; test spring.servlet.multipart.* with a real server.

# Build logs are flooded
#   andDo(print()) left in tests.
#   -> remove it, or use andDo(log()).

# Slow test suite
#   Many different @MockitoBean / @Import combinations, each creating a new context.
#   -> share mocks via a base class or a @TestConfiguration; keep controller tests free of databases.