Unit Testing ExceptionHandler and ControllerAdvice
Overview
This skill provides patterns for writing unit tests for Spring Boot exception handlers. It covers testing @ExceptionHandler methods in @ControllerAdvice classes using MockMvc, including HTTP status assertions, JSON response validation, field-level validation error testing, and mocking handler dependencies.
When to Use
-
Writing unit tests for @ExceptionHandler methods
-
Testing @ControllerAdvice global exception handling
-
Validating REST API error response formatting
-
Mocking exceptions in controller tests
-
Testing field-level validation error responses
-
Asserting custom error payloads and HTTP status codes
Instructions
-
Create a test controller that throws specific exceptions to trigger each @ExceptionHandler
-
Register ControllerAdvice via setControllerAdvice() on MockMvcBuilders.standaloneSetup()
-
Assert HTTP status codes with .andExpect(status().isXxx())
-
Verify error response fields using jsonPath("$.field") matchers
-
Test validation errors by sending invalid payloads and checking MethodArgumentNotValidException produces field-level details
-
Debug failures with .andDo(print()) — if handler not invoked, verify setControllerAdvice() is called and exception type matches
Examples
Exception Handler and Error DTO
@ControllerAdvice public class GlobalExceptionHandler {
@ExceptionHandler(ResourceNotFoundException.class) @ResponseStatus(HttpStatus.NOT_FOUND) public ErrorResponse handleNotFound(ResourceNotFoundException ex) { return new ErrorResponse(404, "Not Found", ex.getMessage()); }
@ExceptionHandler(ValidationException.class) @ResponseStatus(HttpStatus.BAD_REQUEST) public ErrorResponse handleValidation(ValidationException ex) { return new ErrorResponse(400, "Bad Request", ex.getMessage()); }
@ExceptionHandler(MethodArgumentNotValidException.class) @ResponseStatus(HttpStatus.BAD_REQUEST) public ValidationErrorResponse handleMethodArgumentNotValid(MethodArgumentNotValidException ex) { Map<String, String> errors = new HashMap<>(); ex.getBindingResult().getFieldErrors().forEach(e -> errors.put(e.getField(), e.getDefaultMessage())); return new ValidationErrorResponse(400, "Validation Failed", errors); } }
public record ErrorResponse(int status, String error, String message) {} public record ValidationErrorResponse(int status, String error, Map<String, String> errors) {}
Unit Test
@ExtendWith(MockitoExtension.class) class GlobalExceptionHandlerTest {
private MockMvc mockMvc;
@BeforeEach void setUp() { GlobalExceptionHandler handler = new GlobalExceptionHandler(); mockMvc = MockMvcBuilders.standaloneSetup(new TestController()) .setControllerAdvice(handler) .build(); }
@Test void shouldReturn404WhenResourceNotFound() throws Exception { mockMvc.perform(get("/api/users/999")) .andExpect(status().isNotFound()) .andExpect(jsonPath("$.status").value(404)) .andExpect(jsonPath("$.error").value("Not Found")) .andExpect(jsonPath("$.message").value("User not found")); }
@Test void shouldReturn400WithFieldErrorsOnValidationFailure() throws Exception { mockMvc.perform(post("/api/users") .contentType("application/json") .content("{"name":"","email":"invalid"}")) .andExpect(status().isBadRequest()) .andExpect(jsonPath("$.status").value(400)) .andExpect(jsonPath("$.errors.name").value("must not be blank")) .andExpect(jsonPath("$.errors.email").value("must be a valid email")); } }
@RestController @RequestMapping("/api") class TestController { @GetMapping("/users/{id}") public User getUser(@PathVariable Long id) { throw new ResourceNotFoundException("User not found"); } @PostMapping("/users") public User createUser(@RequestBody @Valid User user) { throw new ValidationException("Validation failed"); } }
Best Practices
-
Test each @ExceptionHandler method independently with a dedicated exception throw
-
Register exactly one @ControllerAdvice instance via setControllerAdvice() — never skip it
-
Assert all fields in the error response body, not just the HTTP status
-
For validation errors, verify both the field name key and the error message value
-
Use MockMvcBuilders.standaloneSetup() for isolated handler tests without full Spring context
-
Log assertion failures: chain .andDo(print()) to print request/response when a test fails
Common Pitfalls
-
Handler not invoked: ensure setControllerAdvice() is called on the builder
-
JsonPath mismatch: use .andDo(print()) to inspect actual response structure
-
Status is 200: missing @ResponseStatus on the handler method
-
Duplicate handlers: @Order controls precedence; more specific exception types take priority
-
Testing handler logic instead of behavior: mock external dependencies, test only the response transformation
Constraints and Warnings
-
@ExceptionHandler specificity: more specific exception types are matched first; Exception.class catches all unmatched types
-
@ResponseStatus default: without @ResponseStatus or returning ResponseEntity , HTTP status defaults to 200
-
Global vs local scope: @ExceptionHandler in @ControllerAdvice is global; declared in a controller it is local only to that controller
-
Logging side effects: handlers that log should be verified with verify(mockLogger).logXxx(...)
-
Localization: when using MessageSource , test with different Locale values to confirm message resolution
-
Security context: AuthorizationException handlers can access SecurityContextHolder — test that context is correctly evaluated