cf.completefrontend
My progress
FUNDAMENTALS · MODULE 16

End-to-end tests

Tests that drive a real browser through your real app like a user would: what to cover, how to keep them reliable, where test data comes from, and how to run them in CI.

The problem end-to-end tests solve

Picture an online shop with a healthy test suite: 1,400 unit and integration tests, all green, running in four minutes on every pull request. On a Thursday the platform team adds a stricter Content-Security-Policy header to the HTML response. The change is reviewed, the tests pass, and it ships.

Within minutes the Pay button stops working. The payment provider loads its card form in an iframe from its own domain, and the new policy does not allow that domain in frame-src. The browser blocks the iframe, the form never appears, and nobody can pay. Support notices 40 minutes later, after the first angry emails.

Why did 1,400 tests miss it? Because none of them ran in a real browser against the real server. The unit tests ran in jsdom, a fake DOM in Node.js that does not enforce CSP.

The integration tests mocked the payment SDK. The header lived on the server, which no frontend test ever touched. Every piece worked; the whole did not.

An end-to-end test (E2E test) closes that gap. It is a program that starts a real browser, opens your real built app served by a real (or realistic) server, and clicks and types the way a person would. Then it checks what that person would see: the confirmation page, the order number, the error message.

What only end-to-end tests catch

Lower layers test pieces in isolation, which is what makes them fast. End-to-end tests are the only layer that sees the seams between pieces. These are the bugs they exist for:

  • Server and browser rules. Headers such as CSP, CORS, and Set-Cookie flags, redirects, and caching only exist in a real browser talking to a real server.
  • The build. A bundler setting that drops a side-effect import, a wrong environment variable, a missing polyfill, a code-split chunk that 404s after deploy.
  • Wiring between pages. Routing, guards that redirect logged-out users, state that must survive a full page load, the back button.
  • Real browser engines. Layout that hides a button behind a sticky footer on a small screen, a Safari-only date input quirk, focus that lands in the wrong place.
  • Contracts between teams. The API renamed totalCents to total_cents; both sides have green unit tests against their own idea of the shape.
Six layers of a running web app, top to bottom: the browser engine with CSP and cookies, the built bundle, your components and hooks, routing and full page loads, the server with its headers and redirects, and the API, database, and payment iframe. Unit tests reach only the components. Integration tests reach the components and routing. End-to-end tests reach all six layers. The CSP bug from the story lives in the browser engine layer, which only end-to-end tests reach.unitintegrationE2Ebrowser engine: CSP, cookiesyesbuilt bundle: chunks, env varsyesyour components and hooksyesyesyesrouting and full page loadsyesyesserver: headers, redirectsyesAPI, database, payment iframeyesthe CSP bug from the storyonly E2E reaches every layer
Lower layers of the test pyramid are fast because they cut the app into pieces. The CSP bug lived in a layer only a real browser has, so only the E2E column could have caught it.

Critical paths: test the flows that pay the bills

End-to-end tests are the slowest and most expensive tests you own. One test takes 5 to 60 seconds, needs a browser and a backend, and can fail for reasons that have nothing to do with your code. So you do not write them for everything. You write them for critical paths: the few user journeys where a break costs money, data, or trust.

Ask what a break costs, not whether the code is complex.
FlowCost if it breaksEnd-to-end test?
Sign up and log inNo new users; existing users locked outYes, always
Search, product page, cart, checkoutDirect revenue loss per minuteYes, the happy path plus one payment failure
Password resetSupport tickets, lost accountsYes
Editing a profile bioMild annoyanceNo: an integration test is enough
Every validation message on a formSmall and localNo: unit or integration tests

A useful size for most products is 10 to 50 end-to-end tests, next to hundreds or thousands of unit and integration tests. The lesson on a [frontend testing strategy](/system-design/concepts/testing-strategy) explains how the layers share the work; this lesson is about doing the top layer well.

Check your understanding · Exercise 1

Which of these bugs would an end-to-end test catch but a Testing Library integration test with mocked APIs most likely miss? Select all that apply.

The mental model: a robot user with backstage powers

Think of an end-to-end test as a mystery shopper you hired. The shopper walks into the store through the front door, picks a product, pays, and writes down what happened. They don't open the stock room or read the cash register's memory. If the receipt is right, the store works.

A test works the same way. It touches the app only through what a user can see and do: it finds a button by its label, clicks it, types into a field, and reads the text on the screen. It never reaches into React state or a Redux store. That is what makes it a test of the whole system, and what lets you rewrite the internals without touching the test.

Unlike a real shopper, though, the test has a few backstage powers. It can create a user through an API before it starts, so it doesn't have to sign up through the UI every time. It can intercept a network request and answer it with a fake, or freeze the clock.

Good end-to-end tests use the front door for the thing they are testing and the back door for everything else.

Three parts, three processes

Every end-to-end setup has the same three parts, and most confusing failures make sense once you know which part a line of code runs in:

  • The test runner. A program, usually in Node.js, that loads your test files, decides which run in parallel, and reports results. It holds your test code.
  • The browser. A real Chromium, Firefox, or WebKit (the engine inside Safari), started by the runner, often headless (no visible window) in CI.
  • The app under test. Your built frontend, served by a server, plus whatever it talks to: the API, a database, a payment sandbox. It can be a local stack in containers, a shared staging environment, or a fresh preview deploy per pull request.

How the tools drive the browser

Among JavaScript developers, surveys such as State of JS show Playwright (from Microsoft) and Cypress as the two most used end-to-end tools. They put the test code in different places, and that one choice explains most of their differences.

Playwright runs outside the browser. Your test runs in Node.js and sends commands to the browser over a protocol connection: "click the element matching this locator", "tell me when this request finishes". For Chromium-based browsers that is the Chrome DevTools Protocol (CDP), the same protocol DevTools uses. For Firefox and WebKit, Playwright ships its own patched browser builds that speak a similar protocol, which is why it can't drive the Firefox or Safari you install yourself.

Because the runner is outside, it can open several tabs, several isolated users, and any number of origins in one test.

Cypress runs inside the browser. It loads your app in an iframe and runs your test code in the same browser, next to the app, with a Node.js server process on the side that proxies the app's traffic. Being inside gives it direct access to window and the DOM, and a very good interactive runner where you can step back through each command. It also means the test lives within the browser's own rules: one tab, and extra work (cy.origin) to visit a second origin.

Two architectures side by side. Playwright: the test runner with your test code runs in Node.js outside the browser and sends commands over CDP or a similar protocol to a real browser holding two isolated contexts, A and B, each with its own cookies. Cypress: inside one real browser tab, your test code and your app run next to each other in two iframes, with a Node server outside the browser proxying the app's traffic.Playwrighttest runnerNode.js, your test codeprotocolreal browsercontext Acontext Beach with its own cookiesCypressreal browser, one tabtest codeyour apptwo iframes, one pagedirect access to windowNode serverproxies app trafficdrives the browser from outsideruns inside, next to the app
Where the test code lives explains most differences. Outside the browser, Playwright can open many tabs, users, and origins in one test. Inside it, Cypress gets direct access to the page but lives by the tab's rules.

A third family, Selenium and WebdriverIO, drives browsers through WebDriver, a W3C standard. A newer companion standard, WebDriver BiDi (bidirectional), adds two-way messages (events such as console logs and network activity) so standard tools can do much of what CDP allows, across browsers. Expect the gap between the families to keep shrinking; the choice today is more about developer experience than raw ability.

Decision Which tool should a new web team pick for end-to-end tests?

Playwright

Test code in Node.js, commands sent to the browser over a protocol

Fresh browser context per test; built-in parallel workers and sharding

Pros

  • Chromium, Firefox, and WebKit from one API
  • Multiple tabs, users, and origins in one test
  • Free parallelism, trace viewer, and screenshot assertions built in
  • Also has Python, Java, and .NET bindings

Cons

  • Commands are async, so a forgotten await is a classic bug
  • Debugging is through traces and UI mode rather than inside the live app

Cypress

Test code runs in the browser next to the app

Commands are queued and retried automatically

Pros

  • Excellent interactive runner with time-travel snapshots
  • Direct access to the app's window for stubbing
  • Also strong at component testing
  • Large community and plugin set

Cons

  • One tab per test by design; second origins need cy.origin
  • JavaScript and TypeScript only
  • Parallel runs are easiest through its paid cloud service

Selenium or WebdriverIO

Standard WebDriver (and now WebDriver BiDi) to any browser, including real devices through cloud grids

Pros

  • Widest browser and device reach
  • Many languages; fits large QA teams already using it

Cons

  • More setup; waiting and isolation are more up to you
  • Slower feedback loop for frontend developers

What we pick: For a new web project, Playwright is a common default today because cross-browser runs, parallelism, and debugging traces are free and built in. Cypress is a fine choice when the team values its interactive runner and the app lives on one origin in one tab. Choose WebDriver tools when you need real mobile devices or already have that grid. In an interview, name one, say why in a sentence, and move on: the ideas in the rest of this lesson apply to all of them.

Think first

Your teammate wants the checkout test to read the cart from the Redux store with window.store.getState() instead of reading the cart badge. Why push back?

How a test runs, step by step

Here is a complete Playwright test for the shop's most important journey: a guest buys one mug. Read it once top to bottom; the rest of the chapter follows what each line does in the browser.

ts
import { test, expect } from "@playwright/test";

test("guest can buy one item", async ({ page }) => {
  await page.goto("/products/blue-mug");
  await page.getByRole("button", { name: "Add to cart" }).click();
  await expect(page.getByRole("link", { name: "Cart, 1 item" })).toBeVisible();

  await page.getByRole("link", { name: "Cart, 1 item" }).click();
  await page.getByRole("button", { name: "Check out" }).click();
  await page.getByLabel("Email").fill("buyer@example.test");

  const card = page.frameLocator('iframe[title="Secure card form"]');
  await card.getByLabel("Card number").fill("4242 4242 4242 4242");
  await page.getByRole("button", { name: "Pay $18.00" }).click();

  await expect(page.getByRole("heading", { name: "Thank you" })).toBeVisible();
  await expect(page).toHaveURL(/\/orders\/\d+$/);
});
Every line either acts like a user or checks what a user would see. The card field lives in the payment provider's iframe, which is exactly what the CSP bug from chapter 1 broke.

Notice what is missing: there is no sleep, no "wait for the API", no check that the page has finished loading. The tool waits for you, in two different ways: before every action and inside every assertion.

What one click really does

Take the line getByRole("button", { name: "Add to cart" }).click(). The runner sends it to the browser, and Playwright then works through these steps, retrying the whole sequence until it succeeds or the test times out:

  1. Find. Look for elements whose role is button and whose accessible name is "Add to cart". If there are none yet (the page is still rendering), wait and look again.
  2. Check it is the only one. Locators are strict: if two buttons match, the test fails with a clear error instead of guessing which one you meant.
  3. Check it can be clicked. The element must be visible, stable (not moving: its box is the same across two animation frames), enabled, and not covered by something else, such as a cookie banner or a loading overlay. Recent versions also refuse to click behind an open modal dialog.
  4. Act. Scroll it into view and send real mouse events at its center: move, press, release. The app receives trusted events, the same as from a person.
A flowchart of one click. Five checks in a column: attached to the page, visible, stable for 2 frames, enabled, not covered. Each yes leads down to the next check, and after the last one the tool clicks with real mouse events. Any no leads right to a wait, then retry box, which loops back to the first check after a short pause. If the timeout passes, the test fails with a clear error.attached to the page?novisible?nostable for 2 frames?noenabled?nonot covered?noclick with real eventswait, then retryafter a short pauseafter the timeoutfail the testwith a clear error
Down is yes, right is no. The loop is why you never write a sleep before a click: the tool keeps checking until the element is truly ready, or fails clearly when it never is.

Playwright calls these actionability checks; Cypress runs similar checks before its actions and uses the same name. Together they remove the most common cause of flaky tests: acting on an element a few milliseconds before it is ready.

Assertions that wait: web-first assertions

The click returns as soon as the mouse is released. It does not wait for the add-to-cart request to finish, because Playwright cannot know which requests matter to you. The next line covers that: await expect(locator).toBeVisible() is a web-first assertion. It checks the page, and if the check fails it checks again after a short pause, over and over, until it passes or a timeout (5 seconds by default) runs out.

So the test reads as "click, then the badge should say 1 item", and the tool works out how long that takes on this machine today. On a fast laptop the badge appears after 120 ms; on a busy CI runner after 900 ms. The same test passes on both without changing a number.

A sequence diagram between the test runner, the browser, and the API. The runner asks the browser to click Add to cart. The browser sends POST /cart/items to the API and reports the click done after 40 ms. The runner then asserts the badge says 1 item; the browser answers not yet, and the runner retries. The API answers 201 Created after 180 ms, the app updates the badge, and the next check passes.test runnerbrowserAPIclick Add to cartPOST /cart/itemsclick done, 40 msbadge says 1 item?not yet: retry201 Created, 180 msbadge says 1 item?yes: assertion passes
click() returns before the request finishes. The web-first assertion absorbs the gap: it fails, waits briefly, and checks again, passing as soon as the badge updates, whether that takes 180 ms or 900 ms.
The left column retries until the page matches; the right column checks once.
Write thisNot thisWhy
await expect(badge).toHaveText("1 item")expect(await badge.textContent()).toBe("1 item")The second reads the text once, right now, and fails if the request hasn't answered yet.
await expect(page).toHaveURL(/orders/)expect(page.url()).toContain("orders")Same trap: page.url() is a snapshot, the redirect may not have happened.
await expect(spinner).toBeHidden()await page.waitForTimeout(2000)A fixed wait is too long on fast machines and too short on slow ones.

Think first

Why not simply wait until the network is idle (no requests for 500 ms) after every click?

Locators: finding things the way users do

A locator is a description of how to find an element: "the button named Add to cart". It is not the element itself. Playwright evaluates it fresh every time you use it, which is why it can wait and retry, and why it still works after React re-renders the button.

How you describe the element decides how often the test breaks for no reason. A test that finds the Pay button by div.checkout > div:nth-child(3) > button breaks when a designer wraps it in one more div. A test that finds it by role and name breaks only when the user would notice a change too.

The order to try them in

The order is the same one Testing Library recommends, covered in [unit and integration tests](/system-design/concepts/unit-and-integration-tests): start with what assistive technology sees, fall back to what is visible, and use test ids last.

LocatorFinds byUse it forWatch out for
getByRole("button", { name })ARIA role plus accessible nameButtons, links, headings, checkboxes, dialogs, rowsIf it can't find a control, the control may be inaccessible: fix the app, not the test
getByLabel("Email")The label tied to a form fieldInputs, selects, text areasPlaceholders are not labels
getByText("Free shipping")Visible textNon-interactive content and messagesMatches substrings by default; long copy changes often
getByTestId("price-chart")A data-testid attributeCanvas, maps, rows with identical textInvisible to users, so it proves nothing about accessibility
locator("css or xpath")DOM structureLast resortBreaks on any markup refactor
The locator getByRole button with name Remove Blue mug, split into its parts: the role, button, and the accessible name, Remove Blue mug. Below it, the cart's markup: a list with two items, each with a Remove button. The locator matches exactly one element, the button labelled Remove Blue mug. The Remove Green cup button has a different name and is skipped.getByRole("button", { name:"Remove Blue mug" })roleaccessible namecart page<ul aria-label="Cart"><li> Blue mug, $18<button aria-label="Remove Blue mug"><li> Green cup, $12<button aria-label="Remove Green cup"></ul>1 match: clickedother name: skipped
The role comes from the element itself (a button tag), the name from its label or text. Both are what a screen reader announces, so the locator survives any change a user wouldn't notice.

Role locators bring a free bonus. If getByRole("button", { name: "Pay $18.00" }) finds nothing because the button is really a div with a click handler, the test has just found an accessibility bug: a screen reader user can't find that button either.

Narrowing to the right element

Real pages repeat things. A cart with three items has three Remove buttons, and a strict locator will refuse to guess. Narrow the search to the right container instead of picking by position:

ts
// Fragile: depends on item order
await page.getByRole("button", { name: "Remove" }).nth(1).click();

// Clear: the Remove button in the row that mentions the blue mug
await page
  .getByRole("listitem")
  .filter({ hasText: "Blue mug" })
  .getByRole("button", { name: "Remove" })
  .click();

await expect(page.getByRole("listitem")).toHaveCount(2);
Chain from a container to the control, and filter by what makes the row unique.

Even better, give the button a unique accessible name in the app, such as aria-label="Remove Blue mug". Screen reader users hear three identical "Remove" buttons too, so the fix helps both.

Keeping tests readable as the suite grows

At 30 tests, the same five lines that log in or fill the address show up everywhere. Two patterns keep this tidy. A page object is a small class per page with methods such as checkout.fillAddress(address), so a changed form means one fix. A fixture (Playwright's test.extend) hands each test a ready-made thing, such as a signed-in page or a seeded product, and cleans it up afterwards.

Don't overdo it. A test should still read as a user story on its own screen. If you need to open four files to learn what a test clicks, the abstraction has gone too far.

Check your understanding · Exercise 2

Which locators for the checkout's Pay button are likely to survive a redesign that keeps the same behavior? Select all that apply.

Test data, isolation, and signing in

Most suites start with one test account, qa@shop.test, created by hand on staging. Every test logs in as it. That works for five tests running one after another. At 60 tests in four parallel workers it falls apart: one test empties the cart while another is checking that the cart has two items, and both fail at random.

The rule that fixes this is simple to say: every test owns its data. A test creates what it needs, uses it, and never touches data another test might be using. Then tests can run in any order, in parallel, and a failure means something.

Before and after. Left, shared account: worker 1 and worker 2 both use one shared cart, so worker 2 adds a mug while worker 1 expects 1 item and sees 2, and tests fail at random. Right, own data per test: worker 1 uses cart A and worker 2 uses cart B, so tests can run in any order on any number of workers.shared accountworker 1worker 2one cartexpects 1 item, sees 2fails at randomown data per testworker 1worker 2cart Acart Beach test owns its cartany order, any workers
The tests didn't change; the data did. Shared data makes a test's result depend on what other tests did a moment ago, which is the most common cause of failures that appear only in parallel runs.

Three kinds of state to isolate

  • Browser state: cookies, localStorage, IndexedDB, cache. Playwright gives every test a fresh browser context, like a new incognito window, in a few milliseconds. This one is free.
  • Server data: users, carts, orders in the database. The tool can't reset your database, so this one is on you, and it is where most shared-state flakiness comes from.
  • The outside world: the clock, feature flags, third-party APIs. Covered in the next chapter.

Where test data comes from

Use the front door for the flow under test and an API for the setup around it.
ApproachHowGood forCost
Seed onceLoad a fixed data set when the environment startsRead-only reference data: the product catalog, countries, pricesTests must never change it
Create per test through an APIA fixture calls POST /api/test/users and returns the new userAnything a test changes: users, carts, orders50 to 200 ms per call; needs a test-only endpoint
Create through the UISign up through the form in every testOnly the test of sign-up itself5 to 10 seconds each, and every UI bug fails every test
Write to the database directlyInsert rows from the testData no API can createCouples tests to the schema

Give generated data unique, recognizable names, such as buyer-w3-8f2c1a@example.test (worker 3, a random suffix). Unique names stop collisions between parallel tests; a shared domain like example.test makes leftovers easy to find.

Clean up in two layers. The fixture deletes what it created when the test ends. A nightly job deletes anything under example.test older than a day, for the times a crashed run skipped its teardown. Even simpler, when you can afford it: run each pull request against a fresh ephemeral environment with its own database, and throw the whole thing away afterwards.

Signing in once: reusing storage state

Almost every test needs a signed-in user, and signing in through the UI takes 3 to 5 seconds. Across 200 tests that is 10 to 17 minutes of typing the same password. Playwright's answer is storage state: sign in once, save the browser's cookies and localStorage (plus IndexedDB if you pass indexedDB: true) to a file, and start every other test with that file already loaded.

ts
// auth.setup.ts: runs once, before the tests that depend on it
import { test as setup, expect } from "@playwright/test";

setup("sign in", async ({ page }) => {
  await page.goto("/login");
  await page.getByLabel("Email").fill(process.env.E2E_USER!);
  await page.getByLabel("Password").fill(process.env.E2E_PASSWORD!);
  await page.getByRole("button", { name: "Sign in" }).click();
  await expect(page.getByRole("button", { name: "Account" })).toBeVisible();
  await page.context().storageState({ path: "playwright/.auth/user.json" });
});

// playwright.config.ts (excerpt)
projects: [
  { name: "setup", testMatch: /auth\.setup\.ts/ },
  {
    name: "chromium",
    dependencies: ["setup"],
    use: { storageState: "playwright/.auth/user.json" },
  },
],
The setup project also doubles as the test of the sign-in page. Keep the .auth folder out of git.
A timeline of three 6-second tests on one worker. When every test signs in through the UI first, each spends 4 seconds signing in and the three finish at 30 seconds. With a setup step that signs in once in 4 seconds and saves user.json, the three tests start already signed in and finish at 22.5 seconds. Across 200 tests that saves about 13 minutes.every test signs insign intest 1sign intest 2sign intest 3done at 30 ssign in once, reuse the statesign insaves user.jsontest 1test 2test 3done at 22.5 s0 s10 s20 s30 s200 tests: about 13 minutes saved
The setup step pays the sign-in cost once and writes user.json; every test after it starts from that saved state. The saving grows with every test you add.

Two traps. First, one saved user shared by all workers is the shared account problem again, as soon as tests change that user's data. Use one account per worker (the worker index picks it) or per test for anything that writes. Second, sessionStorage is not saved, so an app that keeps its token there needs a small script to restore it.

Check your understanding · Exercise 3

A checkout test fails about once in 15 runs with "expected 1 item, received 2", only when CI runs 4 workers. What are good fixes? Select all that apply.

Real backend or fake one: controlling the network

An end-to-end test can run against everything real: browser, frontend, API, database, payment sandbox. Or it can run the real frontend in a real browser while answering some or all network requests with fakes. Both are useful. The skill is knowing which requests to fake and why.

Playwright intercepts requests with page.route, and Cypress with cy.intercept. A route handler sees each matching request and decides: let it through (route.continue()), answer it with a fake (route.fulfill()), or drop it (route.abort()).

ts
test("declined card shows a clear error", async ({ page }) => {
  await page.route("**/api/payments", (route) =>
    route.fulfill({
      status: 402,
      json: { code: "card_declined", message: "Your card was declined." },
    }),
  );
  // Third-party widgets add load time and random overlays: drop them.
  await page.route(/chat-widget|analytics/, (route) => route.abort());

  await page.goto("/checkout");
  await fillTestCard(page); // a helper that types the sandbox card number
  await page.getByRole("button", { name: /^Pay/ }).click();
  await expect(page.getByRole("alert")).toHaveText(/card was declined/);
  await expect(page.getByRole("button", { name: /^Pay/ })).toBeEnabled();
});
Fake the one response that is hard to produce for real; leave the rest of the app real.

What to fake and what to keep real

  • Keep your own API real on critical paths. If the checkout test fakes /api/cart, it can't catch the day the API renames a field. That contract is half of why the test exists.
  • Fake error states that are hard to trigger. A 500 from the order service, a timeout, a declined card, a rate limit. Making the real backend fail on cue is slow and brittle; a route makes it one line.
  • Fake or block third parties you don't own. Analytics, ads, chat widgets, and A/B testing scripts make tests slow and random. Payment providers usually offer a sandbox with test card numbers; use it for the happy path.
  • Pin the outside world. Freeze time with page.clock so "sale ends today" doesn't fail at midnight, set timezoneId and locale so dates render the same everywhere, and force feature flags to known values per test.
A decision tree for each request a test makes. Is it your own API on a critical path? Yes: keep it real, because it catches contract drift. No: is it a third party you don't own? Yes: fake or block it, with the sandbox for one payment test. No: is it an error that is hard to cause? Yes: fake it in this test, for example a 500, a timeout, or a declined card. No: keep it real.your own API, critical path?yeskeep it realit catches contract driftnoa third party you don't own?yesfake or block itsandbox for one payment testnoan error hard to cause?yesfake it in this test500, timeout, declined cardnokeep it real
Fake what you can't control or can't easily break on cue. Keep real whatever the test exists to prove.

A fully mocked suite, where every API call is faked, is still valuable, but call it what it is: a browser-level integration test. It is fast and stable and checks real rendering and routing, but it shares the blind spot from chapter 1: it never meets the real server. Many teams run both: a fully mocked suite on every push, and a small real-backend suite of critical paths before merge and after deploy.

Think first

The checkout test uses the payment provider's real sandbox. It fails a few times a month because the sandbox is slow or down. Should you mock it?

Flaky tests: causes, fixes, and retries

A flaky test passes and fails on the same code. The lesson on a [testing strategy](/system-design/concepts/testing-strategy) shows why small flake rates add up: 200 tests that each flake once in 200 runs give a clean suite only about 37 percent of the time. This chapter is about the end-to-end causes and what to do about each one.

Where end-to-end flakiness comes from

SymptomLikely causeFix
Passes locally, times out in CIA fixed waitForTimeout tuned on a fast laptop; CI runners have 2 to 4 slow coresWait for a condition with a web-first assertion
Click "works" but nothing happensThe server-rendered button was clicked before React hydrated and attached its handlerAssert on something that appears only after hydration, or make the control work without JavaScript (a real form)
Fails only with several workersTests share a user, cart, or recordEach test owns its data (chapter 5)
Fails only after another testLeftover state, such as a saved setting or a feature flagFresh data per test; run in random order now and then to find these
"Element is not stable" or clicks the wrong spotAnimations, toasts sliding in, a layout shift as images loadEmulate reducedMotion: "reduce" and have the app honor it; reserve image space
Random overlay blocks a clickCookie banner, chat widget, or a promo modalSet the consent cookie in storage state; block the widget
Fails a few times a month at nightClock, time zone, or a third-party outagePin the clock and time zone; fake the third party
Fails rarely and the trace shows two responsesA real race in the app, such as a double submitFix the app: this is a bug the test found

The hydration row deserves a closer look because it surprises people. With server rendering, the HTML arrives with a visible, enabled Pay button long before the JavaScript loads. Playwright sees a visible, stable, enabled button and clicks it.

The click lands on plain HTML with no handler, and nothing happens. Users on slow phones hit the same bug, so the test is telling you something real.

Retries: a seatbelt, not a fix

Both tools can re-run a failed test automatically. In Playwright, retries: 2 in CI is the common setting. A test that fails and then passes on a retry is reported as flaky rather than passed, which is the most useful thing about retries: they turn a red build into a list of tests to fix.

Retries also cost something. Each retry of a 20-second test adds 20 seconds to the run, and a test that fails for real is run three times before it is reported. Worse, a real bug that shows up one time in ten will almost never fail a build with two retries: 0.1 to the power 3 is one run in a thousand. Try it in the lab.

Test data:
Waiting:
Retries:
Workers:
One sample run of a 40-test checkout suite: 38 passed first time, 0 passed only on a retry, 2 failed. Each test flakes 4.7% of the time, so 85% of runs go red for no reason. The real coupon race fails the build in 10% of runs. CI takes about 3.7 minutes with 4 workers.one sample run, 40 checkout testsokokokokokokokokokokokokokfailokokokfail*okokokokokokokokokokokokokokokokokokokokokok38 ok, 0 passed on retry, 2 failed; * is test 18, the coupon raceeach test flakes: 4.7%runs red for no reason: 85%real coupon bug fails the build: 10%CI time: 3.7 min16 s per test4 workers, 1 min start-up
Without retries every flake turns the whole run red. Fix the causes (own data, web-first assertions) and the red runs drop without hiding anything. Test 18 has a real race in the coupon code: right now it fails loudly, which is what you want.

Debugging a flake with a trace

A flake you can't reproduce on your laptop is hard to fix from a red line in a log. Playwright's trace records everything about a run: a DOM snapshot before and after each action, every network request and response, console messages, and a screenshot film strip. Set trace: "on-first-retry" so you pay the recording cost only when a test has already failed once.

A wireframe of Playwright's trace viewer for a failed checkout test. On the left, the list of actions: goto checkout, fill Email, and fill card succeeded, click Pay failed, and expect Thank you never ran. On the right, a film strip of screenshots, then the DOM snapshot from just before the click, where a cookie banner covers the Pay button. Below it, the network tab shows POST /api/payments was never sent.trace: checkout.spec.tsactionsgoto /checkoutfill Emailfill cardclick Payexpect Thank youDOM snapshot, before click PayPay $18.00cookie banner on topnetwork: POST /api/paymentsnever sent: banner in the way
One look at the snapshot explains the flake: on some runs the cookie banner loads late and covers the button. The fix is to accept cookies in the saved storage state, not to add a sleep.

Open it with npx playwright show-trace trace.zip, step to the failing action, and look at the page as it was. Most flakes become obvious within a minute: the overlay that was covering the button, the request that came back after the assertion gave up, the second response that overwrote the first. Cypress offers similar replays in its runner and its cloud service.

Check your understanding · Exercise 4

A test of a server-rendered page clicks "Add to cart" right after page.goto, and about 1 in 8 runs the badge never updates. The trace shows no request was sent. What are good fixes? Select all that apply.

Running end-to-end tests in CI

End-to-end tests earn their cost only if they run automatically, on every change that matters, and finish fast enough that people wait for them. That makes the CI setup as much a part of the design as the tests themselves.

When to run what

  • On every pull request: the critical-path tests, tagged @critical, in one browser (usually Chromium), against a preview deploy or a local stack in containers. This set blocks the merge, so keep it under about 10 minutes.
  • Before merge or nightly: the full suite, plus Firefox and WebKit, plus mobile viewports. Failures open a ticket instead of blocking someone's afternoon.
  • After every deploy: a handful of smoke tests against the real environment: home page loads, sign-in works, one product can reach the payment step. If they fail, roll back.

The pipeline, step by step

  1. Start from a pinned image. The official Playwright Docker image ships the browsers and their system libraries for one exact Playwright version. Same OS, same fonts, same browser builds on every run, which matters a lot once you compare screenshots.
  2. Build and start the app. Playwright's webServer option starts your server and waits until its URL answers before any test runs.
  3. Run the tests in parallel: several workers on each machine, and the suite split across machines with --shard=1/4, --shard=2/4, and so on.
  4. Keep the evidence. Upload the HTML report, traces, screenshots, and videos of failures as build artifacts, so whoever sees the red build can open the trace without re-running anything.
  5. Merge and report. Each shard writes a blob report; a final job merges them with npx playwright merge-reports into one report and one status check.
ts
// playwright.config.ts (excerpt)
export default defineConfig({
  fullyParallel: true,
  forbidOnly: !!process.env.CI, // a stray test.only fails the build
  retries: process.env.CI ? 2 : 0,
  workers: process.env.CI ? 2 : undefined,
  reporter: process.env.CI ? "blob" : "html",
  use: {
    baseURL: process.env.BASE_URL ?? "http://localhost:3000",
    trace: "on-first-retry",
    screenshot: "only-on-failure",
  },
  webServer: {
    command: "npm run start",
    url: "http://localhost:3000",
    reuseExistingServer: !process.env.CI,
  },
});
A typical CI-aware config: strict in CI, comfortable locally.

Workers and shards: where the minutes go

Two knobs split the work. Workers are parallel processes on one machine, each with its own browser. More workers help until the machine runs out of CPU; past that, every test slows down and timing flakes appear. Two to four workers per typical CI runner is a common sweet spot.

Shards split the suite across separate machines: whole test files by default, or single tests when fullyParallel is on.

Shards have a catch: every machine pays a fixed start-up cost before the first test runs, often 1 to 3 minutes for checkout, dependency install, and build. Doubling the shards halves the test time but not the start-up, and doubles the machine minutes you pay for. The calculator shows where that stops being worth it.

Workers per machine:
Highlight:
300 tests at 20 seconds each with 2 workers per machine and 2.5 minutes of start-up per machine. 1 machine: 52.5 minutes of waiting, 53 machine-minutes; 2 shards: 27.5 minutes of waiting, 55 machine-minutes; 4 shards: 15.0 minutes of waiting, 60 machine-minutes; 8 shards: 8.8 minutes of waiting, 71 machine-minutes.300 tests x 20 s, 2 per machine; hatched: start-up1 machine52.5 min wait53 machine-minutes2 shards27.5 min wait55 machine-minutes4 shards15.0 min wait60 machine-minutes8 shards8.8 min wait71 machine-minutes
With 4 shards, you wait 15.0 minutes and pay for 60 machine-minutes. Each doubling halves the test part of the bar but never the hatched start-up, so the wait shrinks less and less while the bill keeps growing.

Two more ways to save time. First, build the app once and pass the build to every shard as an artifact. Second, run only what changed: Playwright's --only-changed flag (version 1.46 and later) runs test files changed since a Git revision, plus test files that import a changed file.

Use the second with care. End-to-end tests rarely import app code, so the flag can't see that a change to a shared header component touches every journey. It suits a fast local loop; the critical set should still run in full on every pull request.

Check your understanding · Exercise 5

A suite of 300 tests averages 20 seconds each and runs on one machine with 2 workers, taking about 53 minutes. Start-up is 3 minutes per machine. Which changes would bring pull request feedback under 15 minutes? Select all that apply.

Beyond the pull request: screenshots and production checks

Two more uses of the same browser automation round out the picture. One checks how pages look, which functional assertions ignore. The other keeps running your critical-path tests after release, against production, around the clock.

Visual regression in brief

A functional test passes as long as the Pay button exists and works. It still passes if a CSS change made the button white on white, or pushed it under the footer on a narrow screen. Visual regression testing catches that: take a screenshot, compare it pixel by pixel with an approved baseline image, and fail if too much changed.

ts
await page.clock.setFixedTime(new Date("2026-03-01T10:00:00Z"));
await page.goto("/checkout");
await expect(page).toHaveScreenshot("checkout.png", {
  mask: [page.getByTestId("promo-banner")], // content that changes daily
  maxDiffPixelRatio: 0.01,
});
The first run has nothing to compare against, so it saves the screenshot as the baseline; later runs compare against it and save a diff image when they fail.

The hard part is noise, not comparison. Fonts render a little differently on macOS and Linux, so baselines must be created in the same Docker image CI uses. Dates, ads, and carousels change on every load, so freeze the clock and mask what you can't control.

Every intended design change needs a person to review and approve new baselines, which hosted services such as Chromatic or Percy make easier. The [next lesson](/system-design/concepts/contract-and-visual-tests) goes deep on all of this.

Synthetic monitoring: end-to-end tests in production

Your CI suite proves the code worked in a test environment before deploy. It says nothing about production at 3 a.m., when a certificate expires, a CDN rule changes, or the payment provider has an outage. Synthetic monitoring runs a few critical-path scripts against production on a schedule, every 1 to 10 minutes, from several regions, and alerts a person when one fails or gets slow.

It complements [real-user monitoring](/system-design/concepts/real-user-monitoring) (RUM), which measures what real visitors experience. RUM needs traffic: at 3 a.m. there may be too few checkouts to notice that none succeed. A synthetic check runs the same steps on the same schedule no matter what, so it is also a clean baseline for performance over time.

A state machine for a synthetic monitor that runs the checkout script every 5 minutes from 3 regions. It starts passing. When a check fails it becomes suspect and retries from another region. If the next check passes it goes back to passing. If it fails again it moves to alerting and pages the on-call engineer. After the check passes twice in a row it returns to passing.checkout script every 5 minutes, from 3 regionspassingall greensuspectretry other regionalertingpage on-calla check failsfails againnext check passespasses twice in a row
The suspect state is what keeps the pager quiet during a one-off network blip, while a real outage still pages someone within about 10 minutes.
  • Reuse your tests. Services such as Checkly run Playwright scripts as monitors, and observability platforms such as Datadog and Grafana offer browser checks, so a critical-path test can double as a monitor.
  • Use dedicated production accounts that are flagged as synthetic, so their orders never ship, their payments use a test mode or stop before charging, and analytics can filter them out.
  • Confirm before you page. Alert when a check fails twice in a row or from two regions, not on a single blip, or people learn to ignore the alerts.
  • Keep them few. Five to ten journeys is plenty. Each run costs money, and every flaky monitor is a false alarm at night.

Check your understanding · Exercise 6

Which problems would a synthetic checkout check every 5 minutes catch that a pre-merge E2E suite would not? Select all that apply.

End-to-end tests in a system design interview

Testing rarely gets its own interview question, but it comes up in almost every frontend design round, usually near the end: "How would you make sure this keeps working?" A good answer takes about two minutes and is tied to the product you just designed, not a generic pyramid.

What to say, in order

  1. Name the critical paths of this product. For a shop: sign-in, search to checkout, payment failure. For a chat app: send and receive a message, reconnect after going offline. Three to five journeys.
  2. Say how they run. Playwright (or Cypress) against a preview deploy, one browser on pull requests, all three nightly, under a 10-minute budget with sharding.
  3. Say how they stay reliable. Each test owns its data through API setup, sign-in reused through saved storage state, third parties faked, role-based locators and web-first assertions instead of sleeps.
  4. Say how flakes are handled. Retries in CI with traces on the first retry, flaky tests reported, quarantined, and owned.
  5. Close the loop in production. The same journeys as synthetic checks every few minutes, next to RUM and error tracking.

How deep to go by level

LevelWhat a strong answer shows
JuniorKnows E2E tests drive a real browser, picks the right journeys, uses role locators, and avoids fixed sleeps.
Mid-levelAdds test data isolation, storage state, network faking for error states, and a CI plan with retries and traces.
SeniorTreats the suite as a budget: what blocks merges, sharding cost, flake policy, ephemeral environments, synthetic monitoring, and how failures reach the right owner.
StaffShapes the system for testability: test-only endpoints guarded in production, synthetic traffic marked end to end, hydration and readiness signals in the app, and shared schemas that keep mocks honest.

Draw it if there is time. A small box labelled "E2E: 5 journeys" next to the CDN and the API, with an arrow to a preview deploy and a second arrow to production marked "synthetic, every 5 min", shows in a few seconds that you think about quality as part of the system, not as an afterthought.

Check your understanding · Exercise 7

You designed a collaborative document editor. The interviewer asks how you'd test it end to end. Which points belong in a strong answer? Select all that apply.

Summary

  • End-to-end tests drive a real browser through the real app; they are the only layer that sees headers, the build, routing, and real engines together.
  • Cover critical paths only, the journeys where a break costs money or trust: usually 10 to 50 tests.
  • Find elements the way users do (getByRole, getByLabel), and let auto-waiting and web-first assertions replace every fixed sleep.
  • Every test owns its data: create it through an API, give it unique names, clean it up, and reuse sign-in with saved storage state.
  • Keep your own API real on critical paths; fake error states, third parties, and the clock.
  • Retries turn flakes into a visible list; traces explain them; a quarantine policy keeps the suite trusted. A retry is never the fix.
  • In CI: critical tests block pull requests within about 10 minutes, the full cross-browser suite runs later, and artifacts make every failure debuggable.
  • After release, the same journeys run as synthetic monitors against production, next to RUM.

In the interview

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

Question 1

What makes an end-to-end test different from an integration test, and what should it cover?

Question 2

Your E2E suite fails about one run in three, and people just press re-run. What do you do?

Question 3

How do you handle sign-in across 200 end-to-end tests?

Question 4

Should end-to-end tests run against a real backend or mocked APIs?

Question 5

How would you keep E2E feedback on pull requests under 10 minutes as the suite grows?

Practice it

These design problems lean on end-to-end tests.

References

Read next