cf.completefrontend
My progress
FUNDAMENTALS · MODULE 16

A frontend testing strategy

How to split a frontend's tests between static checks, unit, integration, and end-to-end tests, so they catch the bugs that matter without making every change slow.

What tests are for

Picture eight engineers working on an online shop. On a Friday afternoon one of them renames a prop on the shared PriceTag component from amount to value. The change is small, the unit tests for the price formatter pass, and the pull request is merged. On Saturday morning the checkout page shows NaN where the total should be, and the shop loses a weekend of orders.

The team had 1,400 tests and 82 percent coverage. But none of them rendered the checkout page with the real PriceTag inside it, so none could see the bug. The tests checked the parts one at a time; the bug lived in the join between two parts.

A testing strategy is the plan for which tests you write, at which level, and how many, so that the bugs your users would hit are caught before they ship, at a price your team can afford.

Confidence per minute

A test is not free. You pay when you write it, every time CI runs it, every time it breaks because the code changed shape but not behavior, and every time it fails for no reason. In return, it gives you confidence: the belief that if the tests pass, the app works for real users.

So the useful question is not "how many tests do we have?" but "how much confidence does each test buy for each minute we spend on it over its life?" Call it confidence per minute. A cheap, stable test of something users care about scores high. A slow, flaky test of an internal detail scores near zero or below, because it slows every change and teaches people to ignore red builds.

A bar chart, to scale, of what four checks on a checkout page cost in minutes a year to run and maintain, with illustrative numbers. A type check on the PriceTag props: about 2 minutes, and it guards the total. A checkout integration test: about 30 minutes, guarding the total, error states, and a double payment. A checkout end-to-end journey: about 3 hours, guarding cookies, deploy config, and the real API. A test that checks setTotal was called: about 2 hours, mostly fixing it after refactors, and it guards nothing a user sees.cost per year: running, fixing, and false alarmstype check on PriceTag props: guards the totalabout 2 min a yearcheckout integration test: total, errors, double payabout 30 mincheckout E2E journey: cookies, deploy, real APIabout 3 htest that setTotal was called: guards nothing users seeabout 2 h
Illustrative numbers, to show the idea. The cheap checks buy a lot; the E2E journey is expensive but guards things nothing else can; the test of an internal call costs almost as much and buys nothing.

The three jobs of a test suite

A test suite does three different jobs, and a good strategy keeps all three in view. Teams that think only about the first end up with slow, fragile suites.

  1. Catch regressions. A regression is something that used to work and now doesn't, like the checkout total that turned into NaN.
  2. Let you change code without fear. Good tests check behavior, not internals, so you can rewrite a component or upgrade React and learn whether users would notice. Tests that break on every refactor make people stop refactoring.
  3. Give fast feedback. A test that fails in 2 seconds on your laptop is worth far more than the same check failing 40 minutes later in CI.

These jobs pull against each other. The most realistic tests (a real browser, a real backend) are best at the first job and worst at the third. The fastest tests (a pure function in isolation) are best at the third job and miss most frontend bugs. A strategy is how you balance them.

Why frontend testing is its own problem

On a backend, much of the risk sits in logic: pricing rules, permissions, queries. On a frontend, most bugs are in the wiring: a prop passed wrong, a loading state that never ends, an error message that never shows, a button that works with a mouse but not with a keyboard, a layout that breaks only in Safari.

So backend testing advice does not carry over unchanged. This lesson builds a strategy for frontend code: the layers, the shapes people argue about, what each layer can see, what it costs, and what to test for each kind of component. Later lessons go deeper into each layer: [unit and integration tests](/system-design/concepts/unit-and-integration-tests), [end-to-end tests](/system-design/concepts/e2e-tests), and [contract and visual tests](/system-design/concepts/contract-and-visual-tests).

Check your understanding · Exercise 1

The shop had 1,400 tests and 82 percent coverage, yet the renamed prop broke checkout. What was the real gap? Select all that apply.

The mental model: how much of the real app runs

Every test sets something up, does something, and checks the result. What makes one kind of test different from another is how much of the real app runs during the test, and how much is replaced by a fake.

Picture a dial. At one end, the test runs a single function and fakes everything around it. At the other end, it runs everything for real: a real browser, the real build, the real servers. Turning the dial towards "real" catches more kinds of bugs, but every step costs speed and stability.

A stack of four test layers, from bottom to top. Static analysis reads the code and runs nothing, as you type. Unit tests run one function with nothing around it, in 1 to 10 milliseconds. Integration tests run real components with a faked network, in 20 to 300 milliseconds. End-to-end tests run a real browser, the real build, and real servers, in 5 to 60 seconds. An arrow up the side says: more of the real app runs, and each test is slower.End-to-endreal browser, real build, real servers5 to 60 sIntegrationreal components, faked network20 to 300 msUnitone function, nothing around it1 to 10 msStatic analysisreads the code, runs nothingas you typemore realfaster
Each step up runs more of the real app, so it can see more kinds of bugs, and each step often costs 10 to 100 times more time per test.

The layers, from cheapest to most real

  • Static analysis. TypeScript and a linter such as ESLint read the code without running it, as you type. They catch typos, wrong prop types, a possibly undefined value used as if it were set, and a useEffect with a missing dependency.
  • Unit tests. One function, hook, or reducer on its own, such as a price formatter or a cart reducer. They run in Node in a test runner like Vitest or Jest, in about 1 to 10 ms each.
  • Integration tests. A component rendered with its real children, hooks, context, and state, in a simulated DOM (jsdom or happy-dom) or a real browser, with only the network faked. Frontend teams often call these component tests. They take roughly 20 to 300 ms each.
  • End-to-end (E2E) tests. A real browser, driven by Playwright or Cypress, uses the real built app against a real or seeded backend: it clicks, types, and waits like a user. Each takes roughly 5 to 60 seconds.
  • Beside the stack. Visual regression tests compare screenshots to catch changes in how things look. Contract tests check that the frontend and the API still agree on the shape of the data. Both are covered in [contract and visual tests](/system-design/concepts/contract-and-visual-tests).

These times are rough ranges for a typical React app on a CI machine; yours will differ. What holds everywhere is the ratio: each step up the dial is often 10 to 100 times slower than the one below it, sometimes more.

What is real and what is faked at each layer

The clearest way to tell the layers apart is to list what each one replaces with a fake. A fake is anything the test controls instead of the real thing: a mocked module, a fake fetch, a simulated DOM, a seeded database.

Part of the appUnitIntegrationEnd-to-end
Your function or hookRealRealReal
Child components and contextNot renderedRealReal
The DOM and eventsNoneSimulated (jsdom) or a real browserReal browser
CSS and layoutNoneMostly ignored in jsdomReal
The network and APINot usedFaked, for example with MSWReal or a seeded test server
Routing, cookies, auth redirectsNot usedUsually fakedReal
Build output and deploy configNot usedNot usedReal

The table predicts what each layer misses. A unit test cannot see a bug in how two components connect, because only one is running. An integration test in jsdom cannot see a button hidden behind a sticky header, because jsdom does not compute layout. Only an E2E test can see a broken redirect after login, because only it runs the real auth flow.

The names are fuzzy; the dial is not

Teams use these words differently: one team's "unit test" renders a whole form, and another team calls that an integration test. Don't argue about the label. Ask instead what the test runs for real and what it fakes, because that tells you what it can catch.

Check your understanding · Exercise 2

A SearchBox test renders the component in jsdom with its real children and a fake API from MSW. What can it NOT catch? Select all that apply.

The pyramid, the trophy, and the honeycomb

The next question is how many tests to put in each layer. Three well-known shapes answer it, each drawn for a different kind of software at a different time, which tells you when each still fits.

Three test suite shapes side by side; the width of each layer is how many tests it gets. The test pyramid, from Mike Cohn in 2009: a wide base of unit tests, a middle of integration tests, a narrow top of end-to-end tests. The testing trophy, from Kent C. Dodds in 2018: a wide base of static analysis, a thin stem of unit tests, a big bowl of integration tests, and a small cap of end-to-end tests. The testing honeycomb, from Spotify in 2018: a small top of integrated tests across services, a big middle of integration tests, and a small base of implementation detail tests.E2EintegrationunitE2EintegrationunitstaticintegratedintegrationdetailspyramidCohn, 2009trophyDodds, 2018honeycombSpotify, 2018
All three keep end-to-end tests few. They disagree only about where the bulk goes: the pyramid bets on small unit tests, the trophy and the honeycomb on tests of parts working together.

The test pyramid

The test pyramid comes from Mike Cohn's 2009 book Succeeding with Agile and was made widely known by Martin Fowler's writing. It says: write many small unit tests at the bottom, fewer service or integration tests in the middle, and very few tests through the UI at the top.

The reason was cost. In the late 2000s, UI tests ran through tools like Selenium against slow browsers, broke whenever a page changed, and could take minutes each. Unit tests ran in milliseconds and rarely failed by accident. With those numbers, a pyramid is the cheapest way to get coverage.

Its best-known opposite is the ice cream cone: a few unit tests under a large pile of slow manual or E2E tests. Suites shaped like that take hours, fail randomly, and lose the team's trust.

The testing trophy

In 2018 Kent C. Dodds proposed the testing trophy for JavaScript apps, growing out of a line by Guillermo Rauch: "Write tests. Not too many. Mostly integration." From the bottom up, it has a wide base of static analysis, a thin layer of unit tests, the biggest layer of integration tests, and a small cap of E2E tests.

The argument has two parts. First, the more your tests resemble the way your software is used, the more confidence they give: users don't call your reducer, they click a button and read the screen. Second, the costs changed. With jsdom and Testing Library, rendering a whole form with its real children takes tens of milliseconds, so the old reason to avoid bigger tests mostly went away for components.

The testing honeycomb

The honeycomb came from Spotify's engineering blog in 2018, for backend microservices. A small service has little logic of its own; most of its risk is in how it talks to databases and other services. So the honeycomb puts most tests in the middle (integration tests of one service with its real dependencies), with a few "implementation detail" tests below and a few "integrated" tests across many services above.

