home / skills / giuseppe-trisciuoglio / developer-kit / unit-test-exception-handler

This skill helps you validate exception handling in Spring controllers by testing @ExceptionHandler and @ControllerAdvice with MockMvc for precise error

npx playbooks add skill giuseppe-trisciuoglio/developer-kit --skill unit-test-exception-handler

Review the files below or copy the command above to add this skill to your agents.

Files (1)
SKILL.md
15.7 KB
---
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.
category: testing
tags: [junit-5, exception-handler, controller-advice, error-handling, mockmvc]
version: 1.0.1
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.

## 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

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

## 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

```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()
    );
  }

  @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

```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();
  }

  @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

```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\":\"[email protected]\"}"))
      .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());
  }
}
```

## 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](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)

Overview

This skill provides pragmatic patterns for unit testing Spring @ExceptionHandler methods and @ControllerAdvice classes using MockMvc. It focuses on verifying exception-to-error-response transformations, HTTP status codes, and error payload shape without running full integration tests. The goal is fast, maintainable tests that validate error formatting, validation details, and side effects like logging or localization.

How this skill works

Create lightweight test controllers that deliberately throw exceptions and register your ControllerAdvice with MockMvcBuilders.standaloneSetup(...).setControllerAdvice(...). Build targeted tests that perform MockMvc requests and assert status codes and JSON response fields. You can inject mocked services into the handler to verify localization, logging, or other contextual behavior while keeping tests small and focused.

When to use it

  • Validating that @ExceptionHandler methods map exceptions to correct HTTP status codes
  • Checking error response shape and required fields (timestamp, status, error, message)
  • Testing validation error handling (MethodArgumentNotValidException) and field-level messages
  • Verifying logging, localization, or other side effects triggered by exception handlers
  • Writing fast unit tests that avoid full application context or integration test overhead

Best practices

  • Use simple test controllers that throw specific exceptions to exercise each handler
  • Register your ControllerAdvice explicitly with MockMvc standaloneSetup to isolate behavior
  • Assert both HTTP status and JSON payload fields, including nested validation errors
  • Mock dependent services (message sources, logging) to verify side effects without external systems
  • Test each exception type, plus a generic fallback exception handler, to avoid gaps

Example use cases

  • Unit test that ResourceNotFoundException returns 404 and expected error message
  • Verify MethodArgumentNotValidException produces an errors map with per-field messages
  • Assert DuplicateResourceException maps to 409 Conflict and contains conflict details
  • Mock a MessageService to ensure business exceptions return localized messages
  • Confirm LoggingService.logException is invoked when a BusinessException is handled

FAQ

Do I need a running Spring context to test exception handlers?

No. Use MockMvcBuilders.standaloneSetup with a test controller and setControllerAdvice to exercise handlers without starting the full context.

How do I test validation errors with field-level messages?

Trigger MethodArgumentNotValidException by POSTing invalid JSON to a controller with @Valid and assert jsonPath($.errors.fieldName) values.