Spring BootWebMvcTest
Spring Boot

WebMvcTest

WebMvcTest is a Spring Boot test slice that loads only the web layer — controllers, filters, advice, and MockMvc infrastructure — without starting the full application context or connecting to a database. It is the fastest way to test controller logic: request mapping, input validation, response serialisation, error handling, and security rules.

Basic WebMvcTest Setup

@WebMvcTest loads the controller under test plus Spring MVC infrastructure (Jackson, validators, exception handlers). Services, repositories, and other beans are NOT loaded — they must be provided as @MockBean. This makes WebMvcTest fast and focused.
Java
@WebMvcTest(UserController.class)   // only loads UserController + MVC infra
class UserControllerTest {

    @Autowired
    private MockMvc mockMvc;            // pre-configured HTTP test client

    @Autowired
    private ObjectMapper objectMapper;  // Jackson — for serialising request bodies

    @MockBean
    private UserService userService;    // not loaded by @WebMvcTest — must mock

    // ── GET — happy path: ────────────────────────────────────────────
    @Test
    void findById_returnsUser() throws Exception {
        UserResponse user = UserResponse.builder()
            .id(1L).name("Alice").email("alice@example.com").build();

        when(userService.findById(1L)).thenReturn(user);

        mockMvc.perform(get("/api/users/1")
                .contentType(MediaType.APPLICATION_JSON))
            .andExpect(status().isOk())
            .andExpect(jsonPath("$.id").value(1L))
            .andExpect(jsonPath("$.name").value("Alice"))
            .andExpect(jsonPath("$.email").value("alice@example.com"));
    }

    // ── GET — not found: ─────────────────────────────────────────────
    @Test
    void findById_notFound_returns404() throws Exception {
        when(userService.findById(99L))
            .thenThrow(new ResourceNotFoundException("User not found"));

        mockMvc.perform(get("/api/users/99"))
            .andExpect(status().isNotFound())
            .andExpect(jsonPath("$.message")
                .value("User not found"));
    }

    // ── POST — create user: ───────────────────────────────────────────
    @Test
    void create_validRequest_returnsCreated() throws Exception {
        CreateUserRequest request = new CreateUserRequest(
            "Bob", "bob@example.com", "password123");
        UserResponse created = UserResponse.builder()
            .id(2L).name("Bob").email("bob@example.com").build();

        when(userService.create(any(CreateUserRequest.class)))
            .thenReturn(created);

        mockMvc.perform(post("/api/users")
                .contentType(MediaType.APPLICATION_JSON)
                .content(objectMapper.writeValueAsString(request)))
            .andExpect(status().isCreated())
            .andExpect(jsonPath("$.id").value(2L))
            .andExpect(header().string("Location",
                containsString("/api/users/2")));
    }

    // ── DELETE: ───────────────────────────────────────────────────────
    @Test
    void delete_existingUser_returnsNoContent() throws Exception {
        doNothing().when(userService).delete(1L);

        mockMvc.perform(delete("/api/users/1"))
            .andExpect(status().isNoContent());

        verify(userService).delete(1L);
    }
}

Validation Testing

WebMvcTest is the right place to verify that Bean Validation constraints on request bodies and path variables work correctly. Invalid requests should return 400 Bad Request with field-level error details — test both valid and invalid inputs for every constraint.
Java
// ── Controller with validation: ──────────────────────────────────────
@RestController
@RequestMapping("/api/users")
@Validated
public class UserController {

    @PostMapping
    public ResponseEntity<UserResponse> create(
            @Valid @RequestBody CreateUserRequest request) { ... }

    @GetMapping("/{id}")
    public UserResponse findById(
            @PathVariable @Positive Long id) { ... }
}

// ── CreateUserRequest with constraints: ───────────────────────────────
public record CreateUserRequest(
    @NotBlank(message = "Name is required")
    @Size(min = 2, max = 100, message = "Name must be 2100 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
) {}

// ── Validation tests: ─────────────────────────────────────────────────
@WebMvcTest(UserController.class)
class UserControllerValidationTest {

    @Autowired  private MockMvc mockMvc;
    @Autowired  private ObjectMapper objectMapper;
    @MockBean   private UserService userService;

    @Test
    void create_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())
            .andExpect(jsonPath("$.errors[?(@.field=='name')]"
                + ".message").value("Name is required"));
    }

    @Test
    void create_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")
                .value("Email must be a valid address"));
    }

    @Test
    void create_shortPassword_returns400() throws Exception {
        CreateUserRequest request =
            new CreateUserRequest("Alice", "alice@example.com", "short");

        mockMvc.perform(post("/api/users")
                .contentType(MediaType.APPLICATION_JSON)
                .content(objectMapper.writeValueAsString(request)))
            .andExpect(status().isBadRequest());
    }

    @Test
    void create_validRequest_returns201() throws Exception {
        CreateUserRequest request =
            new CreateUserRequest("Alice", "alice@example.com", "password123");
        when(userService.create(any())).thenReturn(
            UserResponse.builder().id(1L).name("Alice").build());

        mockMvc.perform(post("/api/users")
                .contentType(MediaType.APPLICATION_JSON)
                .content(objectMapper.writeValueAsString(request)))
            .andExpect(status().isCreated());
    }
}

Security Testing with WebMvcTest

When Spring Security is on the classpath, @WebMvcTest includes the security filter chain. Use @WithMockUser to inject a mock authenticated user or @WithUserDetails to load a real UserDetails. Test both that protected endpoints reject unauthenticated requests and that authorised requests succeed.
Java
@WebMvcTest(UserController.class)
@Import(SecurityConfig.class)   // import your security configuration
class UserControllerSecurityTest {