For frontend engineers it is the trophy's idea reached from the other side of the API. It is also a good shape for any backend for frontend (a BFF, a small server that shapes data for one UI) your team owns.

Decision Which shape should a frontend test suite follow?

Pyramid

Mostly unit tests of functions, hooks, and reducers

Some integration tests

A handful of E2E tests

Pros

  • Very fast and very stable
  • Pinpoints the exact broken function
  • Great for logic-heavy code

Cons

  • Misses wiring bugs, which are most frontend bugs
  • Component unit tests tend to check internals and break on refactors

Trophy

Strict TypeScript and lint rules as the base

A few unit tests for pure logic

Most tests render real components with a faked network

A small set of E2E tests for critical journeys

Pros

  • Tests look like real use, so they survive refactors
  • Catches wiring, state, and accessibility bugs
  • Still fast enough to run on every save

Cons

  • A failing test points at a screen, not a line
  • jsdom still misses layout and real browser quirks
  • Easy to over-mock and fool yourself

Honeycomb

Most tests run one service with its real database and dependencies

Few small tests, few cross-service tests

Pros

  • Fits thin services whose risk is in their connections
  • Good for a BFF or API layer

Cons

  • Not designed for UI code
  • Needs real or containerized dependencies in CI

What we pick: For UI code, start from the trophy: a strict static base, most effort on integration tests of components and screens, and a short list of E2E tests. Use pyramid thinking inside it for pure logic, like a pricing engine, where many small unit tests are the right tool. Use the honeycomb for a backend for frontend.

Where the shapes agree

The shapes are more alike than the arguments suggest. All three keep E2E tests few and aimed at the journeys that matter most, and all three put the bulk of the effort where most bugs are. They only disagree about where the bugs are: logic-heavy code has them inside functions, UI code has them between components.

Check your understanding · Exercise 3

Which parts of a frontend codebase suit pyramid-style testing (many small unit tests)? Select all that apply.

What each layer catches, and what it misses

Choose between layers by starting from bugs, not tools. For each bug that could reach users, ask which is the cheapest layer that can even see it. This chapter walks through a dozen common kinds of frontend bug and where each one is caught.

Bugs the static layer catches

  • A renamed prop. The checkout passes amount to a PriceTag that now expects value. With typed props, tsc fails the build in seconds. Without types, only a test that renders the checkout with the real PriceTag notices.
  • A missing value used as if present. The API sometimes returns no tags field, and the code calls product.tags.map(...). With strictNullChecks on and the API type saying tags?: string[], TypeScript refuses to compile it.
  • A stale closure in an effect. A useEffect reads userId but does not list it as a dependency, so it keeps fetching the first user. The react-hooks/exhaustive-deps lint rule flags it as you type.

Bugs unit tests catch

  • Rounding in money. The cart adds prices as floating point numbers (in JavaScript, 0.1 + 0.2 is 0.30000000000000004) and rounds each line before applying a discount, so the total is a cent away from the server's receipt. A unit test with a table of 20 price cases catches it in a few milliseconds.
  • An edge in a parser. A date range helper treats "last 7 days" as 8 days around a daylight saving change. A unit test with a fixed clock and a few tricky dates finds it.

Unit tests are excellent at logic with many cases and nearly blind to everything else. They never render the components that use the function, so they can't tell you whether it is called with the right data.

Bugs integration tests catch

  • An error state that never shows. The orders API returns 500, and the page shows a spinner forever because the error branch sets the wrong state. An integration test makes the fake API fail and checks that the user sees "Couldn't load your orders" and a Retry button.
  • A keyboard trap. A dialog opens, but pressing Escape does nothing and focus escapes to the page behind it. A test with user-event presses Escape and checks that the dialog is gone and focus is back on the button that opened it.
A sequence diagram of one integration test between the test, the real OrdersPage component, and MSW acting as a fake API. The test renders the page. The page's real fetch code sends GET /api/orders. MSW answers 500. The test looks for the error text, and the page shows it. The test clicks Retry. The page sends GET /api/orders again, MSW answers 200 with orders, and the test finds the row for order 1042.TestOrdersPageMSWrender the pageGET /api/orders500find error textfoundclick RetryGET /api/orders200 + orders
Everything between the test and the network is real: the component, its hooks, its fetch and error code. Only the server's answers are scripted, so the test can make the API fail on purpose.

Here is such a test. It finds things the way a user or a screen reader would, by role and visible text, and never reaches into state or checks which hook was called.

tsx
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { http, HttpResponse } from "msw";
import { server } from "../test/server";
import { OrdersPage } from "./OrdersPage";

test("shows an error and retries when orders fail to load", async () => {
  server.use(http.get("/api/orders", () => new HttpResponse(null, { status: 500 })));
  render(<OrdersPage />);

  expect(await screen.findByText("Couldn't load your orders")).toBeInTheDocument();

  server.resetHandlers();
  await userEvent.click(screen.getByRole("button", { name: "Retry" }));
  expect(await screen.findByRole("row", { name: /Order 1042/ })).toBeInTheDocument();
});
Mock Service Worker (MSW) answers the request at the network level, so the component's real fetch code, hooks, and error handling all run.

Bugs only a real browser and real servers catch

  • A sign-in redirect loop. The session cookie is set with SameSite=Strict, so it is not sent on the redirect back from the identity provider, and the app sends the user to sign in again, forever. Nothing below E2E runs real cookies across real origins.
  • A config that exists only in production. The production build reads API_URL from an environment variable the deploy pipeline forgot to set. Every test against the dev server passes; a smoke E2E test against the deployed app catches it.
  • A browser-only bug. Checkout works in Chrome, but in Safari a CSS difference pushes the Pay button off the bottom of the screen. Only a run in a real WebKit engine sees it, and a screenshot makes it obvious.

Bugs the side layers catch

Some bugs slip past all four classic layers at a reasonable cost. When the backend team renames items to results, your integration tests keep passing, because the fake API still returns items. A contract test (for example with Pact), or validating responses with a schema at the boundary as shown in [API contracts and errors](/system-design/concepts/api-contracts-and-errors), catches it. When a CSS change shifts every card by 4 pixels, no assertion fails, but a visual regression test that compares screenshots does.

A matrix of ten bugs against five kinds of test: static, unit, integration, end-to-end, and the side layers (contract and visual). renamed prop: caught by static, integ., E2E; cheapest is static. missing effect dependency: caught by static, integ., E2E; cheapest is static. money rounding: caught by unit, integ.; cheapest is unit. error state never shown: caught by integ.; cheapest is integ.. dialog keyboard trap: caught by integ., E2E; cheapest is integ.. sign-in redirect loop: caught by E2E; cheapest is E2E. env var missing in prod: caught by E2E; cheapest is E2E. Pay off screen in Safari: caught by E2E, side; cheapest is side. API renamed a field: caught by E2E, side; cheapest is side. cards shifted 4 px: caught by side; cheapest is side.staticunitinteg.E2Esiderenamed propmissing effect dependencymoney roundingerror state never showndialog keyboard trapsign-in redirect loopenv var missing in prodPay off screen in SafariAPI renamed a fieldcards shifted 4 px✓✓✓✓✓✓✓✓✓✓✓✓✓✓✓✓✓✓
Outlined cells are the cheapest layer that catches each bug. The bottom rows are the expensive ones: only a real browser, a real API, or a screenshot sees them.

Check your understanding · Exercise 4

Pick the cheapest layer that reliably catches each bug. Which pairs are right? Select all that apply.

Cost and speed, with numbers

Every layer costs something different, and the costs add up in ways that are easy to miss. A test you write once may run tens of thousands of times over its life, on every save, pull request, and merge, so small numbers per run turn into hours per week.

The five costs of a test

  1. Writing it. A unit test of a pure function takes minutes. An integration test needs a render helper, providers, and fake API handlers, shared after the first few. An E2E test needs seeded data, sign-in, and a stable environment.
  2. Running it. Milliseconds for unit tests, tens to hundreds of milliseconds for integration tests, seconds to a minute for E2E tests, multiplied by every run.
  3. Keeping it working. A test that checks internals breaks on refactors users would never notice. These false alarms are the biggest hidden cost of many suites.
  4. Finding the bug when it fails. A failing unit test points at one function. A failing E2E test says "the checkout journey broke somewhere", and someone has to open a trace to find where.
  5. Flakiness. A test that sometimes fails with no code change costs re-runs, focus, and, worst of all, trust. A later chapter covers it.

A worked example: one pull request

Take a mid-sized app with this suite. The per-test times are rough averages; measure your own before planning around them.

LayerCountTime eachTotal, one workerWith parallel workers
Static (tsc + ESLint)whole repoone passabout 40 sabout 15 s with incremental caches
Unit1,200about 5 msabout 6 sa few seconds
Integration900about 150 msabout 2.3 minabout 30 s on 8 workers
End-to-end40about 25 sabout 17 minabout 4 to 5 min on 4 machines

Notice the shape: 40 E2E tests cost more time than 2,100 unit and integration tests together. Splitting them across machines (called sharding) helps, but each shard first installs dependencies, builds or downloads the app, and starts browsers, which can take a minute or more.

A timeline on a log scale from a tenth of a second to a day, showing how long after a mistake you hear about it. A type error in the editor: under a second. An integration test in watch mode: about 2 seconds. Pull request checks for static, unit, and integration tests: about a minute. Critical E2E journeys on the pull request: about 5 minutes. The full E2E and visual suites after merge: about 30 minutes. Nightly long journeys: the next morning.type error in the editor: under a secondintegration test in watch mode: about 2 sPR: static, unit, integration: about 1 minPR: critical E2E journeys: about 5 minafter merge: full E2E and visual: about 30 minnightly: long journeys: next morning1 s1 min1 h1 day
Rough times for a mid-sized app. Each step to the right costs a context switch on top of the fix, which is why most checks should sit on the left.

Why speed matters more than it looks

Speed decides when you learn about a bug, not just the CI bill. A type error shows up as you type, and a failing integration test in watch mode 2 seconds after you save, while the code is still in your head. A failing E2E test in CI shows up 5 to 15 minutes later, after you've opened another task, and costs a context switch on top of the fix.

Many teams set a budget, such as pull request checks finishing in under about 10 minutes. Staying inside it usually means running static checks, unit tests, and integration tests on every push, a small set of critical E2E tests on every pull request, and the wider E2E and visual suites on merge to main or nightly.

Try it: build a test mix

The lab below takes ten bugs from the previous chapter. First guess the cheapest layer for each. Then switch layers on and off and watch two numbers change: the bugs your mix catches, and the minutes it adds to every pull request.

1. Guess. For each bug, pick the cheapest layer that reliably catches it.

2. Build a mix. Switch layers on and off and watch bugs caught against minutes added to every pull request.

Run:
Ten bugs against the chosen mix: Unit. 1 of 10 caught, about 0.1 minutes of CI per pull request with parallel workers. Renamed prop: amount to value: missed. Effect misses a dependency: missed. Total is a cent off: caught by Unit. Spinner forever on a 500: missed. Escape doesn't close the dialog: missed. Sign-in redirect loop: missed. API_URL missing in prod build: missed. Pay button off screen in Safari: missed. API renamed items to results: missed. Every card shifted 4 px: missed.✗Renamed prop: amount to valuemissed✗Effect misses a dependencymissed✓Total is a cent offunit✗Spinner forever on a 500missed✗Escape doesn't close the dialogmissed✗Sign-in redirect loopmissed✗API_URL missing in prod buildmissed✗Pay button off screen in Safarimissed✗API renamed items to resultsmissed✗Every card shifted 4 pxmissedcaught 1 of 10; CI time per pull requestabout 0.1 minflaky failures: rare
Static and integration tests catch half the list for well under a minute. The rest can only be bought with E2E, contract, or visual time, and each of those bugs costs far more per catch.

Two things stand out. Static checks plus integration tests catch half the list for under a minute of CI. The bugs about cookies, deploy config, real browsers, and the real API can only be bought with E2E, contract, or visual time, and each costs far more. That is the trophy's argument in numbers.

Check your understanding · Exercise 5

Your pull request checks take 28 minutes, mostly 140 E2E tests. Which changes cut the time without losing much confidence? Select all that apply.

Static analysis: the base of the trophy

Static analysis is the cheapest layer you have. It runs as you type, never flakes, and checks every line, including branches no test reaches. Many teams treat it as a style tool, but it belongs in the test strategy: whole classes of bugs can be made impossible before any test runs.

TypeScript: turn on the settings that catch bugs

TypeScript only checks what you let it check, so a project with loose settings and any everywhere gets little from it. These settings do most of the work:

  • strict: true. Turns on a family of checks, the most important being strictNullChecks: a value that might be undefined must be checked before use. This alone removes the "cannot read properties of undefined" family of crashes from typed code.
  • noUncheckedIndexedAccess. Makes items[0] have the type Item | undefined, because the array might be empty. It is noisy at first and catches real crashes in lists and lookups.
  • No any at the edges. Ban explicit any with a lint rule, and type third-party data as unknown until it is checked. One any silently switches off checking for everything it touches.

Make wrong states impossible to write

The strongest use of types is not catching typos; it is shaping data so that bugs cannot be written at all. A request state written as two booleans and an optional value, isLoading, isError, and data, allows eight combinations, several of them nonsense (loading and errored at once). A discriminated union, a type with a status field that names each case, allows exactly the four real ones.

ts
type Orders =
  | { status: "idle" }
  | { status: "loading" }
  | { status: "error"; message: string }
  | { status: "ready"; orders: Order[] };

function render(state: Orders) {
  switch (state.status) {
    case "error":
      return showError(state.message);
    case "ready":
      return showTable(state.orders); // orders exists only here
    default:
      return showSpinner();
  }
}
TypeScript lets you read orders only after checking status, so the "table with no data" bug cannot be written.

Lint rules that act like tests

  • react-hooks/rules-of-hooks and react-hooks/exhaustive-deps. Catch hooks called conditionally and effects that read values they do not list, the source of many stale-data bugs.
  • @typescript-eslint/no-floating-promises. Flags a promise that is neither awaited nor handled, so a failed save is not silently lost. It needs type-aware linting, which is slower but worth it in CI.
  • jsx-a11y rules. Catch an image with no alt, a click handler on a div with no keyboard support, and a form field with no label. They find part of the accessibility problems, not all; see [testing accessibility](/system-design/concepts/accessibility-testing).

Where types lie: the network boundary

Types describe what your code believes, not what the server sends. When code writes const order = (await res.json()) as Order, TypeScript takes your word for it. If the API changes, the types still look fine and the bug appears three components later.

