Spring Boot
WebMvcTest
How to test Spring Boot controllers with @WebMvcTest and MockMvc: what the slice loads, request and response assertions, Bean Validation, exception handlers and Spring Security rules. The examples are written to pass as shown, and the page lists the traps that make correct-looking tests fail, such as missing CSRF tokens, catch-all exception handlers and duplicate validation messages.
What @WebMvcTest Loads
@WebMvcTest starts a slice of the application context that contains only the web layer: the controllers you name, @ControllerAdvice classes, filters, Jackson, validation and MockMvc. Services, repositories and other @Component beans are not created, so every dependency of the controller must be supplied as a mock with @MockitoBean. That is why these tests run in a fraction of the time of @SpringBootTest and why a failure points at the web layer rather than the database. If Spring Security is on the classpath, its filter chain is part of the slice too, which changes how the tests must be written (see the security section). The controller below is the one all examples on this page test.
Java
@RestController
@RequestMapping("/api/users")
public class UserController {
private final UserService userService;
public UserController(UserService userService) {
this.userService = userService;
}
@GetMapping
public List<UserResponse> findAll() {
return userService.findAll();
}
@GetMapping("/{id}")
public UserResponse findById(@PathVariable @Positive Long id) {
return userService.findById(id);
}
@PostMapping
public ResponseEntity<UserResponse> create(@Valid @RequestBody CreateUserRequest request) {
UserResponse created = userService.create(request);
return ResponseEntity.created(URI.create("/api/users/" + created.id())).body(created);
}
@DeleteMapping("/{id}")
@ResponseStatus(HttpStatus.NO_CONTENT)
public void delete(@PathVariable Long id) {
userService.delete(id);
}
}
public record UserResponse(Long id, String name, String email) { }
public record CreateUserRequest(
@NotBlank(message = "Name is required")
@Size(min = 2, max = 100, message = "Name must be 2-100 characters")
String name,
@NotBlank(message = "Email is required")
@Email(message = "Email must be a valid address")
String email,
@NotBlank(message = "Password is required")
@Size(min = 8, message = "Password must be at least 8 characters")
String password) { }Testing Endpoints
Each test does three things: stub the mocked service, perform a request with MockMvc, and assert on status, headers and JSON. Two habits make the tests more useful. Assert that the service was called with the right argument (verify), which proves the controller really mapped the request body; for the record used here, equals compares the values, so verify(userService).create(request) works after the JSON round trip. And remember that an unstubbed mock returns null or an empty collection, so a forgotten when(...) shows up as an empty body rather than an obvious error. These tests assume Spring Security is not on the classpath; if it is, add @WithMockUser to each test and .with(csrf()) to every POST, PUT and DELETE, otherwise you get 401 or 403 instead of the status you expect.
Java
@WebMvcTest(UserController.class)
class UserControllerTest {
@Autowired
private MockMvc mockMvc;
@Autowired
private ObjectMapper objectMapper;
@MockitoBean
private UserService userService;
@Test
void findById_returnsUser() throws Exception {
when(userService.findById(1L))
.thenReturn(new UserResponse(1L, "Alice", "alice@example.com"));
mockMvc.perform(get("/api/users/1").accept(MediaType.APPLICATION_JSON))
.andExpect(status().isOk())
.andExpect(jsonPath("$.id").value(1))
.andExpect(jsonPath("$.name").value("Alice"))
.andExpect(jsonPath("$.email").value("alice@example.com"));
}
@Test
void findById_unknownId_returns404() throws Exception {
when(userService.findById(99L))
.thenThrow(new ResourceNotFoundException("User 99 not found"));
mockMvc.perform(get("/api/users/99"))
.andExpect(status().isNotFound())
.andExpect(jsonPath("$.message").value("User 99 not found"));
}
@Test
void create_validRequest_returns201WithLocation() throws Exception {
CreateUserRequest request =
new CreateUserRequest("Bob", "bob@example.com", "password123");
when(userService.create(request))
.thenReturn(new UserResponse(2L, "Bob", "bob@example.com"));
mockMvc.perform(post("/api/users")
.contentType(MediaType.APPLICATION_JSON)
.content(objectMapper.writeValueAsString(request)))
.andExpect(status().isCreated())
.andExpect(header().string("Location", "/api/users/2"))
.andExpect(jsonPath("$.id").value(2));
verify(userService).create(request); // the controller passed the body through
}
@Test
void delete_existingUser_returns204() throws Exception {
mockMvc.perform(delete("/api/users/1"))
.andExpect(status().isNoContent());
verify(userService).delete(1L);
}
}Exception Handler Testing
@RestControllerAdvice classes are part of the slice, so test them through the controller that triggers them. Pay attention to the catch-all handler at the bottom: it is useful, but because it matches every Exception it also swallows exceptions that Spring would normally turn into a 400 or 404. Spring 6.1+ throws HandlerMethodValidationException for an invalid path variable or parameter, and without its own handler that case would be reported as a 500. The advice below handles it explicitly, and the tests prove it. The classes are named ApiError and FieldErrorDetail on purpose: ErrorResponse and FieldError already exist in Spring and make imports ambiguous.
Java
@RestControllerAdvice
public class GlobalExceptionHandler {
@ExceptionHandler(ResourceNotFoundException.class)
public ResponseEntity<ApiError> handleNotFound(ResourceNotFoundException ex) {
return ResponseEntity.status(HttpStatus.NOT_FOUND)
.body(new ApiError(404, ex.getMessage()));
}
@ExceptionHandler(MethodArgumentNotValidException.class)
public ResponseEntity<ValidationError> handleBodyValidation(MethodArgumentNotValidException ex) {
List<FieldErrorDetail> errors = ex.getBindingResult().getFieldErrors().stream()
.map(e -> new FieldErrorDetail(e.getField(), e.getDefaultMessage()))
.toList();
return ResponseEntity.badRequest().body(new ValidationError(400, errors));
}
@ExceptionHandler(HandlerMethodValidationException.class) // invalid @PathVariable / @RequestParam
public ResponseEntity<ApiError> handleParameterValidation(HandlerMethodValidationException ex) {
return ResponseEntity.badRequest().body(new ApiError(400, "Invalid request parameter"));
}
@ExceptionHandler(Exception.class) // last resort
public ResponseEntity<ApiError> handleGeneric(Exception ex) {
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR)
.body(new ApiError(500, "Internal server error"));
}
}
public record ApiError(int status, String message) { }
public record FieldErrorDetail(String field, String message) { }
public record ValidationError(int status, List<FieldErrorDetail> errors) { }
@WebMvcTest(UserController.class)
class GlobalExceptionHandlerTest {
@Autowired
private MockMvc mockMvc;
@MockitoBean
private UserService userService;
@Test
void unexpectedException_returns500WithoutLeakingDetails() throws Exception {
when(userService.findById(1L)).thenThrow(new RuntimeException("db password is hunter2"));
mockMvc.perform(get("/api/users/1"))
.andExpect(status().isInternalServerError())
.andExpect(jsonPath("$.status").value(500))
.andExpect(jsonPath("$.message").value("Internal server error"));
}
@Test
void negativeId_returns400NotServerError() throws Exception {
mockMvc.perform(get("/api/users/-1"))
.andExpect(status().isBadRequest())
.andExpect(jsonPath("$.message").value("Invalid request parameter"));
verifyNoInteractions(userService);
}
}Validation Testing
Test validation here, in the controller slice, because that is where the constraints are enforced and where the 400 response is built. Two details cause most broken validation tests. First, one invalid value can break several constraints: an empty name violates both @NotBlank and @Size(min = 2), so the response contains two messages for the field and an assertion for exactly one message fails. Assert with hasItem instead, or choose input that breaks one rule only (name "A" breaks only the size rule). Second, verifyNoInteractions(userService) proves the invalid request was rejected before it reached your business logic, which is the real point of validating at the edge. Do not put @Validated on the controller class for path-variable constraints: Spring 6.1+ validates method parameters natively, and the class-level annotation switches to the older proxy-based mechanism that throws a different exception.
Java
@WebMvcTest(UserController.class)
class UserControllerValidationTest {
@Autowired
private MockMvc mockMvc;
@Autowired
private ObjectMapper objectMapper;
@MockitoBean
private UserService userService;
@Test
void blankName_returns400() throws Exception {
CreateUserRequest request = new CreateUserRequest("", "alice@example.com", "password123");
mockMvc.perform(post("/api/users")
.contentType(MediaType.APPLICATION_JSON)
.content(objectMapper.writeValueAsString(request)))
.andExpect(status().isBadRequest())
// a blank name breaks two rules, so check that the message is among the errors
.andExpect(jsonPath("$.errors[?(@.field=='name')].message",
hasItem("Name is required")));
verifyNoInteractions(userService);
}
@Test
void invalidEmail_returns400() throws Exception {
CreateUserRequest request = new CreateUserRequest("Alice", "not-an-email", "password123");
mockMvc.perform(post("/api/users")
.contentType(MediaType.APPLICATION_JSON)
.content(objectMapper.writeValueAsString(request)))
.andExpect(status().isBadRequest())
.andExpect(jsonPath("$.errors[?(@.field=='email')].message",
hasItem("Email must be a valid address")));
}
@Test
void severalInvalidFields_reportsEachField() throws Exception {
String body = """
{ "name": "A", "email": "not-an-email", "password": "short" }
""";
mockMvc.perform(post("/api/users")
.contentType(MediaType.APPLICATION_JSON)
.content(body))
.andExpect(status().isBadRequest())
.andExpect(jsonPath("$.errors[*].field",
containsInAnyOrder("name", "email", "password")));
}
@Test
void malformedJson_returns400() throws Exception {
mockMvc.perform(post("/api/users")
.contentType(MediaType.APPLICATION_JSON)
.content("{ not json"))
.andExpect(status().is4xxClientError());
}
}Security Testing
With Spring Security present, @WebMvcTest applies the security filter chain, but only your own SecurityConfig if you @Import it; otherwise Spring Boot's default (everything requires authentication, form login, CSRF on) is used. The rules being tested must exist in the code: the examples assume method security on the controller and a stateless API configuration with CSRF disabled. If your configuration keeps CSRF enabled, add .with(csrf()) to POST, PUT and DELETE requests, and expect 403 without it. Add the spring-security-test dependency for @WithMockUser. Note that @WithMockUser creates an authenticated user with the roles you give it; it does not run your login code, so use it to test authorization rules, not authentication.
Java
// Additions to UserController
@GetMapping
@PreAuthorize("hasRole('ADMIN')")
public List<UserResponse> findAll() { ... }
@GetMapping("/{id}")
@PreAuthorize("hasRole('ADMIN') or #id.toString() == authentication.name")
public UserResponse findById(@PathVariable @Positive Long id) { ... }
// Security configuration under test
@Configuration
@EnableMethodSecurity
class SecurityConfig {
@Bean
SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
return http
.csrf(csrf -> csrf.disable())
.authorizeHttpRequests(auth -> auth.anyRequest().authenticated())
.httpBasic(Customizer.withDefaults()) // unauthenticated requests get 401
.build();
}
}
@WebMvcTest(UserController.class)
@Import(SecurityConfig.class)
class UserControllerSecurityTest {
@Autowired
private MockMvc mockMvc;
@MockitoBean
private UserService userService;
@Test
void findAll_withoutLogin_returns401() throws Exception {
mockMvc.perform(get("/api/users"))
.andExpect(status().isUnauthorized());
}
@Test
@WithMockUser(roles = "USER")
void findAll_asUser_returns403() throws Exception {
mockMvc.perform(get("/api/users"))
.andExpect(status().isForbidden());
}
@Test
@WithMockUser(roles = "ADMIN")
void findAll_asAdmin_returns200() throws Exception {
when(userService.findAll()).thenReturn(List.of());
mockMvc.perform(get("/api/users"))
.andExpect(status().isOk());
}
@Test
@WithMockUser(username = "42", roles = "USER")
void findById_ownAccount_returns200() throws Exception {
when(userService.findById(42L)).thenReturn(new UserResponse(42L, "Alice", "a@example.com"));
mockMvc.perform(get("/api/users/42"))
.andExpect(status().isOk());
}
@Test
@WithMockUser(username = "99", roles = "USER")
void findById_otherUsersAccount_returns403() throws Exception {
mockMvc.perform(get("/api/users/42"))
.andExpect(status().isForbidden());
verifyNoInteractions(userService); // the rule fired before the service was reached
}
}Testing a JWT Resource Server
If the application is an OAuth2 resource server (it validates bearer tokens with oauth2ResourceServer().jwt()), do not build real tokens in controller tests. Spring Security's jwt() request post-processor puts a ready-made authenticated JWT into the request, and you supply the authorities you want to test. Because the slice still needs a JwtDecoder bean to start, mock it. This approach is separate from the HTTP Basic configuration in the previous section; use whichever matches how your application actually authenticates.
Java
@WebMvcTest(UserController.class)
@Import(ResourceServerSecurityConfig.class) // configured with oauth2ResourceServer().jwt()
class UserControllerJwtTest {
@Autowired
private MockMvc mockMvc;
@MockitoBean
private UserService userService;
@MockitoBean
private JwtDecoder jwtDecoder; // required to start the slice; never called here
@Test
void findAll_withAdminAuthority_returns200() throws Exception {
when(userService.findAll()).thenReturn(List.of());
mockMvc.perform(get("/api/users")
.with(jwt().authorities(new SimpleGrantedAuthority("ROLE_ADMIN"))))
.andExpect(status().isOk());
}
@Test
void findAll_withoutToken_returns401() throws Exception {
mockMvc.perform(get("/api/users"))
.andExpect(status().isUnauthorized());
}
}Spring Boot Version Differences
Most breakage when upgrading comes from the mocking annotation. @MockBean was deprecated in Spring Boot 3.4 and removed in Spring Boot 4, where @MockitoBean (from Spring Framework) replaces it; @MockitoBean works on Boot 3.4 as well, so you can switch early. Spring Boot 4 also moved @WebMvcTest and @AutoConfigureMockMvc to a new package and a separate test starter, and it uses Jackson 3 by default, so check the ObjectMapper import against the migration guide for your exact version.
Text
Spring Boot 3.3 and earlier
@MockBean org.springframework.boot.test.mock.mockito.MockBean
@WebMvcTest org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest
Spring Boot 3.4
@MockBean deprecated
@MockitoBean org.springframework.test.context.bean.override.mockito.MockitoBean
Spring Boot 4
@MockBean removed
@MockitoBean same import as above
@WebMvcTest org.springframework.boot.webmvc.test.autoconfigure.WebMvcTest
test dependency spring-boot-starter-webmvc-testErrors You Will Probably Hit
"No qualifying bean of type UserService" means a controller dependency is not mocked; add a @MockitoBean for it. Every request returning 401 or 403 means Spring Security's default chain is active: add @WithMockUser, import your SecurityConfig, and use .with(csrf()) on state-changing requests if CSRF is enabled. A status 415 means the POST had no contentType(APPLICATION_JSON). A 200 with an empty body, or "No value at JSON path", usually means the service mock was not stubbed and returned null. A 500 where you expected 400 or 404 is almost always a catch-all @ExceptionHandler(Exception.class) hiding a more specific Spring exception. Startup errors such as "Not a managed type" or a JPA metamodel failure appear when @EnableJpaRepositories or @EntityScan sits on your main application class, because the slice loads that class; move those annotations into a separate @Configuration class.
Java
// Move slice-breaking annotations off the main class
@SpringBootApplication
public class MyApplication { }
@Configuration
@EnableJpaRepositories(basePackages = "com.example.repository")
@EntityScan(basePackages = "com.example.domain")
class PersistenceConfig { }Choosing the Right Kind of Test
@WebMvcTest answers one question: does the web layer behave correctly when the service behind it is replaced by a mock? It cannot tell you whether the real service, the database or the wiring between them works. Use it for status codes, JSON shape, validation, error responses and authorization rules, and cover the rest with other tests: plain unit tests for service logic, slice or Testcontainers tests for repositories, and a small number of @SpringBootTest tests for end-to-end flows.
Text
Question | Test type
----------------------------------------------|-----------------------------------------
Does the endpoint return the right status/JSON| @WebMvcTest + MockMvc
Is invalid input rejected with a 400? | @WebMvcTest + MockMvc
Is the endpoint protected correctly? | @WebMvcTest + @Import(SecurityConfig)
Is the business rule implemented correctly? | Plain unit test with Mockito
Does the query return the right rows? | @DataJpaTest or Testcontainers
Do all layers work together? | @SpringBootTest (few, focused tests)