Testing Custom Exceptions and Error Handling in Java REST Controllers

A REST controller rarely fails in just one way. A request may contain malformed JSON, a valid identifier that does not exist, a business rule violation, or data that conflicts with a record created a moment earlier. In Java applications, custom exceptions give these failures meaning, while a consistent error response helps clients decide what to do next.

Testing this boundary requires more than checking that an exception is thrown. The useful question is whether the controller, exception handler, serialisation layer, logs, and HTTP contract work together. That matters in an Australian production environment, where a service may support customers in Perth, Sydney, and Melbourne while backend systems run in a different time zone or cloud region.

Define The Error Contract Before Writing Tests

A custom exception should represent a meaningful failure in the domain rather than simply wrap a low-level technical problem. OrderNotFoundException, InsufficientCreditException, and DuplicateEmailException communicate different causes and usually deserve different HTTP statuses. A controller test becomes clearer when this mapping is deliberate.

For example, a missing resource commonly produces 404 Not Found, invalid input produces 400 Bad Request, and a state conflict produces 409 Conflict. Authentication and authorisation failures normally belong to 401 and 403 respectively. The exact choice depends on the API contract, but it should remain stable enough for a frontend, mobile app, or integration partner to rely on.

A useful error payload might include a machine-readable code, a safe message, a timestamp, a request or correlation ID, and field-level validation details. Avoid exposing stack traces, SQL fragments, class names, or internal hostnames. An Australian customer seeing “customer record missing from shard syd-03” has learned something operational that should have remained inside the service.

Map Custom Exceptions At A Single Boundary

Spring-based Java APIs often use @RestControllerAdvice to translate exceptions into ResponseEntity objects or ProblemDetail responses. Centralising this work prevents each controller method from inventing its own JSON shape. It also makes the exception-to-status mapping straightforward to test.

The handler should distinguish expected domain failures from unexpected defects. A known business exception can produce a controlled response, while an unhandled NullPointerException or database outage should be logged with diagnostic context and returned as a generic 500 Internal Server Error. Returning a friendly message must not hide an incident from the operations team.

Tests should verify both the HTTP status and the response body. Assert the error code, relevant message, content type, and important headers such as a correlation identifier. Do not make every assertion depend on a full timestamp or generated UUID. Those values should be checked for presence and format rather than exact equality, which keeps the test focused on the contract.

When a service integrates with older Java middleware or remote systems, failure translation can become complicated. Documentation such as the Oracle middleware blog can help explain integration behaviour, but application tests still need to confirm the error presented by the public REST endpoint rather than trusting an upstream exception name.

Exercise The Controller Boundary With MockMvc

@WebMvcTest and MockMvc are a practical combination for testing a Spring MVC controller without starting the entire application. Mock the service dependency, perform an HTTP request, and make the service throw the selected custom exception. The test then verifies the boundary that external clients actually use.

A typical scenario might look like this:

when(orderService.findById(42L))
    .thenThrow(new OrderNotFoundException(42L));

mockMvc.perform(get("/api/orders/42")
        .accept(MediaType.APPLICATION_JSON))
    .andExpect(status().isNotFound())
    .andExpect(jsonPath("$.code").value("ORDER_NOT_FOUND"))
    .andExpect(jsonPath("$.message").value("Order was not found"));

This test does not need to inspect the exception’s private fields or reproduce service-layer logic. Its responsibility is narrower: confirm that a domain failure becomes the agreed HTTP response. Mockito verification can also confirm that the service was called with the expected ID and that no unrelated dependency was invoked after the failure.

Include the status code in the test name or display name. Names such as returns404WhenOrderIsMissing make a failing build easier to understand, especially when a pipeline runs hundreds of controller tests overnight. Teams working across AEST and AWST benefit from failures that explain the expected contract without requiring a developer to reproduce the case locally.

Cover Validation And Malformed Requests

Error handling begins before a custom service exception is raised. Jackson may reject malformed JSON, Bean Validation may reject an empty field, and Spring may fail to convert a path variable such as "abc" into a Long. These situations often pass through different framework handlers, so they deserve separate tests.

