In a web framework's middleware chain, how do you test one hook in isolation, and what stands in for the rest of the chain?
answer
- call the hook, not the app
- hand-built request in memory
- stand-in for the rest of the chain
- recording stub captures the call
- assert call count and captured request
basics
~20 sCall the hook directly with a hand-built request and a recording stub in place of the next element. The stub captures whether it was invoked and with which request, so the test can assert delegation without starting a server.
solid answer
~40 sAn isolated hook test constructs three things: a **synthetic request** built in memory, a **recording stub** that stands in for the rest of the chain, and a handle on whatever the hook writes its response into. The test then calls the hook with that request and that stub, and reads the recording. `stub.callCount == 1` says the hook delegated; `0` says it refused the request itself. The request the stub *captured* shows whether the hook enriched it on the way in, and the response shows what the hook added on the way out. No server, no route matching, no application wiring is involved, so the test runs in microseconds and every branch of the hook is reachable by choosing the request.
go deeper
Remember the shape: build a request by hand, pass a stub in place of the next element, call the hook directly, then check whether the stub was called.
Explain that the stub records both the invocation count and the request it received, so delegation, refusal, and context enrichment are all read off one recording.
Show that the stub is a fixture with behaviour of its own: what it returns, and whether it writes a response, decides which production bugs this test can reproduce at all.
Frame the tradeoff: these tests buy cheap per-branch coverage of hook decisions, and the price is a stand-in that can drift from the real downstream until the suite agrees with nothing.
A middleware — variously called a hook, filter, or interceptor — is code a server-side web framework runs around a request on its way to the code that produces the response, and again on the way back out. It is handed the request plus a handle on the rest of the chain, and it does one of two things: **delegate** (invoke that handle so the request continues) or **short-circuit** (produce a response itself and never invoke it). An isolated test exercises exactly that decision. It starts no server, opens no socket, matches no route, and builds no application graph. ## The fixture has three parts 1. **A synthetic request.** Built in memory by the test: a method, a path, whatever headers the hook reads, sometimes a body, sometimes pre-populated context values. Only the fields the hook actually touches need to be realistic; everything else can stay at its default. 2. **A recording stub in place of the next element.** Something with the same shape as the real continuation that records *that* it was called, *how many times*, and *which request value* it received, and then returns a minimal response so the hook has something to work with as the call unwinds. 3. **Somewhere to read the outcome.** Frameworks differ here: in some the hook returns a response object the test inspects directly, in others it writes into a mutable response the test supplied. Either way the test needs a handle on that value before it makes the call. The body of the test is then a single invocation plus assertions. ``` stub = recorder(returns = response(status = 200)) hook = RequestIdHook(generator = fixedIdGenerator) result = hook.handle(request(method = GET, path = "/orders"), next = stub) assert stub.callCount == 1 assert stub.capturedRequest.attribute("requestId") == fixedId assert result.header("X-Request-Id") == fixedId ``` ## What the recording lets you assert | What you want to know | What the test asserts | | --- | --- | | Did the request continue? | the stub recorded exactly one invocation | | Was it refused right here? | the stub recorded zero invocations, and the hook set a status | | Did the hook enrich the request? | the request the stub captured carries the added header or context value | | Did the hook work on the way out? | the response carries what the hook added after the stub returned | | Did it delegate once, not twice? | the recorded count is `1`, not merely non-zero | That table is the whole argument for the technique. A hook's contract is *whether, and with what, the chain continued* — and a stand-in for the chain is the one place where that is directly observable. Through a running application you can only infer it, from a status code or a side effect that some other component might equally have produced. ## Why the technique earns its place - **Branch reach.** A header that is malformed rather than missing, a context value absent because an earlier hook did not run, a clock sitting one second past an expiry: all are trivial to construct as a synthetic request and awkward to provoke end to end. - **Speed and determinism.** There is no startup cost and no shared state, so the test can be written per branch without anybody complaining about suite time. - **Failure localisation.** When it goes red, the hook is the only thing in the frame; nothing about routing, binding, or serialization can be the cause. - **Design pressure.** A hook that cannot be constructed without half the application is telling you it has collaborators it should not have. ## Keeping the fixture honest - **Assert on the captured request, not your own variable.** Some frameworks hand the next element the same request object the hook mutated; others hand it a new value the hook derived. A test that asserts against the object it constructed passes in the first model and silently proves nothing in the second. Reading the recording works in both. - **Assert exactly-once, not at-least-once.** Double delegation — a hook that falls through after already continuing — is a real and nasty bug, and a `callCount > 0` assertion is blind to it. - **Treat what the stub returns as a decision.** If the hook inspects or decorates the response on the way out, the stub's return value is the input to that half of its logic and belongs in the test's name. - **Do not make the request emptier than reality.** A hook that reads a value every real request carries should be tested with it present *and* absent, because the absent case is a live production path. - **Name the test after the decision, not the hook.** `delegates when the token is valid` and `refuses when the header is missing` describe the contract; `testHook2` describes nothing. The technique stops where observation stops: everything asserted is read from the hook's own inputs and outputs, so what the surrounding stack would have done with that response is outside the frame of this test by construction.
- How would you tell a hook that delegated once from one that delegated twice?Assert the recorded invocation count is exactly `1`. A hook that continues the chain and then falls through into a second delegation still produces a plausible response, so an at-least-once assertion passes while the downstream work happens twice.
- The hook is supposed to add a value to the request context. Where does the assertion read it from?From the request the stub captured when it was invoked, not from the request object the test constructed. That reads correctly whether the hook mutated the original or passed a derived copy onward.
- Does it matter what the recording stub returns?It matters whenever the hook does anything after delegating. The returned value is the input to the hook's outbound half, so a stub returning a bare success only tests the outbound path against a success; other returns are separate tests.
Testing a turnstile by pushing one card through it, with a counter on the far side. You do not need the building behind it — only something that records who got past and what they were carrying.
saying these in an interview costs you the question
- Believing a hook can only be tested through a running server.
- Asserting on the request object the test built rather than the one the stub captured.
- Treating 'next was called' as enough, without checking it ran exactly once.
- Never asserting anything about delegation, only about the response status.
- Building a synthetic request so bare that the hook's real branch is never taken.