Install any skill in seconds. Free to start, no credit card required.
Get Started Free →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.
.claude/skills/giuseppe-trisciuoglio-unit-test-exception-handler/SKILL.md| Test case | Without → With | Effect | Δ tokens | Δ turns |
|---|---|---|---|---|
| case-01 | ✗→✓ | ▲ Improved | 18% | 0% |
| case-10 | ✗→✓ | ▲ Improved | 23% | 0% |
| case-18 | ✓→✓ | = Same ✓ | 147% | 0% |
| case-02 | ✓→✓ | = Same ✓ | 54% | 0% |
| case-03 | ✓→✓ | = Same ✓ | 37% | 0% |
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.
@ExceptionHandler methods@ControllerAdvice global exception handling@ExceptionHandlersetControllerAdvice() on MockMvcBuilders.standaloneSetup().andExpect(status().isXxx())jsonPath("$.field") matchersMethodArgumentNotValidException produces field-level details.andDo(print()) — if handler not invoked, verify setControllerAdvice() is called and exception type matchesjava@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) {}
java@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"); } }
@ExceptionHandler method independently with a dedicated exception throw@ControllerAdvice instance via setControllerAdvice() — never skip itMockMvcBuilders.standaloneSetup() for isolated handler tests without full Spring context.andDo(print()) to print request/response when a test failssetControllerAdvice() is called on the builder.andDo(print()) to inspect actual response structure@ResponseStatus on the handler method@Order controls precedence; more specific exception types take priority@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@ExceptionHandler in @ControllerAdvice is global; declared in a controller it is local only to that controllerverify(mockLogger).logXxx(...)MessageSource, test with different Locale values to confirm message resolutionAuthorizationException handlers can access SecurityContextHolder — test that context is correctly evaluated| Case | Status | Duration (ms) | Turns | Tokens | Tool calls | ||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| Without | With | Δ | Without | With | Δ | Without | With | Δ | Without | With | Δ | ||
case-01 | fail→pass | 16,768 | 14,445 | -14% | 1 | 1 | 0% | 3,272 | 3,846 | +18% | 0 | 0 | — |
case-18 | pass→pass | 4,822 | 3,746 | -22% | 1 | 1 | 0% | 800 | 1,973 | +147% | 0 | 0 | — |
case-02 | pass→pass | 8,556 | 6,453 | -25% | 1 | 1 | 0% | 1,630 | 2,505 | +54% | 0 | 0 | — |
case-03 | pass→pass | 15,559 | 12,337 | -21% | 1 | 1 | 0% | 2,672 | 3,670 | +37% | 0 | 0 | — |
case-04 | pass→pass | 9,575 | 9,158 | -4% | 1 | 1 | 0% | 1,737 | 3,022 | +74% | 0 | 0 | — |
case-05 | pass→pass | 12,785 | 12,461 | -3% | 1 | 1 | 0% | 2,301 | 3,535 | +54% | 0 | 0 | — |
case-06 | pass→pass | 12,880 | 8,593 | -33% | 1 | 1 | 0% | 2,155 | 2,828 | +31% | 0 | 0 | — |
case-07 | pass→pass | 12,885 | 11,676 | -9% | 1 | 1 | 0% | 2,198 | 3,550 | +62% | 0 | 0 | — |
case-08 | pass→pass | 11,897 | 8,626 | -27% | 1 | 1 | 0% | 2,087 | 2,769 | +33% | 0 | 0 | — |
case-09 | pass→pass | 12,779 | 12,725 | -0% | 1 | 1 | 0% | 2,470 | 4,070 | +65% | 0 | 0 | — |
case-19 | pass→pass | 15,030 | 12,016 | -20% | 1 | 1 | 0% | 2,591 | 3,264 | +26% | 0 | 0 | — |
case-10 | fail→pass | 17,184 | 12,583 | -27% | 1 | 1 | 0% | 2,824 | 3,478 | +23% | 0 | 0 | — |
case-11 | pass→pass | 13,639 | 10,308 | -24% | 1 | 1 | 0% | 2,711 | 3,155 | +16% | 0 | 0 | — |
case-12 | pass→pass | 10,036 | 7,035 | -30% | 1 | 1 | 0% | 1,968 | 2,771 | +41% | 0 | 0 | — |
case-13 | pass→pass | 11,858 | 14,456 | +22% | 1 | 1 | 0% | 2,112 | 3,661 | +73% | 0 | 0 | — |
case-14 | pass→pass | 13,055 | 12,899 | -1% | 1 | 1 | 0% | 2,283 | 3,509 | +54% | 0 | 0 | — |
case-15 | pass→pass | 10,063 | 7,769 | -23% | 1 | 1 | 0% | 1,900 | 2,679 | +41% | 0 | 0 | — |
case-16 | pass→pass | 14,402 | 9,490 | -34% | 1 | 1 | 0% | 2,582 | 3,187 | +23% | 0 | 0 | — |
case-17 | pass→pass | 5,462 | 2,691 | -51% | 1 | 1 | 0% | 944 | 1,787 | +89% | 0 | 0 | — |
case-20 | pass→pass | 12,926 | 10,167 | -21% | 1 | 1 | 0% | 2,431 | 3,370 | +39% | 0 | 0 | — |
case-21 | pass→pass | 12,437 | 12,746 | +2% | 1 | 1 | 0% | 2,383 | 4,024 | +69% | 0 | 0 | — |
case-22 | pass→pass | 12,322 | 11,907 | -3% | 1 | 1 | 0% | 2,350 | 3,645 | +55% | 0 | 0 | — |
DecimalAI ran this skill against gemini-3.6-flash twice over the same eval suite — once with the skill loaded and once without — and compared the two runs case by case. 22 cases were attempted. The headline lift of +9 percentage points is the difference between those two pass rates over the 22 comparable cases.
Without the skill loaded, the model failed this case. With it loaded, the same prompt on the same model passed. This is one improved case from the latest verified run; every case, including any that regressed, is in the table above.
Other measured skills in the registry, with their headline benchmark lift.