156: Full-Stack Testing: Unit, Integration, Contract, Component, and E2E
Learning outcomes
By the end of this lesson, you can:
- explain and apply unit tests in a realistic implementation;
- explain and apply integration tests in a realistic implementation;
- explain and apply contract tests in a realistic implementation;
- explain and apply component tests in a realistic implementation;
- explain and apply end-to-end tests in a realistic implementation.
Prerequisites and retrieval
This lesson assumes the 01–06 foundation and the preceding lessons in this module. Before you read, retrieve one concrete example from an earlier project in which the same concern appeared. Perhaps a test depended on a database, a client and server disagreed about an error response, or a UI showed the wrong state after an asynchronous request. The point is not to memorize a set of labels. The point is to make a defensible testing decision inside a production full-stack web application with a React client, a Node.js API, persistent storage, authentication, observability, and deployment concerns.
Terminology
The test categories below describe useful boundaries, not mutually exclusive technologies. A single feature will usually need more than one kind of test, with each test proving a different part of the behavior.
- Unit tests: Use unit tests for pure domain rules, parsers, formatters, policy functions, and algorithms whose dependencies can be absent or represented by small fakes. They are valuable when the behavior can be examined without starting a server or connecting to a database.
- Integration tests: Use integration tests when behavior depends on a real database, HTTP stack, serialization, transaction, middleware chain, or adapter semantics that mocks can misrepresent. They test whether parts work together through the boundary that matters.
- Contract tests: An API contract test verifies status, body shape, headers, error behavior, and compatibility. It catches client/server drift without having to reproduce every browser interaction.
- Component tests: Component tests exercise user-observable behavior, accessibility roles, form submission, asynchronous transitions, and error states. They should interact with the component as a user or assistive technology would, rather than depending on private implementation state.
- End-to-end tests: Reserve E2E tests for critical journeys such as login, checkout, or role-sensitive workflows. They provide broad confidence across the deployed-style stack, but they are slower and more sensitive to environment and fixture problems.
- Test data and isolation: Each test should own its data and cleanup strategy. Isolation prevents one test's records, time-dependent state, or failed cleanup from changing the result of another test.
Mental model
Treat Full-Stack Testing: Unit, Integration, Contract, Component, and E2E as a design problem. Start with observable inputs and outputs, then identify the invariants that must remain true and the failure modes that could violate them. The best test proves a contract at the cheapest stable boundary. That does not mean every rule belongs in a unit test, or that browser tests are unnecessary. It means you avoid turning every rule into a slow, brittle browser scenario when a smaller test could prove it just as well.
A strong implementation makes assumptions visible, narrows uncertainty at boundaries, and leaves enough evidence—tests, types, constraints, metrics, or diagrams—to explain why the design is safe. Testing is therefore part of the design, not a final layer of decoration added after the implementation.
A useful interview and production sequence is:
requirement -> constraints -> model -> implementation -> failure analysis -> verification
Do not jump from a requirement directly to a library call. First state what must remain true. Then choose the mechanism that enforces it and the test boundary that can demonstrate it. For example, “a user may create a task only for an authenticated account” is a requirement; authentication, ownership, persistence, and the HTTP response are separate constraints and observable contracts.
Deep dive
1. Unit tests
Use unit tests for pure domain rules, parsers, formatters, policy functions, and algorithms where dependencies can be absent or represented by small fakes. A unit test is a good fit when the behavior is local and its inputs can be supplied directly. An authorization policy such as “an editor may update a task in their own project, but a viewer may not” can often be tested without an HTTP server or a database.
That narrow boundary gives fast feedback and makes failures easy to interpret. The trade-off is that a unit test cannot prove that a route wires the policy correctly, that a database query enforces ownership, or that serialized input has the expected shape. Those are different contracts and need coverage at their owning boundaries.
Decision rule: Use unit tests deliberately when they make a contract or invariant easier to prove. If a unit test only reduces typing while hiding an important assumption about a real dependency, prefer a more explicit test or design.
2. Integration tests
Use integration tests when behavior depends on a real database, HTTP stack, serialization, transaction, middleware chain, or adapter semantics that mocks can misrepresent. A mock may say that a query succeeded while the real database rejects a constraint, interprets a filter differently, or exposes a transaction boundary you did not model.
An integration test should cross the smallest real boundary needed to expose that risk. A repository test may use a real test database; a route test may exercise the HTTP stack, middleware, serialization, and repository together. This makes the test slower than a unit test, but the result answers a question a unit test cannot: do these components agree at runtime?
Decision rule: Use integration tests deliberately when they make a contract or invariant easier to prove. If they only reduce typing while hiding an assumption, prefer the more explicit design.
3. Contract tests
An API contract test verifies status, body shape, headers, error behavior, and compatibility. The client may compile successfully and still fail because the server renamed a field, changed an error status, omitted a header, or returned a different representation. A contract test makes those agreements executable without reproducing every browser interaction.
Keep the producer and consumer expectations explicit. Test successful responses as well as malformed input, authentication failures, authorization failures, and dependency failures. A contract test is not a replacement for database or domain tests: it proves what crosses the API boundary, while lower-level tests prove how the result is produced.
Decision rule: Use contract tests deliberately when they make the contract or invariant easier to prove. If they only reduce typing while hiding an assumption, prefer the more explicit design.
4. Component tests
Test user-observable behavior, accessibility roles, form submission, asynchronous transitions, and error states. A useful component test can find that a submit button is disabled while a request is pending, that a validation message is announced through the accessible structure, or that a server error is rendered after submission. It should not need to know which hook, internal state variable, or helper produced that behavior.
This boundary is narrower and more deterministic than a full browser journey, while still checking the behavior a user relies on. Avoid asserting implementation-only state when the user cannot observe it; such assertions make harmless refactoring look like a product regression.
Decision rule: Use component tests deliberately when they make the contract or invariant easier to prove. If they only reduce typing while hiding an assumption, prefer the more explicit design.
5. End-to-end tests
Reserve E2E tests for critical journeys such as login, checkout, or role-sensitive workflows. These tests can exercise the React client, the API, authentication, persistence, and deployment-like configuration in one path. That breadth is their strength, but it also means a failure may originate in any of those layers.
Keep fixtures deterministic and minimize dependence on external providers. A test that relies on a live email service or an unstable third-party API can fail for reasons unrelated to the application. Use E2E coverage for the paths where broad confidence justifies the cost, and use unit, integration, contract, and component tests to explain the details when the journey fails.
Decision rule: Use end-to-end tests deliberately when they make the contract or invariant easier to prove. If they only reduce typing while hiding an assumption, prefer the more explicit design.
6. Test data and isolation
Each test should own its data and cleanup strategy. Transaction rollback, per-test schemas, factories, or containers are preferable to shared mutable fixtures. Shared fixtures create order dependence: a test may pass alone but fail after another test has changed a record, consumed a token, or left a pending job behind.
Isolation is also part of debugging. When a test fails, you want its input and environment to be attributable to that test. Choose the strongest practical isolation for the system under test and verify that cleanup happens after failures as well as after successful runs.
Decision rule: Use test data and isolation deliberately when they make the contract or invariant easier to prove. If an isolation shortcut only reduces setup while hiding shared state, prefer the more explicit design.
Worked example
Consider a production full-stack web application with a React client, a Node.js API, persistent storage, authentication, observability, and deployment concerns. Start by writing the requirement in one sentence. Then list the input and output contracts and identify which concept above owns each failure mode.
The useful design move is separation. Parsing or validation belongs at the boundary; domain rules belong in the domain or service layer; persistence rules belong in the database or repository; presentation rules belong in the client. Mixing these concerns can make a happy-path demo look shorter, but it makes edge cases much harder to reason about and harder to test at the right boundary.
export async function handleRequest(input: unknown) {
const command = parseCommand(input);
const result = await service.execute(command);
return toHttpResponse(result);
}
This function illustrates the boundaries without pretending to implement all of them. parseCommand turns untrusted input into a known command or reports invalid input. service.execute applies the domain behavior and coordinates dependencies. toHttpResponse maps the result to the API's status, headers, and body contract. Each responsibility can be verified separately, while an integration or contract test can verify that the pieces agree.
Walk the example with at least four cases:
- the normal path;
- an empty or missing value;
- a duplicate, retry, or concurrent path where relevant;
- a dependency failure.
For each case, state which layer detects the problem and what the caller observes. An invalid request might be rejected by parsing with a client-visible validation response. A duplicate may require a database constraint or idempotency rule. A dependency failure may become a structured server error without exposing credentials or internal details. This is the level of explanation expected in a senior code review or technical interview: name the invariant, locate the owner, and describe the observable result.
Production perspective
Production correctness is broader than “the code works on my machine.” Ask how the design behaves during deploys, retries, partial failure, stale clients, concurrent requests, malformed data, schema changes, and high cardinality. Prefer explicit contracts, bounded resource usage, structured errors, and measurable behavior. Optimize only after you can identify the bottleneck or risk with evidence.
When the topic involves an external dependency, define a timeout and cancellation strategy. A test should not hang indefinitely because a provider stopped responding. When it involves persistence, define transaction and consistency expectations. When it involves user-visible state, define loading, empty, error, stale, and success states rather than testing only the successful response. When it involves security, assume the client can be modified and that network input is untrusted. Client-side checks improve the user experience; they do not replace server-side authorization, validation, or persistence guarantees.
Guided lab
Create a test plan for login plus task creation. Implement a pure authorization unit test, a database integration test, an HTTP contract test, a React component test, and one E2E happy path. For each test, explain why that behavior belongs at that layer and what a failure would tell you. The tests should complement one another rather than all repeating the same browser journey.
Complete the lab with this discipline:
- Write the requirement and two non-requirements.
- List input, output, and error contracts before implementation.
- Implement the smallest correct vertical slice.
- Add at least one invalid-input test and one edge-case test.
- Instrument or inspect the behavior instead of guessing.
- Refactor one hidden assumption into an explicit type, constraint, function, or configuration.
- Explain one alternative design and why you did not choose it.
- Record a short “what would break at 10× scale?” note.
For example, the non-requirements might exclude password-reset email delivery from this slice and exclude cross-project task access. Stating those boundaries prevents the implementation and its tests from silently expanding beyond the requirement. Your final explanation should connect each test to an invariant, not just to a test-library feature.
Edge cases and failure modes
- Unit tests: Test absence, malformed input, duplicates, ordering or concurrency where applicable, and behavior at the smallest and largest credible sizes. Also check that a policy does not accidentally grant access for an unknown role or identifier.
- Integration tests: Test absence, malformed input, duplicates, ordering or concurrency where applicable, and behavior at the smallest and largest credible sizes. Include the real constraint, transaction, or adapter behavior that a mock would conceal.
- Contract tests: Test absence, malformed input, duplicates, ordering or concurrency where applicable, and behavior at the smallest and largest credible sizes. Verify both success and the status, body shape, headers, and error behavior clients depend on.
- Component tests: Test absence, malformed input, duplicates, ordering or concurrency where applicable, and behavior at the smallest and largest credible sizes. Include loading, empty, error, stale, and successful user-visible states when the component can encounter them.
- End-to-end tests: Test absence, malformed input, duplicates, ordering or concurrency where applicable, and behavior at the smallest and largest credible sizes. Keep the critical journey's data and external dependencies deterministic so a failure remains actionable.
Common mistakes and debugging
- Solving the example instead of the requirement: a copied pattern can be syntactically correct but architecturally wrong.
- Hiding uncertainty with assertions, broad exception handlers, permissive schemas, or “temporary”
anyvalues. - Testing only the happy path and therefore discovering contracts only after integration.
- Optimizing before measuring, or selecting a scalable mechanism without a scale requirement.
- Letting client-side behavior stand in for server-side authorization, validation, or persistence guarantees.
For debugging, reproduce the smallest failing case first. Inspect the actual value or execution plan rather than the value you expected to exist. Trace the boundary where the invariant first becomes false: source or build, browser or DOM, Network or HTTP, server or route, database or query, or deployment or configuration. A component test failure may indicate a rendering-state problem; an HTTP contract failure may indicate serialization or route behavior; a database integration failure may indicate a constraint or transaction issue. Fix the owning layer rather than adding a downstream patch that merely hides the symptom.
Interview questions
- What problem do unit tests solve, and what trade-off or failure mode would make you choose a different approach?
- What problem do integration tests solve, and what trade-off or failure mode would make you choose a different approach?
- What problem do contract tests solve, and what trade-off or failure mode would make you choose a different approach?
- What problem do component tests solve, and what trade-off or failure mode would make you choose a different approach?
- What problem do end-to-end tests solve, and what trade-off or failure mode would make you choose a different approach?
Checkpoint
Without notes, explain Full-Stack Testing: Unit, Integration, Contract, Component, and E2E to another developer in five minutes. Your explanation must include one invariant, one edge case, one production failure mode, and one alternative design. Then implement a small example without copying the lesson code. If you cannot say what a failing test isolates, revisit the boundary it is meant to prove.
Mastery checklist
- I can define the core terms precisely.
- I can choose a design from requirements instead of from habit.
- I can implement and test the normal path and edge cases.
- I can explain the runtime, storage, or complexity cost.
- I can identify which layer owns validation, errors, and recovery.
- I can compare at least two reasonable alternatives.
- I can explain how the design changes at larger scale or stricter reliability.
