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
1---2name: unit-test-exception-handler3description: Provides patterns for unit testing `@ExceptionHandler` and `@ControllerAdvice` in Spring Boot applications. Validates error response formatting, mocks exceptions, verifies HTTP status codes, tests field-level validation errors, and asserts custom error payloads. Use when writing Spring exception handler tests, REST API error tests, or mocking controller advice.4---5
6# Unit Testing ExceptionHandler and ControllerAdvice
7
8## Overview
9
10This 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.
11
12## When to Use
13
14- Writing unit tests for `@ExceptionHandler` methods
15- Testing `@ControllerAdvice` global exception handling
16- Validating REST API error response formatting
17- Mocking exceptions in controller tests
18- Testing field-level validation error responses
19- Asserting custom error payloads and HTTP status codes
20
21## Instructions
22
231. **Create a test controller** that throws specific exceptions to trigger each `@ExceptionHandler`
242. **Register ControllerAdvice** via `setControllerAdvice()` on `MockMvcBuilders.standaloneSetup()`
253. **Assert HTTP status codes** with `.andExpect(status().isXxx())`
264. **Verify error response fields** using `jsonPath("$.field")` matchers
275. **Test validation errors** by sending invalid payloads and checking `MethodArgumentNotValidException` produces field-level details
286. **Debug failures** with `.andDo(print())` — if handler not invoked, verify `setControllerAdvice()` is called and exception type matches
29
30## Examples
31
32### Exception Handler and Error DTO
33
34```java
35@ControllerAdvice
36public class GlobalExceptionHandler {
37
38 @ExceptionHandler(ResourceNotFoundException.class)
39 @ResponseStatus(HttpStatus.NOT_FOUND)
40 public ErrorResponse handleNotFound(ResourceNotFoundException ex) {
41 return new ErrorResponse(404, "Not Found", ex.getMessage());
42 }
43
44 @ExceptionHandler(ValidationException.class)
45 @ResponseStatus(HttpStatus.BAD_REQUEST)
46 public ErrorResponse handleValidation(ValidationException ex) {
47 return new ErrorResponse(400, "Bad Request", ex.getMessage());
48 }
49
50 @ExceptionHandler(MethodArgumentNotValidException.class)
51 @ResponseStatus(HttpStatus.BAD_REQUEST)
52 public ValidationErrorResponse handleMethodArgumentNotValid(MethodArgumentNotValidException ex) {
53 Map<String, String> errors = new HashMap<>();
54 ex.getBindingResult().getFieldErrors().forEach(e -> errors.put(e.getField(), e.getDefaultMessage()));
55 return new ValidationErrorResponse(400, "Validation Failed", errors);
56 }
57}
58
59public record ErrorResponse(int status, String error, String message) {}
60public record ValidationErrorResponse(int status, String error, Map<String, String> errors) {}
61```
62
63### Unit Test
64
65```java
66@ExtendWith(MockitoExtension.class)
67class GlobalExceptionHandlerTest {
68
69 private MockMvc mockMvc;
70
71 @BeforeEach
72 void setUp() {
73 GlobalExceptionHandler handler = new GlobalExceptionHandler();
74 mockMvc = MockMvcBuilders.standaloneSetup(new TestController())
75 .setControllerAdvice(handler)
76 .build();
77 }
78
79 @Test
80 void shouldReturn404WhenResourceNotFound() throws Exception {
81 mockMvc.perform(get("/api/users/999"))
82 .andExpect(status().isNotFound())
83 .andExpect(jsonPath("$.status").value(404))
84 .andExpect(jsonPath("$.error").value("Not Found"))
85 .andExpect(jsonPath("$.message").value("User not found"));
86 }
87
88 @Test
89 void shouldReturn400WithFieldErrorsOnValidationFailure() throws Exception {
90 mockMvc.perform(post("/api/users")
91 .contentType("application/json")
92 .content("{\"name\":\"\",\"email\":\"invalid\"}"))
93 .andExpect(status().isBadRequest())
94 .andExpect(jsonPath("$.status").value(400))
95 .andExpect(jsonPath("$.errors.name").value("must not be blank"))
96 .andExpect(jsonPath("$.errors.email").value("must be a valid email"));
97 }
98}
99
100@RestController
101@RequestMapping("/api")
102class TestController {
103 @GetMapping("/users/{id}") public User getUser(@PathVariable Long id) {
104 throw new ResourceNotFoundException("User not found");
105 }
106 @PostMapping("/users") public User createUser(@RequestBody @Valid User user) {
107 throw new ValidationException("Validation failed");
108 }
109}
110```
111
112## Best Practices
113
114- Test each `@ExceptionHandler` method independently with a dedicated exception throw
115- Register exactly one `@ControllerAdvice` instance via `setControllerAdvice()` — never skip it
116- Assert all fields in the error response body, not just the HTTP status
117- For validation errors, verify both the field name key and the error message value
118- Use `MockMvcBuilders.standaloneSetup()` for isolated handler tests without full Spring context
119- Log assertion failures: chain `.andDo(print())` to print request/response when a test fails
120
121## Common Pitfalls
122
123- Handler not invoked: ensure `setControllerAdvice()` is called on the builder
124- JsonPath mismatch: use `.andDo(print())` to inspect actual response structure
125- Status is 200: missing `@ResponseStatus` on the handler method
126- Duplicate handlers: `@Order` controls precedence; more specific exception types take priority
127- Testing handler logic instead of behavior: mock external dependencies, test only the response transformation
128
129## Constraints and Warnings
130
131- **`@ExceptionHandler` specificity**: more specific exception types are matched first; `Exception.class` catches all unmatched types
132- **`@ResponseStatus` default**: without `@ResponseStatus` or returning `ResponseEntity`, HTTP status defaults to 200
133- **Global vs local scope**: `@ExceptionHandler` in `@ControllerAdvice` is global; declared in a controller it is local only to that controller
134- **Logging side effects**: handlers that log should be verified with `verify(mockLogger).logXxx(...)`
135- **Localization**: when using `MessageSource`, test with different `Locale` values to confirm message resolution
136- **Security context**: `AuthorizationException` handlers can access `SecurityContextHolder` — test that context is correctly evaluated