Skip to content

Unit vs Integration vs E2E

Explicit criteria for choosing unit, integration, or E2E, and the failure modes of “only E2E” and “only unit” strategies.

5 min read
Unit Testing
Integration Testing
E2E Testing
Vitest
Playwright
Frontend

When a PR asks "should this be a unit, an integration, or Playwright?" I use a short decision tree.

Wrong layer is worse than no test: it either misses the bug or blocks every harmless refactor.

Criteria, not gut feel

I ask four questions in order:

  1. Is the behavior pure? No React tree, no network, no router, prefer unit.
  2. Does the bug live in wiring? Providers, query keys, form libraries, accessible names, prefer integration with Testing Library.
  3. Would a fake collaborator hide the bug? Auth cookies, real navigation, multi-tab, third-party redirects, prefer E2E.
  4. How often will this change for cosmetic reasons? High churn UI chrome → stay low or assert outcomes, not markup snapshots.

If (1) and (2) both apply, I write the unit and a thin integration only when the pure function has multiple call sites with different wiring risks.

Only-unit failure mode

ts
// Looks green. Ships broken. test('maps API error to message', () => { expect(mapOrderError({ status: 404 })).toBe('Order not found'); });

Useful. Incomplete. The screen might never call mapOrderError, might swallow the error in a catch that returns null, or might render the message inside an element with aria-hidden. Only-unit strategies optimize for function correctness and miss product correctness.

Symptoms I watch for:

  • High line coverage, recurring production bugs in "glue"
  • Refactors that delete dead mappers and nothing fails
  • Designers change copy; zero tests notice because assertions never saw the UI

Only-E2E failure mode

ts
// apps/web/e2e/everything.spec.ts, illustrative anti-pattern test('settings page', async ({ page }) => { await page.goto('/settings'); await page.getByLabel('Display name').fill('Ada'); await page.getByRole('button', { name: 'Save' }).click(); await expect(page.getByText('Saved')).toBeVisible(); // …forty more steps covering every tab });

One browser journey for "save profile" is healthy. A suite that re-boots the app for every checkbox state is how CI becomes overnight.

Only-E2E strategies also teach the wrong feedback loop: developers wait ten minutes to learn a pure date formatter regressed. Flakes get rerun. People stop trusting red builds.

A concrete split for a typical feature

Feature: cancel order on an order detail screen.

ConcernLayerWhy
Idempotent cancel payload shapeUnitPure request builder
Button disabled while mutation pending; error toast on 409IntegrationWiring + a11y name
Logged-in user cancels, list updates after redirectE2ESession + navigation truth
tsx
// Integration slice, illustrative test('disables Cancel while the mutation is pending', async () => { // MSW: POST /orders/:id/cancel hangs until aborted render(<OrderDetail orderId="ord_1" />, { wrapper: AppProviders }); const cancel = screen.getByRole('button', { name: /cancel order/i }); await userEvent.click(cancel); expect(cancel).toBeDisabled(); expect(cancel).toHaveAttribute('aria-busy', 'true'); });

I do not assert the exact toast library class names. I assert the accessible outcome the user needs.

Decision cheat-sheet

SignalPrefer
Pure transform / parser / money mathUnit
Component + QueryClient / router / formIntegration
Auth, cookies, full URL transitionsE2E
Visual polish onlyManual / Chromatic later, not every PR
"We need coverage %"Stop, pick a user failure instead

Failure modes inside a "balanced" suite

Integration tests that mock fetch and the router and the form library. You are back to unit tests with extra ceremony. Prefer MSW (or a real test server) for HTTP; prefer the real router for links.

E2E that asserts implementation details. page.locator('.css-abc') is a landmine. Roles and labels survive redesigns better.

Duplicating the same assertion at every layer. Pick the lowest honest layer; keep one higher-layer smoke if the risk is cross-cutting.

Blast radius and feedback speed

Two more axes when the cheat-sheet is tied:

LayerWhen it fails, blame usually sits in…Feedback loop
UnitOne moduleSub-second in watch mode
IntegrationOne screen + its providersSeconds; still local
E2EApp + env + network + timingMinutes; often CI-gated

If the bug is "Cancel stays enabled while pending," an E2E that boots auth and navigates from the list page has the wrong blast radius. If the bug is "session cookie missing after IdP redirect," integration with a fake document.cookie is the wrong fidelity.

I would rather fix a slow integration file (split setup, share a QueryClient factory, stop rendering the whole shell) than "promote" it to E2E so Vitest looks fast on paper.

Continuity

Vitest config, parallelism, and workspaces are what keep the integration band fast enough to prefer over E2E for most PRs.

Takeaway

Choose the lowest layer that can see the user-visible failure honestly - only-unit misses glue, only-E2E burns trust, and duplicated assertions at every layer are how suites die.