Skip to content

Testing Strategy: The Testing Trophy

Use the testing trophy as a judgment framework, where unit, integration, and E2E pay off, and what over-testing UI chrome actually costs.

4 min read
Testing Trophy
Vitest
Playwright
Frontend Testing
Test Strategy

Types catch a class of mistakes before the browser opens. They do not catch a broken checkout flow, a race in a query cache, or a button that looks clickable and is not.

The hard part is judgment about tests: which layer pays off, which layer is ceremony, and how to keep a suite fast enough that people still run it.

The trophy vs the pyramid

Kent C. Dodds popularized the testing trophy as a correction to the classic pyramid. The shape matters less than the allocation rule:

LayerWhat it provesTypical cost
Static / typesContracts you authorCheap, continuous
UnitPure logic in isolationCheap, fast feedback
IntegrationComponents + real collaborators (router, query, form lib)Medium
E2EFull user path in a real browserExpensive, flaky risk

I spend most of my frontend testing budget in the integration band, render a real screen (or a focused subtree) with the real providers the user would hit, assert on what the user can see and do. Units cover parsers, formatters, and reducers. E2E covers a thin set of critical journeys.

Static typing is the base of my trophy. The rest of this post is about the layers above it.

Where each layer pays off

Unit pays off when the logic is pure and the failure mode is subtle:

ts
// packages/billing/src/format-invoice-total.ts, illustrative export function formatInvoiceTotal(cents: number, currency: 'USD' | 'IDR') { if (!Number.isFinite(cents) || cents < 0) { throw new RangeError('cents must be a non-negative finite number'); } return new Intl.NumberFormat('en', { style: 'currency', currency, }).format(cents / 100); }

No React. No DOM. A unit test here is cheaper than mounting a receipt screen every time someone tweaks rounding.

Integration pays off when wiring is the risk, props into hooks into network into UI:

tsx
// apps/web/src/orders/order-status.test.tsx, illustrative import { render, screen } from '@testing-library/react'; import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; import { OrderStatus } from './order-status'; test('shows retry when the order query fails', async () => { const client = new QueryClient({ defaultOptions: { queries: { retry: false } }, }); // stub the query fn / MSW handler for GET /orders/:id → 500 render( <QueryClientProvider client={client}> <OrderStatus orderId="ord_123" /> </QueryClientProvider>, ); expect(await screen.findByRole('button', { name: /retry/i })).toBeVisible(); });

That test dies if someone removes the error UI, breaks the query key, or wraps the button in a div that kills the accessible name. A unit test of getErrorMessage() alone would not.

E2E pays off on paths where faking the stack lies, auth cookies, real navigation, multi-page flows. One happy-path checkout and one "session expired" path beat twenty pixel-perfect tours of the settings drawer.

The cost of over-testing UI chrome

Chrome tests look productive and rot fast:

  • Asserting exact class strings that Tailwind or CVA regenerate
  • Snapshotting entire page trees after every copy tweak
  • E2E that clicks through every nav item "because coverage"

Those suites become a tax on redesign. The tailwind-merge post already argued that class composition is policy, testing the policy outcome (focusable, labeled, disabled when pending) beats testing the CSS string.

Decision criteria I use before adding a test:

  1. What failure would ship to a user if this assertion did not exist?
  2. Which is the lowest layer that can see that failure honestly?
  3. Will this assertion break on a rename that users never notice?

If I cannot answer (1), I do not write the test. If (2) is E2E but a Testing Library integration would catch it, I stay lower.

Failure modes

Trophy as dogma. Some domains need more E2E (payments). Some libraries need more units (date math). The shape is a prior, not a law.

Mocking the thing under test. If the test passes with a fake router and the bug is in router wiring, the test is misleading.

Integration tests that are secretly E2E. Spinning a full browser for every button state burns CI and patience. Keep Playwright for journeys; keep Vitest for screens.

Ignoring flake debt. A flaky integration suite trains people to rerun CI until green. Quarantine or fix, do not normalize.

Continuity

Branded types and conditional/template literals reduce the unit surface. Composition and polymorphic hosts change what you assert (roles and labels, not implementation tags). Unit vs integration vs E2E criteria spell out when the trophy alone is not enough guidance.

Takeaway

The testing trophy is a budget tool, put integration where wiring fails, units where logic is pure, and E2E where fakes lie, not a mandate to snapshot every button.