Four Test Levels: Unit, Functional, Integration, End-to-End
CoreIdentify the type of a Claude application error · Difficulty 2/5
Explanation
A Different Cut Than the Failure Buckets
The five failure buckets classify a failure *after* it happens. Test levels are a complementary idea: they classify what a given *test* is actually capable of catching *before* anything fails, so a passing test suite doesn't quietly hide a gap nobody wrote a test for. A given test only tells you something if you know in advance the specific class of failure it's positioned to catch -- and, equally, the classes it has no way of catching.
| Level | What it isolates | What it cannot catch |
|---|---|---|
| Unit | A single function on its own -- a parser, a tool wrapper, isolated from its surroundings | Whether the pieces actually work once wired together |
| Functional | One Claude call, checked for the right shape and type against a given input | Problems in the system surrounding that one call |
| Integration | The join between two components -- e.g., a retrieval result feeding into a model call | Problems that only show up once the whole pipeline runs together |
| End-to-end | The complete path, run the way a real user would trigger it, input through to output | *Which* link in the chain actually broke -- it only shows you the final outcome, and it's the slowest tier to run |
Why the Integration Level Is Where Silent Failures Hide
This is the level worth dwelling on, because it's the one most teams skip. Each side of a handoff can sail through its own test while the connection between the two sides is still broken -- and neither unit test, run on its own, will ever surface that.
Here's a concrete shape of that failure: an order-lookup function is unit-tested in isolation and correctly returns a structured order record -- it passes. A response-formatter function is functionally tested by handing it a hand-built order record directly, and it correctly renders a customer-facing summary from that record -- it passes too. Wire the two together, though, and the order-lookup function actually returns its record wrapped inside a status envelope ({"status": "ok", "data": {...}}), while the formatter was written expecting the bare record itself. The formatter chokes silently on the mismatch and falls back to a generic "order not found" message even though the lookup succeeded. Neither test sees this: the lookup function is doing exactly what its unit test checks, and the formatter is doing exactly what its functional test checks against a well-formed input. The gap lives specifically in the never-written contract *between* the two functions, and only a test exercising both together, with real data flowing from one into the other, would catch it.
PASS test_lookup_unit returns a valid order record
PASS test_formatter_functional renders a summary from a well-formed record
FAIL test_full_flow_e2e
step 1 lookup_order(id) ok -> {"status": "ok", "data": {...}}
step 2 format_summary(resp) ok -> formatter reads resp directly, not resp["data"]
step 3 assert summary FAIL -> "order not found" shown despite a successful lookupComplementary to, Not a Replacement For, Reproduce-Inspect-Localize
The four test levels and the reproduce-inspect-localize procedure (4.2.1) answer different questions and work together, not as alternatives. Test levels are a *design-time* discipline: deciding in advance which level of test would catch a given class of bug, so the gap is closed before it ships. Reproduce-inspect-localize is a *post-hoc* discipline: given a failure that already happened in the wild, working backward to find which layer owns it. A missing integration test is exactly the kind of gap that reproduce-inspect-localize will eventually surface in production -- the fix, once localized, is often to add the integration test that should have existed already, so the same seam-level break can't ship silently again.
Common exam traps
- Assuming that unit tests plus functional tests passing means the system works. Both can pass while the handoff between the two components they cover is still broken -- that gap is precisely what the integration level exists to close.
- Treating end-to-end tests as sufficient on their own because they exercise the whole flow. They catch that *something* broke but are the hardest level to localize a failure within -- they don't tell you *where* in the chain the break occurred.
Key Takeaways
- Four test levels: unit (one function in isolation), functional (one Claude call returns the expected shape), integration (the seam between two components), end-to-end (the full flow)
- The integration level is where most silent failures hide -- each side can pass its own test while the handoff between them is still broken
- Classic integration gap: a retrieval function returns a list of dicts, a prompt-builder expects a plain string -- both pass their own tests, the combination silently fails
- End-to-end tests are the slowest to run and the hardest to localize a failure within, even though they exercise the whole flow
- Test levels are a design-time discipline (which test would catch this class of bug) that complements, not replaces, the post-hoc reproduce-inspect-localize procedure
Glossary Terms
The five recognizable buckets a Claude application failure falls into -- transport/HTTP (429/529/5xx), request error (400/401), parsing/validation (a code-level exception reading the response), model-output error (well-formed but wrong content), and tool-loop error (wrong tool, malformed arguments, or a mis-fed tool_result). The bucket a failure falls into dictates the correct fix, and misclassifying it sends the fix to the wrong layer.
A pattern where structured output that fails validation is sent back to Claude with the specific error, requesting a correction. More effective than silent retries because Claude uses the error feedback to understand and fix the problem. Typically capped at 2-3 retries.
Related Concepts