Two panels. Before: the API sends price as the string "12.50", the code casts res.json() as Order so TypeScript believes it, and the CartTotal component computes "12.50" + 3 as "12.503": the bug appears three components later. After: the same response goes through OrderSchema.parse, which fails right in the fetch layer with "price: expected number": a clear error at the boundary, where the data enters.Before: trust the castAPIsends price: "12.50"res.json() as OrderCartTotal"12.50" + 3 = "12.503"bug appears 3 components laterAfter: parse at the boundaryAPIsends price: "12.50"OrderSchema.parse(json)fetch layerprice: expected numberclear error at the boundary
Types describe what your code believes. A cast like as Order skips the check; a schema checks the real data once, at the door, and gives you the type for free.

The fix is to check data where it enters: parse the response with a schema library such as Zod or Valibot, and derive the TypeScript type from the schema. A wrong shape then fails loudly at the boundary instead of quietly deep inside the UI. This is the runtime half of a contract; [API contracts and errors](/system-design/concepts/api-contracts-and-errors) covers it in depth.

Check your understanding · Exercise 6

Which of these can static analysis alone catch, before any test runs? Select all that apply.

What to test for each kind of component

Different components carry different risks, so they need different mixes. Four common kinds cover most of a product: a design-system primitive, a data-heavy widget, a business-critical flow, and a visual component.

A flowchart of four questions for each risk. Can types or lint catch it? If yes, a static check. If no: is it pure logic with no DOM? If yes, a unit test. If no: does it need real cookies or servers? If yes, an E2E test, and keep those few. If no: is it about looks or the API's shape? If yes, a visual or contract test. If no to all four, an integration test, which is where most tests land.Types or lint can catch it?yesstaticnoPure logic, no DOM?yesunitnoNeeds real cookies or servers?yesE2E, keep fewnoAbout looks or API shape?yesvisual, contractnointegration testmost tests land here
Ask the questions in order, cheapest layer first. Most answers fall through to the bottom box, which is why integration tests make up the bulk of a trophy-shaped suite.

A design-system button

A Button in a shared design system is used in hundreds of places, so a bug spreads everywhere, but it has very little logic. Its risks are its props, its accessibility, and how it looks.

  • Static: typed props, with variants as a union like "primary" | "secondary" | "danger" so a typo fails the build.
  • Integration: a few tests that it renders a real button element, fires onClick on click and on Enter and Space, does not fire when disabled, and has an accessible name when it is icon-only.
  • Visual: a screenshot per variant, size, and theme, usually from Storybook stories, so a CSS change that shifts every button is seen in review.
  • Skip: E2E tests. Every E2E journey already clicks buttons.

A data table

A table with sorting, filtering, pagination, and row selection has real logic and many states. Its bugs are a wrong sort order, a filter that leaves you on a page that no longer exists, an empty state that never shows, and a header that screen readers don't announce.

  • Unit: the sort and filter functions, with a table of cases: numbers vs strings, null values, locale-aware names ("Émile" next to "Emily"), and ties.
  • Integration: render with fake network data and act like a user: click a header and check the first row, go to page 2, filter, and check you're back on page 1. Check the loading, empty, and error states, and that the sorted header has aria-sort.
  • Visual: a few screenshots for dense rows, long text, and narrow screens.
  • E2E: usually none for the table itself; it appears inside one critical journey at most.

A checkout flow

Checkout is where the money is. It spans several pages, talks to payment and order services, and uses cookies, redirects, and often third-party scripts. A bug here costs revenue by the minute, so it gets the most realistic tests you have.

  • Unit: the price, tax, and discount rules.
  • Integration: each step on its own: the address form's validation, the error when the card is declined (fake API returns 402), what happens on a double click of Pay.
  • Contract: the order and payment API shapes, so a renamed field fails a check rather than a customer.
  • E2E: one or two happy-path journeys from cart to confirmation, in Chromium and WebKit, against a test payment provider, plus a post-deploy smoke test of the same journey in production with a test account.

A chart

A chart's risk is in two places: turning raw data into points (scales, gaps, time zones), and what the user sees. Asserting on SVG path strings is a classic trap: such tests break on every library upgrade and prove nothing a person would notice.

  • Unit: the data shaping: bucketing by day in the user's time zone, filling gaps, computing axis ticks.
  • Integration: the parts a screen reader uses, such as the chart's accessible name, the data table fallback, and the tooltip text on keyboard focus.
  • Visual: screenshots with fixed data for the main chart types and for edge cases like a single point, no data, and very large numbers.
ComponentMain riskMost tests atAlso
Design-system buttonUsed everywhere; accessibility; looksVisual + a few integrationTyped variants
Data tableMany states and interactionsIntegrationUnit for sort and filter logic
Checkout flowRevenue; cookies; real servicesIntegration per step1 to 2 E2E journeys, contract, smoke test
ChartData shaping; looksUnit + visualIntegration for accessible parts

Check your understanding · Exercise 7

You're adding a date range picker to the design system. Which tests give the best confidence for the cost? Select all that apply.

Flaky tests and what they really cost

A flaky test passes and fails on the same code: run it ten times and it might be green nine times and red once. Every team with E2E tests meets them, and how a team handles them decides whether people still trust a red build.

Small flake rates add up fast

One flaky test is an annoyance; many slightly flaky tests are a broken suite. If each test fails by accident with a small chance p, independently, a suite of n tests passes with a chance of (1 - p) to the power n.

Take 200 E2E tests that each flake once in 200 runs (0.5 percent). The suite passes cleanly only about 37 percent of the time (0.995 to the power 200), so almost two out of three pull requests go red for no reason. People learn to press "re-run" without reading the failure, and the day a real bug turns the build red, it gets re-run too.

Each test flakes:
Retries:
100 pull requests with no bug, as a grid of 100 squares. With 200 tests that each flake 0.5 percent of the time and 0 retries, about 63 of the 100 go red for no reason. A real race bug that fails one run in three is caught on about 33 percent of runs.100 pull requests with no bug in themred for no reason: about 63 of 100real race failing 1 run in 3: caught on about 33% of runschance one test fails every attempt: 0.5%
Small per-test rates multiply across a suite. Retries bring the false reds down fast, but they also hide real intermittent bugs, which is why the list of retried tests must be visible.

Google's testing blog reported in 2016 that about 1.5 percent of its test runs gave a flaky result, and that almost 16 percent of its tests showed some flakiness. Those are one company's numbers; the lesson is that flakiness is normal at scale and has to be managed, not wished away.

Where flakiness comes from

  • Timing. A fixed sleep(500) that is enough on a laptop but not on a busy CI machine. Fix: wait for a condition, not a time. Playwright's web-first assertions retry until the page matches or a timeout passes.
  • Shared data. Two tests edit the same user's cart at once, or one leaves data another trips over. Fix: give each test its own data, created in setup through an API, not the UI.
  • Order. A test passes only when another one ran first and left state behind. Fix: run tests in random order now and then to find these.
  • The clock and the outside world. A test that checks "today" fails at midnight or in another time zone; one that calls a real third-party API fails when it is slow. Fix: pin the clock and fake third parties.
  • Real bugs. Sometimes the app itself has a race, such as two requests answering out of order, and the test is right to fail. Retries hide exactly these bugs; see [request races](/system-design/concepts/request-races).

A policy that keeps the suite trusted

Good teams treat a flaky test as a bug with a life cycle. A common policy:

A state machine for a flaky test with five states. Healthy, which blocks merges, goes to Flaky when a retry passes on the same commit. Flaky goes to Quarantined within a day: it still runs but does not block. Quarantined goes to Owner fixing, with a deadline. From Owner fixing, a fixed test goes back to Healthy and blocks again; if the deadline passes, the test is Deleted because nobody trusts it.Healthyblocks mergesFlakyseen on a retryQuarantinedruns, no blockOwner fixinghas a deadlineDeletednot trustedflakesin a dayowner assignedfixed: blocks againdeadline passed
A flaky test always has a state and an owner. Quarantine protects everyone else's pull requests; the deadline stops quarantine from becoming a graveyard.
  1. Detect. Record every test result. A test that failed and then passed on a retry, on the same commit, is flagged as flaky automatically. Playwright reports these as "flaky" when retries are on.
  2. Quarantine. Move the test out of the blocking set within a day, so it stops failing other people's pull requests. It still runs and reports, it just doesn't block.
  3. Own and fix. Every quarantined test gets an owner and a deadline. Fix the cause, or delete the test if the same thing is covered elsewhere.
  4. Return or remove. A fixed test goes back into the blocking set. A test still flaky after the deadline is deleted. A test nobody trusts is worse than no test.

Check your understanding · Exercise 8

An E2E test for "add to cart" fails about once in 30 runs. Which steps are good next moves? Select all that apply.

Coverage: a weak signal, used well

Code coverage is the share of your code that ran during the tests. Tools such as Istanbul or V8's built-in coverage (both available in Vitest and Jest) report it per file: lines, statements, functions, and branches. It's easy to measure, so it's easy to turn into a target, and that's where the trouble starts.

What coverage tells you, and what it doesn't

Coverage tells you which code did not run. That is useful: a file at 0 percent has no tests at all, and an uncovered catch block means no test ever tried the failure path. Coverage cannot tell you whether the code that ran was checked. A test that renders the whole app and asserts nothing reaches high coverage and catches almost nothing.

tsx
test("checkout page", () => {
  render(<CheckoutPage />);
  // no assertions at all
});
// Coverage: CheckoutPage.tsx 88% lines. Bugs caught: close to none.
Coverage counts lines that ran, not lines that were checked. This test would pass with the NaN total from the first chapter.

Coverage also says nothing about what lives outside your source code. Unit and integration tests can cover 95 percent of lines and still miss the cookie, the deploy config, the API rename, and the Safari layout.

Why hard targets backfire

When a team must hit "90 percent coverage or the build fails", people write tests to raise the number: tests of getters, constants, and paths no user can reach. They cost maintenance, add little confidence, and turn the number into the goal instead of a hint.

Using coverage well

  • Look for gaps, not totals. Scan the report for important files with low coverage, like checkout or auth, and for uncovered error branches.
  • Check the coverage of the change. Some teams report coverage only for the lines a pull request changed (diff coverage). It asks "did you test what you just wrote?" without pressure to test old code.
  • Use mutation testing for critical logic. A tool such as Stryker makes small changes to your code (flip a > to >=, delete a line) and checks that some test fails. A surviving change shows code that runs without being checked. It is slow, so keep it to logic like pricing.
  • Track escaped bugs. The best measure of a test strategy is how many bugs reach users and which layer would have caught them. Coverage is only a hint towards that.

Check your understanding · Exercise 9

A team's coverage rose from 60 to 90 percent in a quarter, but the number of bugs reaching users stayed the same. What are likely reasons? Select all that apply.

Testing in a system design interview

Testing rarely gets its own question in a frontend system design interview, but it often comes up near the end: "How would you test this?" A short, structured answer is a strong senior signal, because it shows you think about the system's life after launch.

When to bring it up

  • When the interviewer asks, or when you wrap up with "things I'd add next".
  • When a decision creates a risk only tests can manage. If you chose optimistic updates, say how you'd test the rollback.
  • For component questions (design an autocomplete, a date picker, a data table), testing is part of a complete answer: keyboard behavior, accessibility, and states.

A four-part answer

Keep it to about two minutes, and tie each part to the design you just drew. A good structure:

  1. The shape and why. "Mostly integration tests of components with the network faked by MSW, a strict TypeScript and lint base, unit tests for the ranking logic, and a few E2E tests."
  2. The critical journeys. Name the 2 to 4 E2E journeys for this product. For a news feed: load the feed, scroll to load more, like a post, and see it survive a refresh.
  3. The risky parts of this design. Point at your own diagram: "The cursor pagination and the optimistic like are where I'd put the most integration tests: duplicates across pages, rollback on a failed like, and two quick likes in a row."
  4. How it runs. What blocks a pull request, what runs after merge, and what runs after deploy. Mention flaky test handling in one sentence.
A wireframe of the checks on pull request 482. Passed and blocking: typecheck and lint in 18 seconds; unit and integration tests on 8 workers in 41 seconds; E2E critical journeys on 4 shards in 4 minutes 12 seconds. Not run on the pull request: visual tests and the full E2E suite in three browsers, which both run on merge. A note says the three blocking checks take about 5 minutes.Pull request #482: checks✓typecheck and lint0:18✓unit + integration, 8 workers0:41✓E2E critical journeys, 4 shards4:12…visual testson merge…full E2E in 3 browserson mergeblocking: about 5 min in total
What you draw at the end of an interview answer: what blocks a merge, what runs later, and the time budget. The fast checks run first so a typo fails in seconds, not after the E2E shards.

How deep to go by level

LevelWhat a good answer covers
Junior / midNames the layers and what each catches; tests behavior through roles and text, not internals; one or two E2E journeys
SeniorPicks a shape for this product and says why; ties tests to the design's risky parts; CI budget and where each layer runs; contract or schema checks at the API
StaffTreats tests as a team system: ownership of flaky tests, escaped-bug tracking, test data and environments, post-deploy checks and feature flags as a safety net alongside tests

Production safety nets belong in a senior answer too. Tests reduce the chance of a bad release; monitoring, error tracking, feature flags, and fast rollback reduce the damage when one gets through. Saying both shows you know tests are only one part of shipping safely.

Check your understanding · Exercise 10

You designed a chat app with WebSockets and optimistic sending. Which testing points would strengthen your answer? Select all that apply.

Summary

  • A test suite is a budget. Judge each test by the confidence it buys per minute of writing, running, and maintaining it.
  • Tell layers apart by what runs for real and what is faked. Each step towards real catches more kinds of bugs and is often 10 to 100 times slower.
  • For UI code, follow the trophy: a strict static base, a few unit tests for pure logic, most tests at the integration level, and a small set of E2E tests for critical journeys.
  • Pick the cheapest layer that can see each bug. Types and lint catch whole classes of bugs for free; cookies, deploy config, and real browsers need E2E; API shape changes need contract or schema checks.
  • Match the mix to the component: visual and accessibility tests for design-system pieces, integration tests for stateful widgets, E2E for revenue-critical flows, unit and visual tests for charts.
  • Flaky tests compound: 200 tests at 0.5 percent flakiness fail most runs. Detect, quarantine, own, and fix them, and never let retries hide the list.
  • Coverage shows what didn't run, not what was checked. Use it to find gaps, not as a target.

In the interview

Questions interviewers ask about this. Answer each out loud first, then compare.

Question 1

How would you set up testing for a new frontend app your team is starting?

Question 2

What's the difference between the test pyramid and the testing trophy, and which do you use?

Question 3

Your E2E suite fails on about half of all pull requests, and people just re-run it. What do you do?

Question 4

Is 90 percent code coverage a good goal?

Question 5

What would you test in a design-system Select component?

Practice it

These design problems lean on a frontend testing strategy.

References

Read next