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.
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
Instructions
-
Create test controllers: Create simple test controllers that throw exceptions to test handler behavior
-
Register ControllerAdvice: Use setControllerAdvice() when building MockMvc to register exception handlers
-
Test all exception types: Verify each @ExceptionHandler method handles its specific exception type
-
Verify HTTP status codes: Use @ResponseStatus assertions to verify correct status codes
-
Test error response structure: Verify error responses contain all required fields (timestamp, status, error, message)
-
Test validation errors: Verify MethodArgumentNotValidException produces field-level error details
-
Test logging and side effects: Verify exception handlers log errors or perform other side effects
-
Use mock controllers: Throw exceptions from mock controllers to trigger exception handlers
Examples
Setup: Exception Handler Testing
Maven
<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
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
// 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() ); }
@ExceptionHandler(ValidationException.class) @ResponseStatus(HttpStatus.BAD_REQUEST) public ErrorResponse handleValidationException(ValidationException ex) { return new ErrorResponse( HttpStatus.BAD_REQUEST.value(), "Validation failed", ex.getMessage() ); } }
// Error response DTO public record ErrorResponse( int status, String error, String message ) {}
Unit Test Exception Handler
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(); }
@Test void shouldReturnNotFoundWhenResourceNotFoundException() 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")); }
@Test void shouldReturnBadRequestWhenValidationException() 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")); } }
// Test controller that throws exceptions @RestController @RequestMapping("/api") class TestController {
@GetMapping("/users/{id}") public User getUser(@PathVariable Long id) { throw new ResourceNotFoundException("User not found"); } }
Testing Multiple Exception Types
Handle Various Exception Types
@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
@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
@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
@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());
} }
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
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)
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
-
Spring ExceptionHandler
-
MockMvc Testing