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 fetch call 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.

Each contract maps to a band of the pyramid, and the band’s width is the share of total assertions it should carry. The logic contract sits on the wide base because pure functions are cheap and plentiful; the journey contract sits at the narrow apex because each end-to-end assertion is expensive and must be rationed.

The three layer contracts as bands of the test pyramid A pyramid with a narrow gold apex for the E2E journey contract, a navy middle band for the integration collaboration contract, and a wide dark base for the unit logic contract that carries the bulk of assertions. E2E · journey 5–10% of flows Integration · collaboration real modules, stubbed boundary Unit · logic contract bulk of assertions, deterministic costly, few cheap, many Prove a fact once at its layer; assume it, never re-assert it, above.
Each contract owns one band; the width of the band is the share of assertions it should carry.

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.

The cost of ignoring that rule is not merely duplicated effort; it is a corrupted failure signal. When the same tax calculation is asserted at all three layers, a single arithmetic bug lights up three suites at once, and an engineer triaging the red build cannot tell whether they are looking at one fault or three. Worse, the two higher-layer failures are false in a specific sense: they are reporting a unit-level defect through an expensive, slow, and flake-prone channel that was never meant to carry it. The mapping discipline restores a one-to-one relationship between a fault and the layer that reports it, so a red test names its own cause. That property — that the location of a failure tells you the nature of the failure — is the single largest maintenance dividend the pyramid pays, and it evaporates the moment layers begin re-proving each other’s facts.

There is a subtler corollary about direction of assumption. A higher layer may assume a lower-layer fact, but a lower layer must never assume a higher-layer one. A unit test that quietly depends on a real network response, or an integration test that only passes when a full browser session has warmed some cache, has inverted the dependency and will flake unpredictably. The safe direction of trust always runs downward: expensive layers lean on cheap ones, never the reverse. Enforcing that direction is what the onUnhandledRequest: 'error' guard and the isolated runner configuration in the prerequisites are really for — they make an accidental upward dependency fail loudly at authoring time instead of intermittently in CI.

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.

Decision tree mapping a feature to a test layer A flowchart starting from a feature, asking whether it crosses a real external boundary, then whether multiple modules collaborate, routing to unit, integration, or end-to-end layers. Feature to test Crosses a real external boundary? E2E layer Multiple modules collaborate? Integration Unit layer yes no yes no
The boundary-first routing tree: the most expensive question is asked first so the costliest layer stays rare.

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.

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.

Fault injection proves single-layer ownership Breaking the tax calculation should turn exactly the unit test red while the integration and end-to-end suites stay green because they assume, not re-prove, the logic. Break the tax calc one injected fault Unit test FAILS — owns the fact Integration stays green — assumes E2E stays green — assumes Exactly one layer red means no overlap; extra reds mean illegal re-assertion.
A single injected fault should light up exactly one layer — the one that owns the behaviour.

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. Run these three checks as part of the suite itself rather than as a one-off audit, so a regression in the mapping — a new E2E test that re-proves unit logic, or an integration test that leaks to the network — fails the build the same way a broken assertion would, and the discipline stays enforced without anyone having to remember it.

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.

Validation logic that spans a form and an API. A common ambiguity is client-side validation that mirrors a server rule — a required field, a length limit, a format check. The instinct is to prove it end to end, but the cheaper and sharper split is to unit-test the validation function directly, integration-test that the form surfaces the validator’s error message against an intercepted endpoint, and let E2E confirm only that a valid submission reaches a confirmation. Three narrow, fast assertions replace one slow, brittle browser test, and each one names a distinct concern: the rule, the wiring, and the journey.

Error and empty states. Teams routinely under-test the unhappy paths because they are awkward to reach in a browser. That awkwardness is itself the signal that they do not belong at E2E. An empty list, a failed fetch, or a permission-denied banner is a rendering decision driven by props or an intercepted response, which makes it an integration concern where the failing condition can be injected deterministically. Reserving the browser for the happy path and pushing every error state down a layer is one of the highest-leverage rebalancing moves a suite can make, because error states are numerous and the browser is the worst place to enumerate them.

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.

The economics are worth stating in concrete terms, because they are what make the mapping non-negotiable rather than a stylistic preference. A unit assertion that runs in under fifty milliseconds can execute tens of thousands of times in the wall-clock budget of a single thirty-second end-to-end flow. That ratio means a fact proven at the unit tier is effectively free to re-run on every push, while the same fact proven at the browser tier competes for a scarce, expensive slot. Every assertion pushed up a layer therefore does double damage: it costs more to run and it displaces a higher-value journey check that genuinely needs the browser. Read the pyramid as a budget allocation, and the routing decision becomes an act of spending discipline — you are deciding where each assertion earns its keep.

The failure signature of a drifting suite is measurable, so monitor for it rather than waiting for the pain. Track the ratio of E2E execution time to total suite time and the median pull-request feedback latency over a rolling window; when the browser share climbs or feedback slows without a corresponding rise in caught defects, the pyramid is inverting. Catching that early lets you rebalance by demoting a handful of re-asserting E2E tests to the integration or unit tier — usually the error states and validation checks noted above — before the slow gate becomes the norm the team quietly routes around with skips and reruns.

In-Depth Guides

The following walkthroughs apply this mapping to two of the highest-leverage decisions teams face: