Guide testing strategy across unit, integration, and E2E test layers. Use when writing tests, choosing test approaches (TDD vs test-after), implementing test doubles (mocks, stubs, spies, fakes), designing test data with builders/factories, managing flaky tests, or setting up CI test pipelines. Triggers: "testing", "unit test", "integration test", "E2E", "end-to-end", "mocking", "test coverage", "test pyramid", "flaky tests", "TDD".
Tests are documentation and a safety net for change. Write tests that provide value proportional to their maintenance burden. Test behavior, not implementation—tests should fail when the system breaks, not when you refactor it.
┌───────────┐
│ E2E │ Few, slow, high confidence
├───────────┤
│Integration│ Some, medium speed
├───────────┤
│ Unit │ Many, fast, focused
└───────────┘
| Layer | Speed | Scope | Confidence | Maintenance |
|---|---|---|---|---|
| Unit | Fast (ms) | Single function/component | Low | Low |
| Medium (seconds) |
| Multiple components |
| Medium |
| Medium |
| E2E | Slow (minutes) | Full system | High | High |
Adjust based on your system's characteristics.
Good candidates: Pure functions (same input → same output), business logic and calculations, data transformations, input validation, edge cases and error handling.
Poor candidates: Glue code with no logic, direct database queries, framework code.
Test behavior, not implementation: Test what the function does, not how. Tests should survive refactoring. Avoid testing private methods.
One assertion per concept: Each test verifies one behavior. Multiple assertions are fine if testing one concept. Test name describes the behavior.
Arrange-Act-Assert (AAA):
// Arrange: Set up preconditions
const calculator = new Calculator();
// Act: Execute the behavior
const result = calculator.add(2, 3);
// Assert: Verify the outcome
expect(result).toBe(5);
Name tests to describe the behavior:
calculatesTotalWithTaxreturnsNullForInvalidInputthrowsErrorWhenUnauthorizedDon't test: Internal method calls, specific data structures (unless part of contract), execution order (unless it matters).
Do test: Return values, side effects (what changed), error conditions.
Integration tests verify that components work together:
Test at natural integration points:
| Strategy | Speed | Isolation | Reality |
|---|---|---|---|
| In-memory DB | Fast | High | Lower |
| Test containers | Medium | High | High |
| Shared test DB | Fast | Lower | High |
| Mocked DB | Fast | High | Lowest |
Use test containers for CI, in-memory for rapid local iteration, seed known data for deterministic tests.
Options:
Hybrid approach: Use mocks for most integration tests, run contract tests periodically.
E2E tests verify complete user journeys:
Don't test everything E2E: Edge cases (unit tests are better), error handling variations, visual styling.
Keep E2E tests stable: Use stable selectors (data-testid, ARIA labels), wait for actual conditions not arbitrary timeouts, reset state between tests.
Keep E2E tests fast: Parallelize where possible, use API shortcuts for setup, test only what needs full stack.
Common causes: Race conditions (not waiting for state), shared state between tests, external service instability, animation timing issues.
Solutions: Explicit waits for conditions, isolated test data, retry logic for external services, disable animations in tests.
For detailed patterns on mocks, stubs, spies, and fakes, see references/test-doubles.md.
| Type | Purpose | Behavior |
|---|---|---|
| Dummy | Fill parameter lists | No behavior |
| Stub | Provide canned responses | Returns preset values |
| Spy | Record calls | Tracks invocations |
| Mock | Verify interactions | Fails if not called correctly |
| Fake | Working implementation | Simpler version of real thing |
Don't mock what you don't own: Wrap external libraries, mock the wrapper.
Don't over-mock: Mocking everything tests nothing. Prefer integration tests for interactions. Mock at boundaries, not everywhere.
Builders/Factories: Create test data with defaults
const user = UserBuilder.create()
.withEmail("[email protected]")
.withRole("admin")
.build();
Only specify what's relevant to the test; defaults handle the rest.
Encapsulate page interactions:
class LoginPage {
enterUsername(username) { ... }
enterPassword(password) { ... }
submit() { ... }
getErrorMessage() { ... }
}
One place to update if UI changes, tests read like user actions, reusable across tests.
Use sparingly: Setup that's truly common, state that doesn't affect test behavior.
Avoid: Setup that makes tests interdependent, setup that obscures what's being tested, global state mutations.
Test-Driven Development (TDD): Write failing test → Write minimal code to pass → Refactor.
Test-After: Write code → Write tests.
Hybrid approach: TDD for well-understood problems, test-after for exploration then refactor with tests.
Coverage is a compass, not a target:
Start at the highest level possible, work down as you refactor.
On every commit: All unit tests, fast integration tests, linting and type checking.
On PR/merge: Full integration tests, E2E tests for critical paths, security and vulnerability scans.
Keep tests fast:
Set time budgets: Unit tests < 10 seconds, integration tests < 2 minutes, E2E tests < 10 minutes.
Testing how instead of what. Fix: Test inputs and outputs, not internal steps.
Everything is mocked, nothing is tested. Fix: More integration tests, fewer mocks.
Tests take too long to run. Fix: Optimize slow tests, use test pyramid.
Tests depend on each other's side effects. Fix: Isolate tests, reset state.
Testing getters, setters, and glue code. Fix: Focus on logic and behavior.
Before shipping: