How do you test that an unhandled exception from a handler is turned into the service's contracted error response?
answer
- force the throw at a replaced seam
- assert a response, not a throw
- search the raw body for internals
- the test setup may re-throw instead
- specific mapping must beat the catch-all
basics
~20 sForce the failure at a replaced collaborator so a handler throws something nothing maps, then assert the response: the contracted status, the published error body with its code and correlation identifier, and no exception type, message or stack anywhere in it.
solid answer
~50 sMake the failure happen where you control it - replace a collaborator with one that throws a type the application has no specific mapping for - and send the request through dispatch so the framework's error path runs. Then assert on the response rather than on the exception: the status the service contracts for an unexpected failure, the error body's machine-readable code and correlation identifier, the content type, and the absence of the exception class name, its message and any stack frames. Two traps make this test meaningless. Many in-process test setups propagate the original exception to the caller instead of producing a response, so the test fails with a throw and never sees the error path; configure the run the way the service ships. And a verbose development error format can be enabled in tests, so the leak assertion passes locally and fails in production.
go deeper
Recall that the test forces a throw at a replaced collaborator and then asserts a response - status and error body - rather than asserting that an exception escaped.
Explain what to assert: exact status, published body with its code and correlation identifier, content type, and no class name, message or stack in the raw body.
Show the traps you check for: a test setup that re-throws, a verbose development format enabled in tests, and a catch-all ordered ahead of specific mappings.
Decide the service-wide contract for unexpected failures - one opaque traceable answer - and how it is kept honest across services without duplicating the case everywhere.
## What this test actually claims Every service has a last line of defence: something that catches what no specific mapping handled and turns it into a response. It is the layer that decides whether a caller sees the service's published error shape or a raw internal failure with a class name and a stack in the body. Because it fires only on defects, it is the layer least likely to be exercised by ordinary tests and most likely to be wrong when it matters. The claim under test is narrow and worth stating precisely: **when a handler throws something the application has no mapping for, the response is the contracted error - right status, right body, no internals** - and the failure is recorded on the server side rather than discarded. ## Constructing the failure Do not reach for a real fault. Force it at a seam: 1. **Replace a collaborator the handler calls** with one that throws a type deliberately chosen to have no specific mapping - a plain runtime failure, not a domain error the application converts on purpose. 2. **Send the request through dispatch**, so the whole chain runs. Calling the handler directly only proves that it throws, which was never in doubt. 3. **Assert on the response**, never on the exception. The moment the test's expectation is "a throw reaches me", it has proven that the error path did *not* run. ## What to assert - **Status** - the one the service documents for an unexpected failure, asserted exactly. - **Error body** - the same published shape as every other error, with its machine-readable code and, where the service carries one, a correlation identifier that also appears in the server-side record. - **Content type** - error responses drifting to a different representation than the rest of the API is a common and invisible defect. - **Absence of internals** - no exception class name, no exception message, no stack frames, no file paths. Assert on the serialised body text, because a field-level assertion will not notice a message smuggled into a free-text field. - **The failure was recorded** - the server-side record exists at error level with the same correlation identifier, so a real incident is traceable. ## The two traps that make this test lie **The test setup re-throws.** Many in-process test arrangements propagate an exception raised inside the chain straight to the calling test, because that makes ordinary failures easier to debug. Under that setting the error path is bypassed entirely: the test either fails with the raw exception or, worse, is written to *expect* the throw and is then asserting the exact opposite of the requirement. Configure the run so failures are converted the way the deployed service converts them, and write the case to assert a response. **The verbose format is on.** A development-oriented error format that includes exception detail is often enabled in test configuration for convenience. The leak assertion then passes on a body that legitimately contains no detail in that mode, while the shipped configuration behaves differently - or, in the reverse case, the test fails on detail the deployed service would never emit. Run this case against the configuration the service actually ships, and if both modes exist, cover each with its own case. | Setup choice | What the test proves | What it silently loses | |---|---|---| | Handler invoked directly | The handler throws | The entire error path | | Dispatched, exception re-thrown to the test | The exception escapes the chain | The response, which is the whole subject | | Dispatched, failures converted as deployed | Status, body, no leak, recorded | Nothing - this is the case | ## Ordering, and the case next to it The catch-all must not steal failures that have a specific mapping. Pair this test with one that throws a type the application *does* map, and assert that the specific mapping's status and error code win. Without that pair, a catch-all registered too broadly silently turns every domain failure - a validation failure, a conflict - into the generic internal answer, and each of those has its own test only if someone wrote it. It is also worth asserting what the response does **not** carry beyond the body: no diagnostic headers added by a development-only handler, and nothing that would confuse a caching intermediary. The point of the whole case is that a defect inside the service produces one predictable, opaque, traceable answer at the edge - the same answer whatever went wrong inside.
- Why is a test that expects the exception to reach it the wrong shape for this requirement?Because an exception reaching the test means the chain did not convert it - exactly the behaviour the requirement forbids. Such a test passes only in a configuration the service does not ship, and it would keep passing if the catch-all were deleted.
- How do you assert that no internal detail leaked, beyond checking the documented fields?Assert against the serialised body text: it must not contain the exception's class name, its message, a stack frame marker, or a file path. Field-level assertions miss detail smuggled into a free-text message field, which is the usual way it escapes.
- What companion test keeps a catch-all mapper from swallowing failures that have specific mappings?One that throws a type the application maps deliberately and asserts the specific status and error code, not the generic ones. It fails the moment the catch-all is registered too broadly or ordered ahead of the specific mapping, which is how domain failures quietly become internal ones.
saying these in an interview costs you the question
- Writes the case to expect the exception reaching the test
- Calls the handler directly and asserts it throws
- Checks documented fields only and misses a message field leaking the cause
- Runs the case under a verbose development error format
- Assumes a catch-all cannot steal failures that have specific mappings
- Accepts an empty body because the status was right