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:
- Is the behavior pure? No React tree, no network, no router, prefer unit.
- Does the bug live in wiring? Providers, query keys, form libraries, accessible names, prefer integration with Testing Library.
- Would a fake collaborator hide the bug? Auth cookies, real navigation, multi-tab, third-party redirects, prefer E2E.
- 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.
| Concern | Layer | Why |
|---|---|---|
| Idempotent cancel payload shape | Unit | Pure request builder |
| Button disabled while mutation pending; error toast on 409 | Integration | Wiring + a11y name |
| Logged-in user cancels, list updates after redirect | E2E | Session + 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
| Signal | Prefer |
|---|---|
| Pure transform / parser / money math | Unit |
| Component + QueryClient / router / form | Integration |
| Auth, cookies, full URL transitions | E2E |
| Visual polish only | Manual / 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:
| Layer | When it fails, blame usually sits in… | Feedback loop |
|---|---|---|
| Unit | One module | Sub-second in watch mode |
| Integration | One screen + its providers | Seconds; still local |
| E2E | App + env + network + timing | Minutes; 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.