Back to Testing & Quality Assurance

unit-test-exception-handler

Spring BootUnit TestingException Handling@ControllerAdvice@ExceptionHandlerMockMvcJavaError Management
282📄 MIT🕒 2026-06-15Source ↗

Install this skill

npx skills add giuseppe-trisciuoglio/developer-kit

Works across Claude Code, Cursor, Codex, Copilot & Antigravity

The unit-test-exception-handler skill focuses on validating Spring Boot global exception handling logic without executing a full application context. By configuring MockMvc with standalone setup, you can isolate your @ControllerAdvice classes to ensure specific exceptions map to the expected HTTP status codes and response bodies. This approach allows developers to verify serialization of error DTOs and check the consistency of error messages across different failure scenarios. Because it avoids booting the entire Spring container, these tests run quickly, supporting a tighter feedback loop during development. The process involves creating a minimal controller that triggers your target exceptions and asserting the resulting JSON structure using standard Spring MVC testing expectations, confirming that your API gracefully handles errors in a structured and predictable manner.

When to Use This Skill

  • Testing global error transformation logic in @ControllerAdvice
  • Validating specific error messages during resource lookup failures
  • Checking conflict responses for duplicate record attempts
  • Confirming proper forbidden or unauthorized access status codes

How to Invoke This Skill

Example prompts that trigger this skill in Claude Code, Cursor, or Antigravity:

  • Write a unit test for my @ControllerAdvice
  • How to verify Spring Boot exception handler with MockMvc
  • Test my global exception handler without full integration tests
  • Check if my API returns 404 for ResourceNotFoundException
  • Create a test class for my custom ExceptionHandler

Pro Tips

  • 💡Isolate exception handler tests from controller logic: Focus purely on the handler's mapping and response generation, not the controller's business logic.
  • 💡Use @WebMvcTest with @ControllerAdvice: This setup is ideal for testing global exception handlers without loading the full application context.
  • 💡Test edge cases: Include tests for unexpected nulls, empty inputs, or specific data constraints that might trigger unique exceptions.

What this skill does

  • Isolated validation of @ExceptionHandler methods
  • Verification of HTTP status code mapping for custom exceptions
  • JSON response structure assertion using MockMvc JSONPath
  • Testing of error DTO formatting and field content
  • Minimal dependency footprint via standalone MockMvc setup

When not to use it

  • When testing complex security filter chain interactions
  • When validating database-level constraints that require a live transaction manager

Example workflow

  1. Define the target @ControllerAdvice class and corresponding error DTO
  2. Create a mock controller in the test file that triggers the specific exception
  3. Configure MockMvc using MockMvcBuilders.standaloneSetup to include the advice
  4. Execute a mock request against the dummy controller endpoint
  5. Add status() and jsonPath() matchers to verify the resulting error response
  6. Run the test and inspect assertion results

Prerequisites

  • Spring Boot Web starter
  • JUnit 5
  • MockMvc dependencies

Pitfalls & limitations

  • !Forgetting to register the @ControllerAdvice in the standalone setup
  • !Misinterpreting JSONPath expressions when the error DTO is nested
  • !Failing to account for default internal Spring errors that bypass your handler

FAQ

Why use standaloneSetup instead of @SpringBootTest?
Standalone setup creates a fast, isolated test environment that only loads the required handler and controller, significantly improving test suite execution speed.
Can I test multiple exception handlers in one class?
Yes, you can define multiple test methods that trigger different controller paths, each asserting a different exception handler's output from the same advice class.
Do I need to mock the service layer in my test controller?
Not strictly necessary if your test controller simply throws the exception to trigger the handler, though it depends on how deeply you need to isolate your logic.

How it compares

This approach provides a repeatable, assertion-based framework for error handling that avoids the flakiness and performance overhead of full integration testing.

Source & trust

282 stars📄 MIT🕒 Updated 2026-06-15
📄 Full skill instructions — original source: giuseppe-trisciuoglio/developer-kit
# Unit Testing ExceptionHandler and ControllerAdvice

Test exception handlers and global exception handling logic using MockMvc. Verify error response formatting, HTTP status codes, and exception-to-response mapping.

## When to Use This Skill

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

## 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\":\"[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

@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)

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

How to Use This Skill Unit

Option A: Project-Specific (Recommended)

  1. Click "Download" above
  2. In your project, create the directory: .agent/skills/unit-test-exception-handler/
  3. Save the file as SKILL.md
  4. The agent will automatically discover the skill based on its description.

Option B: Global Installation (All Agents)

Save the file to these locations to make it available across all projects:

  • Claude Code: ~/.claude/skills/giuseppe-trisciuoglio/developer-kit/unit-test-exception-handler/SKILL.md
  • Cursor: ~/.cursor/skills/giuseppe-trisciuoglio/developer-kit/unit-test-exception-handler/SKILL.md
  • Antigravity: ~/.gemini/antigravity/skills/giuseppe-trisciuoglio/developer-kit/unit-test-exception-handler/SKILL.md

🚀 Install with CLI:
npx skills add giuseppe-trisciuoglio/developer-kit

Read the Master Guide: Mastering Agent Skills

Recommended Rules

View more rules

Recommended Workflows

View more workflows

Recommended MCP Servers

View more MCP servers

Take It Further

Maximize your productivity with these powerful resources

📋

Define Your Standards

Set up coding standards to ensure this workflow produces consistent, high-quality results.

Browse Rules Library
📖

Master Workflows

Learn how to create custom workflows, use Turbo Mode, and build your automation library.

Complete Guide

How to use this Skill in Claude Code & Cursor

For Claude Code (CLI)

To use this skill in Claude Code, copy the rule content into your project's custom instructions or follow our Add-Skill CLI guide. This ensures Claude follows your standards during every code generation.

For Cursor & Windsurf

For Cursor or Windsurf, individual skills are best used in the "Rules for AI" section. This specific unit helps the agent avoid testing & quality assurance issues, leading to cleaner, more efficient code.

Why the skill format matters: the standardized Agent Skills format lets your AI agent load detailed instructions only when they are relevant, keeping your prompt clean while improving results.

Source & attribution

This skill is categorized under Testing & Quality Assurance and is published by Giuseppe Trisciuoglio, maintained in giuseppe-trisciuoglio/developer-kit.

← Browse All Agent Skills
Sponsored AI assistant. Recommendations may be paid.