    @Autowired  private MockMvc mockMvc;
    @Autowired  private ObjectMapper objectMapper;
    @MockBean   private UserService userService;
    @MockBean   private JwtTokenProvider jwtTokenProvider;

    // ── Unauthenticated — should be rejected: ─────────────────────────
    @Test
    void findAll_unauthenticated_returns401() throws Exception {
        mockMvc.perform(get("/api/users"))
            .andExpect(status().isUnauthorized());
    }

    // ── Authenticated as USER — accessing admin endpoint: ─────────────
    @Test
    @WithMockUser(roles = "USER")
    void findAll_insufficientRole_returns403() throws Exception {
        mockMvc.perform(get("/api/users"))
            .andExpect(status().isForbidden());
    }

    // ── Authenticated as ADMIN — should succeed: ──────────────────────
    @Test
    @WithMockUser(roles = "ADMIN")
    void findAll_asAdmin_returns200() throws Exception {
        when(userService.findAll()).thenReturn(List.of());

        mockMvc.perform(get("/api/users"))
            .andExpect(status().isOk());
    }

    // ── Owner accessing own resource: ─────────────────────────────────
    @Test
    @WithMockUser(username = "42", roles = "USER")
    void findById_ownResource_returns200() throws Exception {
        UserResponse user = UserResponse.builder()
            .id(42L).name("Alice").build();
        when(userService.findById(42L)).thenReturn(user);

        mockMvc.perform(get("/api/users/42"))
            .andExpect(status().isOk());
    }

    // ── User accessing another user's resource: ───────────────────────
    @Test
    @WithMockUser(username = "99", roles = "USER")
    void findById_otherUserResource_returns403() throws Exception {
        mockMvc.perform(get("/api/users/42"))
            .andExpect(status().isForbidden());
    }

    // ── JWT via MockMvc request post-processor: ───────────────────────
    @Test
    void findAll_withJwt_asAdmin_returns200() throws Exception {
        when(userService.findAll()).thenReturn(List.of());

        mockMvc.perform(get("/api/users")
                .with(jwt()
                    .authorities(
                        new SimpleGrantedAuthority("ROLE_ADMIN"))))
            .andExpect(status().isOk());
    }
}

Exception Handler Testing

@ControllerAdvice exception handlers are included in the WebMvcTest slice and should be tested alongside the controllers they protect. Verify that each exception type maps to the correct HTTP status, response body structure, and headers.
Java
// ── Global exception handler: ────────────────────────────────────────
@RestControllerAdvice
public class GlobalExceptionHandler {

    @ExceptionHandler(ResourceNotFoundException.class)
    public ResponseEntity<ErrorResponse> handleNotFound(
            ResourceNotFoundException ex) {
        return ResponseEntity.status(HttpStatus.NOT_FOUND)
            .body(new ErrorResponse(404, ex.getMessage()));
    }

    @ExceptionHandler(MethodArgumentNotValidException.class)
    public ResponseEntity<ValidationErrorResponse> handleValidation(
            MethodArgumentNotValidException ex) {
        List<FieldError> errors = ex.getBindingResult()
            .getFieldErrors().stream()
            .map(e -> new FieldError(e.getField(), e.getDefaultMessage()))
            .toList();
        return ResponseEntity.badRequest()
            .body(new ValidationErrorResponse(400, errors));
    }

    @ExceptionHandler(AccessDeniedException.class)
    public ResponseEntity<ErrorResponse> handleAccessDenied(
            AccessDeniedException ex) {
        return ResponseEntity.status(HttpStatus.FORBIDDEN)
            .body(new ErrorResponse(403, "Access denied"));
    }

    @ExceptionHandler(Exception.class)
    public ResponseEntity<ErrorResponse> handleGeneric(Exception ex) {
        return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR)
            .body(new ErrorResponse(500, "Internal server error"));
    }
}

// ── Exception handler tests: ──────────────────────────────────────────
@WebMvcTest(UserController.class)
class GlobalExceptionHandlerTest {

    @Autowired  private MockMvc mockMvc;
    @MockBean   private UserService userService;

    @Test
    void notFound_returnsErrorResponse() throws Exception {
        when(userService.findById(99L))
            .thenThrow(new ResourceNotFoundException("User 99 not found"));

        mockMvc.perform(get("/api/users/99"))
            .andExpect(status().isNotFound())
            .andExpect(jsonPath("$.status").value(404))
            .andExpect(jsonPath("$.message").value("User 99 not found"));
    }

    @Test
    void internalError_returns500() throws Exception {
        when(userService.findById(1L))
            .thenThrow(new RuntimeException("Unexpected error"));

        mockMvc.perform(get("/api/users/1"))
            .andExpect(status().isInternalServerError())
            .andExpect(jsonPath("$.status").value(500))
            .andExpect(jsonPath("$.message")
                .value("Internal server error"));
    }

    @Test
    @WithMockUser
    void validationError_returnsFieldErrors() throws Exception {
        String invalidBody = """
            { "name": "", "email": "not-an-email", "password": "short" }
            """;

        mockMvc.perform(post("/api/users")
                .contentType(MediaType.APPLICATION_JSON)
                .content(invalidBody))
            .andExpect(status().isBadRequest())
            .andExpect(jsonPath("$.errors").isArray())
            .andExpect(jsonPath("$.errors.length()").value(3));
    }
}