Unit vs Integration vs E2E Mapping
Most JavaScript suites fail not because individual assertions are wrong, but because the same behaviour is validated at three layers at once — a tax calculation checked in a unit test, re-checked through a rendered component, then re-checked again through a slow browser flow. The result is duplicated maintenance, ambiguous failure signals, and CI pipelines that scale with redundancy rather than risk. The fix is a disciplined mapping exercise: for every feature, decide which single layer owns its primary contract before a line of test code is written. This guide is the practical companion to the broader Modern JavaScript Test Strategy & Pyramid Design approach, translating high-level pyramid theory into a concrete, repeatable routing decision you can apply per file, per module, and per pull request.
The boundaries here are deliberate. A unit test isolates a pure function or a single module with every collaborator stubbed. An integration test wires several real modules together — a component plus its hooks plus an intercepted network boundary — to verify they cooperate. An end-to-end (E2E) test drives the assembled application through a real browser to confirm a complete user journey. Map each behaviour to exactly one of these as its system of record, and let the other layers reference it only by assumption, never by re-assertion.
Architectural Scope & Boundaries
This guide governs the routing decision itself: given a unit of behaviour, where does its authoritative test live? It does not cover how to configure each runner from scratch — that belongs to dedicated setup material — nor how to write a specific mock. It covers the contract each layer owns and, critically, where one layer must stop.
The three layers map to three distinct contracts:
- Unit layer — logic contract. Inputs map to outputs deterministically. No DOM, no network, no clock. Owns pure functions, reducers, formatters, validators, and selector/derivation logic. This is the cheapest layer and should hold the majority of assertions.
- Integration layer — collaboration contract. Several real modules cooperate behind a stubbed external boundary. Owns component-plus-hook behaviour, state hydration, form submission against an intercepted API, and routing logic that does not require a real server. Network is intercepted with MSW, not faked at the
fetchcall site. - E2E layer — journey contract. The real application, the real browser, the real (or production-like) backend. Owns cross-boundary flows: authentication, checkout, payment, multi-page navigation. Every E2E assertion is expensive, so it must justify itself against the cost-benefit analysis of test layers.
The single hard rule that prevents overlap: a behaviour proven at a lower layer is assumed, not re-proven, at a higher one. If tax math is unit-tested, the checkout E2E test asserts only that a total renders and the order submits — never the arithmetic. This boundary discipline is what keeps the pyramid from collapsing into an hourglass.
Decision Tree: Routing a Feature to a Layer
The following diagram encodes the routing logic as a top-down decision tree. Start at the feature node and follow the first branch whose condition is true.
The tree is intentionally boundary-first. The expensive question — “does this cross a real external boundary?” — is asked first because a true answer forces the most costly layer and should be rare. Everything that survives both questions falls to the unit layer, which is exactly the distribution a healthy pyramid wants.
Prerequisites
Before mapping behaviours to layers, confirm the foundations are in place. A mapping exercise on top of leaky runner configuration produces tests that pass for the wrong reasons.
isolate: trueandclearMocks: trueso unit and integration runs cannot pollute each other.- MSW request layer with
onUnhandledRequest: 'error', so any unrouted network call fails loudly instead of leaking to a live service. *.unit.test.ts,*.integration.test.ts,@e2e) that lets CI select layers independently.retriesscoped to CI only, so flakiness mitigation never masks a deterministic local failure.- Defining Coverage Thresholds — high for unit, moderate for integration, intentionally low for E2E.
Step-by-Step Implementation
The mapping process is mechanical once the layers are defined. Each step below produces a small, runnable artifact you can lift directly into a project.
1. Route pure logic to the unit layer.
Any function whose output is fully determined by its inputs belongs here. No environment, no mocks beyond trivial value stubs.
// src/pricing/calc-total.unit.test.ts
import { describe, it, expect } from 'vitest';
import { calcTotal } from './calc-total';
describe('calcTotal', () => {
it('applies a per-line tax rate', () => {
const total = calcTotal([{ price: 100, taxRate: 0.08 }]);
expect(total).toBe(108);
});
});
2. Route module collaboration to the integration layer.
A component plus its data hook plus an intercepted network boundary is the canonical integration test. The network is real code; only the server is replaced.
// src/features/profile/profile.integration.test.ts
import { describe, it, expect, beforeAll, afterAll, afterEach } from 'vitest';
import { render, screen } from '@testing-library/react';
import { setupServer } from 'msw/node';
import { http, HttpResponse } from 'msw';
import { Profile } from './Profile';
const server = setupServer(
http.get('/api/user/:id', ({ params }) =>
HttpResponse.json({ id: params.id, name: 'Ada Lovelace' }),
),
);
beforeAll(() => server.listen({ onUnhandledRequest: 'error' }));
afterEach(() => server.resetHandlers());
afterAll(() => server.close());
describe('Profile (integration)', () => {
it('renders the fetched user name', async () => {
render(<Profile userId="42" />);
expect(await screen.findByText('Ada Lovelace')).toBeInTheDocument();
});
});
3. Route cross-boundary journeys to the E2E layer.
Reserve the browser for flows that only have meaning end to end. Assert the journey outcome, never the internal math that lower layers already own.
// e2e/checkout.spec.ts
import { test, expect } from '@playwright/test';
test('a signed-in user can complete checkout', async ({ page }) => {
await page.goto('/cart');
await page.getByRole('button', { name: 'Checkout' }).click();
await page.getByLabel('Card number').fill('4242424242424242');
await page.getByRole('button', { name: 'Pay' }).click();
await expect(page.getByRole('heading', { name: 'Order confirmed' })).toBeVisible();
});
4. Split runner configuration by mode so layers never bleed.
A single Vitest config can serve both unit and integration runs by switching environment and include globs on mode, keeping heavy DOM plugins out of pure-logic runs.
// vitest.config.ts
import { defineConfig } from 'vitest/config';
import react from '@vitejs/plugin-react';
export default defineConfig(({ mode }) => {
const isIntegration = mode === 'integration';
return {
plugins: isIntegration ? [react()] : [],
test: {
globals: true,
isolate: true,
clearMocks: true,
environment: isIntegration ? 'jsdom' : 'node',
include: isIntegration
? ['**/*.integration.test.{ts,tsx}']
: ['**/*.unit.test.ts'],
},
};
});
5. Select layers independently in CI.
Run the cheap layers on every push and the browser layer on a tighter trigger. This is where the mapping pays off — changed pure logic never spins up a browser.
# fast feedback on every push
npx vitest run --mode=unit
npx vitest run --mode=integration
# browser layer, only when app code changes
npx playwright test --grep @e2e
Configuration Reference Table
This table is the canonical routing reference. When a behaviour is ambiguous, match it against the “owns” column and assign it to the first row that fits.
| Layer | Runner | Environment | Network | Owns (system of record) | Coverage target | Speed |
|---|---|---|---|---|---|---|
| Unit | Vitest (node) |
none / jsdom | stubbed values only | Pure functions, reducers, validators, formatters | 80–90% | < 50 ms/test |
| Integration | Vitest (jsdom) + RTL |
jsdom | intercepted via MSW | Component + hook + state hydration, forms, client routing | 60–75% | 50–500 ms/test |
| E2E | Playwright | real browser | real / staging backend | Auth, checkout, multi-page journeys | 5–10% of flows | 2–30 s/test |
The coverage targets are deliberately inverted against effort: the layer that is cheapest to run carries the strictest threshold, and the most expensive layer is held to the fewest, highest-value flows. Pair these numbers with your own risk surface rather than copying them blindly.
Verification & Assertions
A mapping is only correct if violations are detectable. Verify the routing with three checks.
First, assert that no two layers prove the same fact. The most reliable signal is a deliberate fault injection: break the tax calculation and confirm that exactly one test fails — the unit test. If the integration or E2E suite also goes red, those layers are illegally re-asserting logic that is not theirs.
Second, assert that the integration layer never touches a live network. The onUnhandledRequest: 'error' setting turns any escape into a hard failure, which is the assertion itself.
beforeAll(() => server.listen({ onUnhandledRequest: 'error' }));
Third, assert that E2E tests fail fast and locally without retries, so a green E2E run means determinism rather than a masked race. Scope retries to CI only and treat any local retry need as a bug, consistent with the practices in How to calculate ROI for E2E tests in React apps.
Edge Cases & Failure Modes
Several behaviours resist clean routing. Handle them explicitly rather than letting them drift upward into expensive layers.
Derived UI state with no network. A component that computes a label purely from props is a unit concern even though it renders. Test the derivation function directly; reserve the rendered test for when a hook or context is genuinely involved.
Time- and timezone-dependent logic. Code that reads Date.now() is deterministic only when the clock is controlled. Route it to the unit layer with fake timers rather than letting wall-clock drift surface as E2E flakiness — see Controlling Date.now and setTimeout in Jest for the technique.
Browser-only APIs in jsdom. IntersectionObserver, ResizeObserver, and WebSocket do not exist in jsdom. Either polyfill them for an integration test or accept that the behaviour is genuinely a browser concern and route it to E2E. Do not fake them so heavily that the test proves nothing.
The over-mocking trap. When an integration test stubs so many collaborators that only one real module remains, it has silently become a unit test wearing an integration label. Collapse it back to the unit layer and delete the ceremony.
Hydration and server-component boundaries. SSR hydration mismatches are a collaboration contract — markup produced on the server must reconcile with client render. These belong at the integration layer where both halves are exercised, not E2E, where the failure signal is buried under network noise.
Performance & CI Impact
Correct mapping is primarily a performance strategy. Because the unit layer carries the bulk of assertions and runs in milliseconds without a DOM, the median pull request gets feedback in seconds. The integration layer adds jsdom and MSW overhead but stays well under a second per test, so it can still run on every push. The E2E layer — minutes per flow — runs only against application code changes and shards across runners to keep wall-clock time flat as the suite grows.
The compounding win is selective execution. With layers tagged and split, a documentation-only change runs nothing in the browser, and a pure-logic refactor never bootstraps jsdom. This is the same impact-based selection principle described in the Vitest configuration setup material, applied at the routing level rather than the file level. The anti-pattern to avoid is the inverted pyramid, where heavy E2E coverage forces every change through a slow browser gate; the mapping discipline in this guide is precisely what prevents that drift.
In-Depth Guides
The following walkthroughs apply this mapping to two of the highest-leverage decisions teams face:
- How to calculate ROI for E2E tests in React apps — quantify whether a browser-level flow earns its place, using a deterministic formula over rolling windows.
- When to skip integration tests in favor of unit tests — a boolean decision matrix for safely pushing validation down to the cheapest layer without losing coverage.
Related
- Up to Modern JavaScript Test Strategy & Pyramid Design — the parent strategy this mapping serves.
- Cost-Benefit Analysis of Test Layers — quantify the spend behind each layer you assign.
- Defining Coverage Thresholds — set the per-layer targets referenced above.
- Advanced Mocking & Service Isolation Patterns — the interception techniques that keep integration tests off the live network.
- Component & Integration Testing — runner and rendering setup for the integration layer.
How to calculate ROI for E2E tests in React apps
A deterministic formula and runnable telemetry for measuring end-to-end test ROI in React apps, weighing defect containment against flakiness and CI cost.
When to skip integration tests in favor of unit tests
A boolean decision matrix and runnable guardrails for safely replacing integration tests with unit tests without losing coverage or pipeline confidence.