Your web-layer test asserts a forbidden outcome on a protected route and passes. What could make it green for the wrong reason?
answer
- many causes, one status
- did the identity actually attach?
- an empty fixture answers too
- pair it with a positive control
- assert the error code, not the family
basics
~20 sSeveral layers can produce that status: an identity that never attached, a missing fixture, an overlapping route, or a broad error mapper flattening an unrelated failure. Pin the body's error code and pair the case with a positive control.
solid answer
~50 sNegative tests are easy to make green and hard to make meaningful, because several different causes land on the same status. The identity may never have attached, so the request was refused as unidentified on a service that answers both cases alike. The fixture the route needs may be absent, so the service's own precedence for a resource the caller may not see produced the answer. The path may have matched a different, overlapping route. Or a catch-all error mapper turned a wiring or serialization fault into the same status. The defences are concrete: assert the machine-readable error code in the body rather than the status alone, pair the case with a positive control on the same path and fixture that must succeed, assert that the handler's collaborator was never invoked, and change one factor at a time between the two.
go deeper
Recall that a refusal status can come from more than one layer, so a negative test needs the body's error code and a positive twin before it means anything.
Explain the distinct causes that land on one status - identity absent, fixture absent, another route matched, a catch-all mapper - and the assertion that separates each.
Demonstrate the diagnosis on a real suite: prove which layer answered, use paired cases and fixtures, and fail the run on unexpected error output.
Set the standard that makes negative coverage trustworthy service-wide - error codes per cause, paired positives - and judge what that discipline costs in test volume.
## Why negative tests rot quietly A positive test fails loudly when the system breaks: the payload is wrong, a field is missing, the status is not the success one. A negative test asserts that something did *not* happen, and almost any breakage also results in the thing not happening. That asymmetry is why an interviewer asks this: writing the case is trivial, making it prove the specific rule is the skill. The concrete danger is that one status is the endpoint of several distinct paths through the stack. Green tells you the response carried that status; it does not tell you which layer decided it. ## The ways this case goes green without proving anything 1. **The identity never attached.** The test meant to run as a caller who is identified but not permitted. If the principal was never installed - a helper misused, an annotation or setup hook that did not apply to this test, a client that dropped the header - the request was refused as unidentified. On a service that deliberately answers both refusals the same way, the status is identical and the rule under test never ran. 2. **The resource does not exist.** The route loads something by identifier before or during the permission decision. With no fixture, the service's precedence for "absent, or present but not visible to you" produces the answer, so the case is green on an empty database and stays green after the rule is deleted. 3. **A different route matched.** Overlapping templates mean the request landed on a neighbouring route - one that is refused for everybody - so the rule attached to the intended route was never consulted. 4. **A broad error mapper flattened an unrelated failure.** A catch-all mapping that converts a wide class of failures into one client-error status can turn a wiring fault, a missing dependency, or a serialization error into the very status being asserted. 5. **A dependency replacement made the answer inevitable.** If the permission source is stubbed to return nothing at all, every caller is refused, including the one the positive case relies on - and the rule's real logic is untested. | Cause of the green | What is really being asserted | The assertion that exposes it | |---|---|---| | Identity never attached | Unidentified callers are refused | The body's error code, distinct per cause | | Fixture missing | Absent resources are refused | A positive control on the same fixture | | Overlapping route matched | Some other route refuses everyone | Assert which handler ran, or the matched template | | Catch-all mapper flattening | Something failed, somewhere | No internal-failure signal; distinct error code | | Permission source stubbed empty | The stub returns nothing | The paired positive case fails too | ## How to make the case honest **Assert the machine-readable error code, not just the status.** A service that publishes an error code per cause makes these paths distinguishable in one assertion. This is the single highest-value change, because it turns a status shared by five causes into a claim about one of them. **Pair it with a positive control.** The same path, the same fixture, the same method, one factor changed - the caller's permission - must produce the success response. If the control does not pass, the negative case proves nothing, because the route was unreachable for reasons that have nothing to do with the rule. Running the two as a pair, ideally adjacent in the same file, keeps that relationship visible to the next reader. **Make the fixture exist.** Deliberately create the resource the route addresses, so "absent" is excluded as a cause and the refusal has to come from the permission decision. This also pins the service's chosen precedence in the direction it actually ships, instead of leaving it to whichever cause happens to fire first. **Assert the absence of the side effect.** With the handler's collaborator replaced by a recording stub, assert it was never invoked. A refusal that still performed work is a different and worse defect than a wrong status, and no status assertion can see it. **Watch for an internal-failure signal.** If the test run logs an unexpected exception while the assertion passes, the catch-all mapper is doing the work. Failing the test on unexpected error-level output is a cheap way to catch that whole class. ## The habit behind all of it Change one factor at a time and know which factor the case owns. A negative web-layer case is only as good as the positive case it is the twin of; alone, it is a claim that something did not happen, which is exactly what a broken system also produces.
- Why does a paired positive case on the same path make the negative case trustworthy?It removes every cause that is common to both. If the same path, fixture and method succeed for a permitted caller, then the route is reachable, the fixture exists and the wiring works - so the only thing left to explain the refusal is the factor that changed, which is the rule under test.
- How does asserting a machine-readable error code help when several causes share one status?The status is the coarse signal the protocol offers; the error code is the service's own account of why. A distinct code per cause lets the assertion say which layer answered, turning a test that accepts five different failures into one that accepts exactly the intended one.
- How would you notice that a broad error mapper is quietly producing your asserted status?Watch the test run's own diagnostics: an unexpected error-level log or a captured exception while the assertion passes is the signature. Making the suite fail on unexpected error-level output during a test turns that silent class of false greens into a visible failure.
saying these in an interview costs you the question
- Treats a passing negative test as proof the rule ran
- Asserts the status alone when several causes share it
- Runs the negative case with no fixture and no positive control
- Ignores an exception logged during a test whose assertion passed
- Stubs the permission source to return nothing and calls the rule covered
- Assumes overlapping route templates cannot change which handler answered