Even with passing Controller tests using @WebMvcTest and MockMvc, many people haven’t verified whether the GlobalExceptionHandler (@RestControllerAdvice) returns the intended response when an exception occurs.
Happy-path tests alone tend to leave exception paths uncovered, don’t they? As a follow-up to @WebMvcTest, this article focuses specifically on verifying exception handlers. For the basic syntax, see How to Write Controller Unit Tests with @WebMvcTest, and for the implementation approach itself, see How to Implement a Unified Error Response.
The following code examples assume given / any / status / jsonPath are statically imported.
Assumed Controller/Service/Exception structure
We assume a structure where the Controller stays thin and business exceptions are thrown from the Service layer.
@RestController
@RequestMapping("/api/users")
public class UserController {
private final UserService userService;
public UserController(UserService userService) {
this.userService = userService;
}
@GetMapping("/{id}")
public UserResponse getUser(@PathVariable Long id) {
return userService.findById(id);
}
}
We assume the Service throws ResourceNotFoundException (404), IllegalArgumentException (400), and DuplicateResourceException (409), and that GlobalExceptionHandler converts them into the corresponding status codes.
Is @RestControllerAdvice included in the @WebMvcTest context?
@WebMvcTest automatically scans not only Controllers but also @ControllerAdvice / @RestControllerAdvice, so in most cases you can write tests without worrying about this at all.
If you’re unsure, deliberately throw an exception and check the result. If you get back Spring Boot’s standard error JSON with timestamp / status / error / path, that’s a sign the Advice isn’t being picked up.
If you mix up @ControllerAdvice and @RestControllerAdvice, @ResponseBody won’t be applied and view resolution will kick in instead. If the response isn’t coming back as JSON, suspect this difference first.
Triggering exceptions with @MockitoBean
To reliably exercise the exception path, mock the Service layer method so it throws an exception.
@WebMvcTest(UserController.class)
class UserControllerExceptionTest {
@Autowired
private MockMvc mockMvc;
@MockitoBean
private UserService userService;
@Test
void returns404WhenUserNotFound() throws Exception {
given(userService.findById(999L))
.willThrow(new ResourceNotFoundException("User not found: 999"));
mockMvc.perform(get("/api/users/999"))
.andExpect(status().isNotFound())
.andExpect(jsonPath("$.status").value(404))
.andExpect(jsonPath("$.detail").value("User not found: 999"));
}
}
@MockBean is deprecated, so use @MockitoBean for new tests. Note that if the argument passed to willThrow() doesn’t match the actual method signature, the mock won’t take effect and the real implementation will be called instead.
Verifying 400, 409, and 500 by status code as well
It’s the same pattern as the 404 case — just vary the status and the exception class.
@Test
void returns400WhenArgumentIsInvalid() throws Exception {
given(userService.findById(-1L))
.willThrow(new IllegalArgumentException("id must be positive"));
mockMvc.perform(get("/api/users/-1"))
.andExpect(status().isBadRequest())
.andExpect(jsonPath("$.title").value("Bad Request"));
}
@Test
void returns409WhenEmailIsDuplicated() throws Exception {
given(userService.create(any()))
.willThrow(new DuplicateResourceException("email already exists: [email protected]"));
mockMvc.perform(post("/api/users")
.contentType(MediaType.APPLICATION_JSON)
.content("{\"email\":\"[email protected]\"}"))
.andExpect(status().isConflict())
.andExpect(jsonPath("$.detail").value("email already exists: [email protected]"));
}
One thing that’s surprisingly easy to forget is the fallback for a RuntimeException that isn’t caught by the handler.
@Test
void returns500WithoutLeakingStackTrace() throws Exception {
given(userService.findById(1L)).willThrow(new RuntimeException("db connection timeout"));
mockMvc.perform(get("/api/users/1"))
.andExpect(status().isInternalServerError())
.andExpect(jsonPath("$..stackTrace").doesNotExist());
}
Checking that ex.getMessage() isn’t being output as-is helps prevent information leaks. For the masking implementation, see GlobalExceptionHandler for Production Use.
Both ProblemDetail and custom formats can be verified with jsonPath
When adopting ProblemDetail (RFC 9457), in addition to status / title / detail, also check that the Content-Type is application/problem+json (if you overlook this, you won’t notice even if it stays application/json). Also keep in mind that when type / instance aren’t set, about:blank is returned.
mockMvc.perform(get("/api/users/999"))
.andExpect(content().contentType(MediaType.APPLICATION_PROBLEM_JSON))
.andExpect(jsonPath("$.type").value("about:blank"))
.andExpect(jsonPath("$.title").value("Not Found"));
Even with a custom ErrorResponse format (errorCode / message / timestamp, etc.), the overall structure is the same — only the field names change. For verifying extension properties, refer to GlobalExceptionHandler for Production Use.
Keeping things from getting repetitive as exceptions increase
As the number of exception classes grows, you end up with more and more tests of the same shape. Managing them as a list with @ParameterizedTest makes it easier to notice if you forget to add one.
@ParameterizedTest
@MethodSource("exceptionAndStatus")
void mapsExceptionToExpectedStatus(RuntimeException thrown, HttpStatus expected) throws Exception {
given(userService.findById(1L)).willThrow(thrown);
mockMvc.perform(get("/api/users/1"))
.andExpect(status().is(expected.value()));
}
static Stream<Arguments> exceptionAndStatus() {
return Stream.of(
Arguments.of(new ResourceNotFoundException("not found"), HttpStatus.NOT_FOUND),
Arguments.of(new IllegalArgumentException("bad arg"), HttpStatus.BAD_REQUEST),
Arguments.of(new DuplicateResourceException("duplicate"), HttpStatus.CONFLICT)
);
}
All the exceptions passed here are unchecked exceptions (subclasses of RuntimeException), so they can be passed directly to willThrow(). If you use a checked exception instead, be aware that the handling of the throws declaration changes.
When @RestControllerAdvice isn’t loaded
Even with @WebMvcTest(UserController.class), the Advice is normally auto-detected. However, since @WebMvcTest determines its scan scope starting from @SpringBootConfiguration, detection can fail if GlobalExceptionHandler is outside that target package. In that case, explicitly import it with @Import.
@WebMvcTest(UserController.class)
@Import(GlobalExceptionHandler.class)
class UserControllerExceptionTest {
// ...
}
If the Advice isn’t loaded, exceptions without @ResponseStatus (such as ResourceNotFoundException) propagate as-is, and with the default behavior you’ll get 500 Internal Server Error (not 404). You can tell the difference because the body also switches to Spring Boot’s standard error JSON with timestamp / status / error / path.
Summary
Happy-path tests and exception handler tests have different things to check, don’t they? Once you’ve got the basic pattern down — using thenThrow() with @MockitoBean to exercise the exception path, then verifying the status and key body fields with jsonPath — you can handle both ProblemDetail and custom formats.
If you want to dig deeper into the implementation details, also check out the Exception Handling Implementation Guide and GlobalExceptionHandler for Production Use.