Use representative payloads rather than testing only an empty request. Check missing fields, invalid email formats, negative amounts, excessive string lengths, and unknown enum values when those cases matter. For validation errors, assert that the response identifies the affected field and uses a predictable structure. A client should be able to display “postcode is required” rather than a generic “request failed”.

Australian services often handle addresses containing state abbreviations, unit numbers, and postcodes from locations such as Parramatta, Geelong, or Fremantle. Test those values as ordinary valid data, while also checking invalid postcode formats and Unicode names. Locale-sensitive parsing should use an explicit locale and format rather than relying on whichever locale happens to be configured on a developer’s laptop.

Malformed JSON tests are valuable because they protect the API from accidental framework changes. An upgrade can alter the default message from a concise client error to a verbose parser detail. If the public contract requires a stable error code such as INVALID_JSON, the test will expose that change immediately.

Test Asynchronous And Downstream Failures

A REST controller may call a queue, an HTTP client, or a database before returning its response. Timeout, connection, and rejection failures should be translated according to their meaning at the API boundary. A downstream timeout might become 504 Gateway Timeout, whereas a dependency refusing a request could result in 503 Service Unavailable.

For asynchronous code using CompletableFuture, Reactor, or messaging callbacks, test both immediate and delayed failure paths. An exception wrapped in CompletionException should still be converted to the intended public error. Tests should also prove that a failure does not produce two responses, leave a future incomplete, or incorrectly return 200 OK with an error hidden inside the body.

Use deterministic tests instead of sleeping for an arbitrary number of milliseconds. Inject a controllable executor, virtual clock, or completed future so the test can trigger success and failure directly. This is especially useful when NBN latency, a busy Melbourne data centre, or a temporary link to a service in Singapore would otherwise make timing-based tests unreliable.

Integration tests can complement controller tests by using WireMock, Testcontainers, or a stub HTTP server. Keep the layers distinct: unit-level tests establish the exception mapping quickly, while a smaller group of integration tests checks that the actual client, serialiser, and timeout configuration work together.

Verify Logging Without Leaking Sensitive Data

A useful error response and a useful log record serve different audiences. The response should help the client recover, while the log should help engineers investigate. Tests can verify that unexpected exceptions are logged at error level and that expected business failures are recorded at an appropriate level without generating noisy alerts.

Correlation IDs are particularly valuable when a request travels through a gateway, controller, message broker, and several downstream services. Assert that the ID is returned in the response header or body when that is part of the contract. Avoid asserting a hard-coded value unless the test deliberately supplies one through a request header.

Sensitive information needs explicit protection. Do not log passwords, access tokens, full payment details, or unnecessary personal information. For an Australian customer record, even a complete address or phone number may be personal data. A test using a log appender can check that a known secret and selected sensitive fields do not appear in the emitted message.

Metrics provide another testing angle. A counter for ORDER_NOT_FOUND may be useful, while a counter based on every unique exception message can create uncontrolled cardinality. Verify only important metric behaviour, and keep operational labels bounded by values such as exception category, endpoint, and status code.

Build A Focused Error-Test Suite

A dependable suite combines isolated handler tests, controller slice tests, and a small number of full integration tests. Handler tests cover mapping rules quickly. Controller tests verify request binding, validation, service interaction, and JSON output. Integration tests protect configuration, filters, serialisation, and real dependency boundaries.

Parameterised tests are effective when several custom exceptions share the same response structure. Each case can provide the exception, expected status, and error code. Keep a few dedicated tests for special behaviour, such as field validation, retry headers, or a conflict response containing a resource link.

Property-based testing can explore combinations of malformed values, unusual Unicode input, and boundary numbers. It is useful for parsers and validation rules, but it should support rather than replace readable examples of important business failures. Contract tests with API consumers can also detect changes that a server-side test suite cannot see.

A practical checklist for a Java REST error-handling suite includes:

Good tests make failure behaviour predictable under pressure. They show whether a client can distinguish a missing order from an unavailable dependency, whether an engineer can trace a request across services, and whether a framework upgrade has altered the public contract. Run the fast controller tests on every change, retain integration coverage for release pipelines, and treat each error response as a designed part of the API rather than an accidental side effect.