Testing Strategy
What is the cheapest test that proves your change works?
L0 TYPES ← L1 UNIT ← L2 INTEGRATION ← L3 E2E
│ │ │ │
▼ ▼ ▼ ▼
<1s <1s 5-30s 30-120s
Compiler Pure logic Real database Real browser
Free Cheap Moderate Expensive
Most codebases are inverted — 80% E2E, 5% unit. Every browser test that proves something a function call can prove is waste. The browser is a last resort, not a starting point.
The Layer Model
Four layers. Each proves something the layer below cannot. Stop at the first layer that covers the change.
| Layer | What It Proves | Tool | Cost |
|---|---|---|---|
| L0 Types | Compiler accepts it — contracts match, imports resolve, refactors propagate | tsc --noEmit | <1s |
| L1 Unit | Pure transform works — input A produces output B, no I/O | Vitest / Jest | <1s per test |
| L2 Integration | Data layer works end-to-end — server action + repository produce correct data | Vitest / Jest + real DB | 5-30s per test |
| L3 E2E | Human can complete the journey — multi-step browser interaction works | Playwright | 30-120s per test |
An optional Intent layer sits between Integration and E2E for API contract validation (A2A protocol, webhook shapes). Most teams don't need it until they have agent-to-agent communication.
Selection Rule
Stop at the first match, top to bottom:
| Code Changed | Layer |
|---|---|
| Pure function, Zod schema, DTO mapping, domain logic | L1 Unit |
| Server action, repository, adapter, composition root | L2 Integration |
| API contract, agent protocol, webhook payload | Intent |
| UI journey requiring browser interaction, layout, a11y | L3 E2E |
L0 runs on every change regardless. TypeScript is free verification — a broken refactor that renames a field lights up every consumer instantly. This is why type-first development matters: the compiler is your most cost-effective test suite.
The Browser Gate
Before writing any E2E test, ask: can this test's core claim be proven without a browser?
If the answer is yes, write the cheaper test. A server action that creates a user and returns a result is an L2 integration test. You only need L3 when the browser itself is part of the proof — form interaction, navigation flow, responsive layout, accessibility.
The Hexagonal Advantage
When server actions are pure TypeScript functions behind ports, most logic is testable without React or a browser:
┌──────────────────────────────────┐
│ PRESENTATION (L3) │ ← E2E only for browser-dependent flows
│ ┌──────────────────────────┐ │
│ │ APPLICATION (L2) │ │ ← Integration: server actions, use cases
│ │ ┌──────────────────┐ │ │
│ │ │ INFRASTRUCTURE │ │ │ ← Integration: repositories, adapters
│ │ │ ┌──────────┐ │ │ │
│ │ │ │ DOMAIN │ │ │ │ ← Unit: pure transforms, validators
│ │ │ └──────────┘ │ │ │
│ │ └──────────────────┘ │ │
│ └──────────────────────────┘ │
└──────────────────────────────────┘
| Layer | Test Strategy | Why |
|---|---|---|
| Domain | L1 Unit — no mocks needed | Pure functions, Zod schemas, DTOs. Zero dependencies. |
| Infrastructure | L2 Integration — real database | Repositories implement domain ports. Prove data round-trips. |
| Application | L2 Integration — composes through ports | Server actions orchestrate repositories. Prove the composition. |
| Presentation | L3 E2E — only for browser interactions | Components consume application layer. Most rendering is server-side. |
This maps directly to the layer model. Domain types are the source of truth. Tests prove each layer honors the contracts.
Patterns
L0: Types as Tests
The cheapest verification. Strict TypeScript catches broken refactors, wrong argument types, missing fields, and import errors before any test runs.
tsconfig.json strict mode flags:
strict: true
noUncheckedIndexedAccess: true
noUnusedLocals: true
exactOptionalPropertyTypes: true
When a domain contract changes, the compiler lights up every file that needs updating — outward through infrastructure, application, presentation. Red squiggles are breadcrumbs.
L1: Unit Tests
Pure functions. No database, no DOM, no HTTP. Under 100ms per test.
Arrange → Set up test data
Act → Execute the function
Assert → Verify the output
Good candidates: score calculations, DTO mappings, Zod schema validation, status logic, discount rules, formatting functions. Anything that takes data in and returns data out.
L2: Integration Tests
The bulk of a server-action architecture. Real database, real queries, real data. Prove that the composition of domain + infrastructure + application produces correct results.
Rules:
- Real database, not mocks — mocking the database hides the bugs that matter
- Clean up test data after each test — no shared state
- Test isolation — each test is independent
Server actions marked "use server" are async functions. In a test runner, the directive is a bundler instruction, not a runtime constraint. Mock next/headers, next/navigation, next/cache — test the function directly.
L3: E2E Tests
Last resort. Browser-dependent flows only. Expensive but irreplaceable for:
- Multi-step form interactions (fill, submit, redirect, error states)
- Authentication flows (OAuth redirects, session management)
- Responsive layout behavior across breakpoints
- Keyboard navigation and accessibility
- Client-side state that only exists in the browser
Selectors: Use data-testid attributes, not CSS selectors or XPath. Semantic selectors survive redesigns.
Timeouts: Use waitForSelector(), never arbitrary sleep(). Flaky tests are worse than no tests.
Contract Testing with Zod
Zod schemas serve as runtime contract verification at every data boundary. The schema IS the test:
L0: tsc catches type mismatches at compile time
L1: Zod catches shape/constraint violations at runtime
L2: Integration tests verify business logic with validated data
L3: E2E verifies the full flow
When schemas define both the TypeScript type (z.infer<typeof Schema>) and the runtime validator, schema drift becomes impossible. The type IS the validator. This is the type boundary made enforceable.
Rebalancing
Don't delete E2E specs. Write cheaper tests for the same logic, then remove the redundant E2E coverage.
The audit question for each existing E2E test: Can the core claim be proven without a browser?
Likely outcome for a server-action-heavy app:
- ~5% remain E2E — auth flows, form journeys, layout
- ~80% become integration — server actions are the bulk of logic
- ~10% become unit — pure transforms extracted from actions
- ~5% become intent — API contract validation
The infrastructure change (testing against preview deploys instead of localhost) unblocks the rebalancing. Do that first.
Context
- Type-First Development — Types drive test specs, compiler as methodology
- Flow Engineering — Maps produce domain contracts that become test expectations
- Architecture — Hexagonal patterns that make testing cheap
- Testing Infrastructure — CI/CD pipeline, preview deploys, cost management
- Testing Tools — Vitest, Jest, Playwright, React Testing Library
- Platform Engineering — Infrastructure beneath the product