git:20260316.91dc3c4 to git:20260324.a02e282

65 added, 417 removed. Audit A to A.

---
name: unit-test-exception-handler
- description: Provides patterns for unit testing `@ExceptionHandler` and `@ControllerAdvice` for global exception handling. Use when validating error response formatting and HTTP status codes.
+ description: 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.
allowed-tools: Read, Write, Bash, Glob, Grep
---
# Unit Testing ExceptionHandler and ControllerAdvice
## Overview
- This skill provides patterns for unit testing `@ExceptionHandler` methods and `@ControllerAdvice` classes using MockMvc. It covers testing exception-to-error-response transformations, HTTP status codes, error message formatting, validation error handling, and custom permission evaluators without full integration test overhead.
+ 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
- Use this skill when:
- - Testing `@`ExceptionHandler methods in `@`ControllerAdvice
- - Testing exception-to-error-response transformations
- - Verifying HTTP status codes for different exception types
- - Testing error message formatting and localization
- - Want fast exception handler tests without full integration tests
+ - 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
- 1. **Create test controllers**: Create simple test controllers that throw exceptions to test handler behavior
- 2. **Register ControllerAdvice**: Use `setControllerAdvice()` when building MockMvc to register exception handlers
- 3. **Test all exception types**: Verify each `@`ExceptionHandler method handles its specific exception type
- 4. **Verify HTTP status codes**: Use `@`ResponseStatus assertions to verify correct status codes
- 5. **Test error response structure**: Verify error responses contain all required fields (timestamp, status, error, message)
- 6. **Test validation errors**: Verify MethodArgumentNotValidException produces field-level error details
- 7. **Test logging and side effects**: Verify exception handlers log errors or perform other side effects
- 8. **Use mock controllers**: Throw exceptions from mock controllers to trigger exception handlers
+ 1. **Create a test controller** that throws specific exceptions to trigger each `@ExceptionHandler`
+ 2. **Register ControllerAdvice** via `setControllerAdvice()` on `MockMvcBuilders.standaloneSetup()`
+ 3. **Assert HTTP status codes** with `.andExpect(status().isXxx())`
+ 4. **Verify error response fields** using `jsonPath("$.field")` matchers
+ 5. **Test validation errors** by sending invalid payloads and checking `MethodArgumentNotValidException` produces field-level details
+ 6. **Debug failures** with `.andDo(print())` — if handler not invoked, verify `setControllerAdvice()` is called and exception type matches
## Examples
- ## Setup: Exception Handler Testing
-
- ### Maven
- ```xml
- <dependency>
- <groupId>org.springframework.boot</groupId>
- <artifactId>spring-boot-starter-web</artifactId>
- </dependency>
- <dependency>
- <groupId>org.springframework.boot</groupId>
- <artifactId>spring-boot-starter-test</artifactId>
- <scope>test</scope>
- </dependency>
- <dependency>
- <groupId>org.assertj</groupId>
- <artifactId>assertj-core</artifactId>
- <scope>test</scope>
- </dependency>
- ```
-
- ### Gradle
- ```kotlin
- dependencies {
- implementation("org.springframework.boot:spring-boot-starter-web")
- testImplementation("org.springframework.boot:spring-boot-starter-test")
- testImplementation("org.assertj:assertj-core")
- }
- ```
-
- ## Basic Pattern: Global Exception Handler
-
- ### Create Exception Handler
+ ### Exception Handler and Error DTO
```java
- // Global exception handler
@ControllerAdvice
public class GlobalExceptionHandler {
@ExceptionHandler(ResourceNotFoundException.class)
@ResponseStatus(HttpStatus.NOT_FOUND)
- public ErrorResponse handleResourceNotFound(ResourceNotFoundException ex) {
- return new ErrorResponse(
- HttpStatus.NOT_FOUND.value(),
- "Resource not found",
- ex.getMessage()
- );
+ public ErrorResponse handleNotFound(ResourceNotFoundException ex) {
+ return new ErrorResponse(404, "Not Found", ex.getMessage());
}
@ExceptionHandler(ValidationException.class)
@ResponseStatus(HttpStatus.BAD_REQUEST)
- public ErrorResponse handleValidationException(ValidationException ex) {
- return new ErrorResponse(
- HttpStatus.BAD_REQUEST.value(),
- "Validation failed",
- ex.getMessage()
- );
+ 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);
+ }
}
- // Error response DTO
- public record ErrorResponse(
- int status,
- String error,
- String message
- ) {}
+ public record ErrorResponse(int status, String error, String message) {}
+ public record ValidationErrorResponse(int status, String error, Map<String, String> errors) {}
```
- ### Unit Test Exception Handler
+ ### Unit Test
```java
- import org.junit.jupiter.api.Test;
- import org.junit.jupiter.api.extension.ExtendWith;
- import org.mockito.InjectMocks;
- import org.mockito.junit.jupiter.MockitoExtension;
- import org.springframework.test.web.servlet.MockMvc;
- import org.springframework.test.web.servlet.setup.MockMvcBuilders;
- import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.*;
- import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*;
-
@ExtendWith(MockitoExtension.class)
class GlobalExceptionHandlerTest {
- @InjectMocks
- private GlobalExceptionHandler exceptionHandler;
-
private MockMvc mockMvc;
@BeforeEach
void setUp() {
- mockMvc = MockMvcBuilders
- .standaloneSetup(new TestController())
- .setControllerAdvice(exceptionHandler)
- .build();
+ GlobalExceptionHandler handler = new GlobalExceptionHandler();
+ mockMvc = MockMvcBuilders.standaloneSetup(new TestController())
+ .setControllerAdvice(handler)
+ .build();
}
@Test
- void shouldReturnNotFoundWhenResourceNotFoundException() throws Exception {
+ void shouldReturn404WhenResourceNotFound() throws Exception {
mockMvc.perform(get("/api/users/999"))
- .andExpect(status().isNotFound())
- .andExpect(jsonPath("$.status").value(404))
- .andExpect(jsonPath("$.error").value("Resource not found"))
- .andExpect(jsonPath("$.message").value("User not found"));
+ .andExpect(status().isNotFound())
+ .andExpect(jsonPath("$.status").value(404))
+ .andExpect(jsonPath("$.error").value("Not Found"))
+ .andExpect(jsonPath("$.message").value("User not found"));
}
@Test
- void shouldReturnBadRequestWhenValidationException() throws Exception {
+ void shouldReturn400WithFieldErrorsOnValidationFailure() throws Exception {
mockMvc.perform(post("/api/users")
.contentType("application/json")
- .content("{\"name\":\"\"}"))
- .andExpect(status().isBadRequest())
- .andExpect(jsonPath("$.status").value(400))
- .andExpect(jsonPath("$.error").value("Validation failed"));
+ .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"));
}
}
- // Test controller that throws exceptions
@RestController
@RequestMapping("/api")
class TestController {
-
- @GetMapping("/users/{id}")
- public User getUser(@PathVariable Long id) {
+ @GetMapping("/users/{id}") public User getUser(@PathVariable Long id) {
throw new ResourceNotFoundException("User not found");
}
- }
- ```
-
- ## Testing Multiple Exception Types
-
- ### Handle Various Exception Types
-
- ```java
- @ControllerAdvice
- public class GlobalExceptionHandler {
-
- @ExceptionHandler(ResourceNotFoundException.class)
- @ResponseStatus(HttpStatus.NOT_FOUND)
- public ErrorResponse handleResourceNotFound(ResourceNotFoundException ex) {
- return new ErrorResponse(404, "Not found", ex.getMessage());
- }
-
- @ExceptionHandler(DuplicateResourceException.class)
- @ResponseStatus(HttpStatus.CONFLICT)
- public ErrorResponse handleDuplicateResource(DuplicateResourceException ex) {
- return new ErrorResponse(409, "Conflict", ex.getMessage());
- }
-
- @ExceptionHandler(UnauthorizedException.class)
- @ResponseStatus(HttpStatus.UNAUTHORIZED)
- public ErrorResponse handleUnauthorized(UnauthorizedException ex) {
- return new ErrorResponse(401, "Unauthorized", ex.getMessage());
- }
-
- @ExceptionHandler(AccessDeniedException.class)
- @ResponseStatus(HttpStatus.FORBIDDEN)
- public ErrorResponse handleAccessDenied(AccessDeniedException ex) {
- return new ErrorResponse(403, "Forbidden", ex.getMessage());
- }
-
- @ExceptionHandler(Exception.class)
- @ResponseStatus(HttpStatus.INTERNAL_SERVER_ERROR)
- public ErrorResponse handleGenericException(Exception ex) {
- return new ErrorResponse(500, "Internal server error", "An unexpected error occurred");
- }
- }
-
- class MultiExceptionHandlerTest {
-
- private MockMvc mockMvc;
- private GlobalExceptionHandler handler;
-
- @BeforeEach
- void setUp() {
- handler = new GlobalExceptionHandler();
- mockMvc = MockMvcBuilders
- .standaloneSetup(new TestController())
- .setControllerAdvice(handler)
- .build();
- }
-
- @Test
- void shouldReturn404ForNotFound() throws Exception {
- mockMvc.perform(get("/api/users/999"))
- .andExpect(status().isNotFound())
- .andExpect(jsonPath("$.status").value(404));
- }
-
- @Test
- void shouldReturn409ForDuplicate() throws Exception {
- mockMvc.perform(post("/api/users")
- .contentType("application/json")
- .content("{\"email\":\"existing@example.com\"}"))
- .andExpect(status().isConflict())
- .andExpect(jsonPath("$.status").value(409));
- }
-
- @Test
- void shouldReturn401ForUnauthorized() throws Exception {
- mockMvc.perform(get("/api/admin/dashboard"))
- .andExpect(status().isUnauthorized())
- .andExpect(jsonPath("$.status").value(401));
- }
-
- @Test
- void shouldReturn403ForAccessDenied() throws Exception {
- mockMvc.perform(get("/api/admin/users"))
- .andExpect(status().isForbidden())
- .andExpect(jsonPath("$.status").value(403));
- }
-
- @Test
- void shouldReturn500ForGenericException() throws Exception {
- mockMvc.perform(get("/api/error"))
- .andExpect(status().isInternalServerError())
- .andExpect(jsonPath("$.status").value(500));
- }
- }
- ```
-
- ## Testing Error Response Structure
-
- ### Verify Error Response Format
-
- ```java
- @ControllerAdvice
- public class GlobalExceptionHandler {
-
- @ExceptionHandler(BadRequestException.class)
- @ResponseStatus(HttpStatus.BAD_REQUEST)
- public ResponseEntity<ErrorDetails> handleBadRequest(BadRequestException ex) {
- ErrorDetails details = new ErrorDetails(
- System.currentTimeMillis(),
- HttpStatus.BAD_REQUEST.value(),
- "Bad Request",
- ex.getMessage(),
- new Date()
- );
- return new ResponseEntity<>(details, HttpStatus.BAD_REQUEST);
- }
- }
-
- class ErrorResponseStructureTest {
-
- private MockMvc mockMvc;
-
- @BeforeEach
- void setUp() {
- mockMvc = MockMvcBuilders
- .standaloneSetup(new TestController())
- .setControllerAdvice(new GlobalExceptionHandler())
- .build();
- }
-
- @Test
- void shouldIncludeTimestampInErrorResponse() throws Exception {
- mockMvc.perform(post("/api/data")
- .contentType("application/json")
- .content("{}"))
- .andExpect(status().isBadRequest())
- .andExpect(jsonPath("$.timestamp").exists())
- .andExpect(jsonPath("$.status").value(400))
- .andExpect(jsonPath("$.error").value("Bad Request"))
- .andExpect(jsonPath("$.message").exists())
- .andExpect(jsonPath("$.date").exists());
- }
-
- @Test
- void shouldIncludeAllRequiredErrorFields() throws Exception {
- MvcResult result = mockMvc.perform(get("/api/invalid"))
- .andExpect(status().isBadRequest())
- .andReturn();
-
- String response = result.getResponse().getContentAsString();
-
- assertThat(response).contains("timestamp");
- assertThat(response).contains("status");
- assertThat(response).contains("error");
- assertThat(response).contains("message");
- }
- }
- ```
-
- ## Testing Validation Error Handling
-
- ### Handle MethodArgumentNotValidException
-
- ```java
- @ControllerAdvice
- public class GlobalExceptionHandler {
-
- @ExceptionHandler(MethodArgumentNotValidException.class)
- @ResponseStatus(HttpStatus.BAD_REQUEST)
- public ValidationErrorResponse handleValidationException(
- MethodArgumentNotValidException ex) {
-
- Map<String, String> errors = new HashMap<>();
- ex.getBindingResult().getFieldErrors().forEach(error ->
- errors.put(error.getField(), error.getDefaultMessage())
- );
-
- return new ValidationErrorResponse(
- HttpStatus.BAD_REQUEST.value(),
- "Validation failed",
- errors
- );
- }
- }
-
- class ValidationExceptionHandlerTest {
-
- private MockMvc mockMvc;
-
- @BeforeEach
- void setUp() {
- mockMvc = MockMvcBuilders
- .standaloneSetup(new UserController())
- .setControllerAdvice(new GlobalExceptionHandler())
- .build();
- }
-
- @Test
- void shouldReturnValidationErrorsForInvalidInput() throws Exception {
- mockMvc.perform(post("/api/users")
- .contentType("application/json")
- .content("{\"name\":\"\",\"age\":-5}"))
- .andExpect(status().isBadRequest())
- .andExpect(jsonPath("$.status").value(400))
- .andExpect(jsonPath("$.errors.name").exists())
- .andExpect(jsonPath("$.errors.age").exists());
- }
-
- @Test
- void shouldIncludeErrorMessageForEachField() throws Exception {
- mockMvc.perform(post("/api/users")
- .contentType("application/json")
- .content("{\"name\":\"\",\"email\":\"invalid\"}"))
- .andExpect(status().isBadRequest())
- .andExpect(jsonPath("$.errors.name").value("must not be blank"))
- .andExpect(jsonPath("$.errors.email").value("must be valid email"));
- }
- }
- ```
-
- ## Testing Exception Handler with Custom Logic
-
- ### Exception Handler with Context
-
- ```java
- @ControllerAdvice
- public class GlobalExceptionHandler {
-
- private final MessageService messageService;
- private final LoggingService loggingService;
-
- public GlobalExceptionHandler(MessageService messageService, LoggingService loggingService) {
- this.messageService = messageService;
- this.loggingService = loggingService;
- }
-
- @ExceptionHandler(BusinessException.class)
- @ResponseStatus(HttpStatus.BAD_REQUEST)
- public ErrorResponse handleBusinessException(BusinessException ex, HttpServletRequest request) {
- loggingService.logException(ex, request.getRequestURI());
-
- String localizedMessage = messageService.getMessage(ex.getErrorCode());
- return new ErrorResponse(
- HttpStatus.BAD_REQUEST.value(),
- "Business error",
- localizedMessage
- );
- }
- }
-
- class ExceptionHandlerWithContextTest {
-
- private MockMvc mockMvc;
- private GlobalExceptionHandler handler;
- private MessageService messageService;
- private LoggingService loggingService;
-
- @BeforeEach
- void setUp() {
- messageService = mock(MessageService.class);
- loggingService = mock(LoggingService.class);
- handler = new GlobalExceptionHandler(messageService, loggingService);
-
- mockMvc = MockMvcBuilders
- .standaloneSetup(new TestController())
- .setControllerAdvice(handler)
- .build();
- }
-
- @Test
- void shouldLocalizeErrorMessage() throws Exception {
- when(messageService.getMessage("USER_NOT_FOUND"))
- .thenReturn("L'utilisateur n'a pas été trouvé");
-
- mockMvc.perform(get("/api/users/999"))
- .andExpect(status().isBadRequest())
- .andExpect(jsonPath("$.message").value("L'utilisateur n'a pas été trouvé"));
-
- verify(messageService).getMessage("USER_NOT_FOUND");
- }
-
- @Test
- void shouldLogExceptionOccurrence() throws Exception {
- mockMvc.perform(get("/api/users/999"))
- .andExpect(status().isBadRequest());
-
- verify(loggingService).logException(any(BusinessException.class), anyString());
+ @PostMapping("/users") public User createUser(@RequestBody @Valid User user) {
+ throw new ValidationException("Validation failed");
}
}
```
## Best Practices
- - **Test all exception handlers** with real exception throws
- - **Verify HTTP status codes** for each exception type
- - **Test error response structure** to ensure consistency
- - **Verify logging** is triggered appropriately
- - **Use mock controllers** to throw exceptions in tests
- - **Test both happy and error paths**
- - **Keep error messages user-friendly** and consistent
+ - 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
- - Not testing the full request path (use MockMvc with controller)
- - Forgetting to include `@ControllerAdvice` in MockMvc setup
- - Not verifying all required fields in error response
- - Testing handler logic instead of exception handling behavior
- - Not testing edge cases (null exceptions, unusual messages)
+ - 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
- - **`@`ControllerAdvice execution order**: Multiple `@`ControllerAdvice handlers can be ordered with `@`Order annotation
- - **Exception handler specificity**: More specific exception types take precedence over generic handlers
- - **ResponseStatus required**: Without `@`ResponseStatus or returning ResponseEntity, status defaults to 200
- - **Global vs local handlers**: `@`ExceptionHandler in `@`ControllerAdvice is global; in controller it's local to that controller
- - **Logging considerations**: Exception handlers should log exceptions at appropriate levels before returning responses
- - **Message localization**: When using localized messages, test with different locales
- - **Security context**: Exception handlers have access to security context for authentication/authorization errors
-
- ## Troubleshooting
-
- **Exception handler not invoked**: Ensure controller is registered with MockMvc and actually throws the exception.
-
- **JsonPath matchers not matching**: Use `.andDo(print())` to see actual response structure.
-
- **Status code mismatch**: Verify `@ResponseStatus` annotation on handler method.
-
- ## References
-
- - [Spring ControllerAdvice Documentation](https://docs.spring.io/spring-framework/docs/current/javadoc-api/org/springframework/web/bind/annotation/ControllerAdvice.html)
- - [Spring ExceptionHandler](https://docs.spring.io/spring-framework/docs/current/javadoc-api/org/springframework/web/bind/annotation/ExceptionHandler.html)
- - [MockMvc Testing](https://docs.spring.io/spring-framework/docs/current/javadoc-api/org/springframework/test/web/servlet/MockMvc.html)
+ - **`@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