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-Cookieflags, 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
totalCentstototal_cents; both sides have green unit tests against their own idea of the shape.
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.
| Flow | Cost if it breaks | End-to-end test? |
|---|---|---|
| Sign up and log in | No new users; existing users locked out | Yes, always |
| Search, product page, cart, checkout | Direct revenue loss per minute | Yes, the happy path plus one payment failure |
| Password reset | Support tickets, lost accounts | Yes |
| Editing a profile bio | Mild annoyance | No: an integration test is enough |
| Every validation message on a form | Small and local | No: 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.
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?
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?
The test would pass even if the badge showed the wrong number, or no number, because the user never sees the store. It would also break the day the team moves from Redux to another library, though nothing the user sees changed.
Read what the user reads: expect(page.getByRole("status", { name: "Cart" })).toHaveText("2 items"). Use backstage access only for setup and for faking what you don't own.
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.
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+$/);
});
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:
- Find. Look for elements whose role is
buttonand whose accessible name is "Add to cart". If there are none yet (the page is still rendering), wait and look again. - 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.
- 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.
- 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.
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.
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.| Write this | Not this | Why |
|---|---|---|
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?
Many apps are never idle: analytics beacons, polling, a chat widget, or a WebSocket keep traffic going, so the wait either never ends or ends at a random moment. When the app is quiet, the wait adds 500 ms to every step for nothing.
More importantly, a quiet network says nothing about the screen. The response may have arrived but React not yet rendered it. Waiting for the thing the user would see, such as the badge text, is both faster and correct.
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.
| Locator | Finds by | Use it for | Watch out for |
|---|---|---|---|
getByRole("button", { name }) | ARIA role plus accessible name | Buttons, links, headings, checkboxes, dialogs, rows | If 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 field | Inputs, selects, text areas | Placeholders are not labels |
getByText("Free shipping") | Visible text | Non-interactive content and messages | Matches substrings by default; long copy changes often |
getByTestId("price-chart") | A data-testid attribute | Canvas, maps, rows with identical text | Invisible to users, so it proves nothing about accessibility |
locator("css or xpath") | DOM structure | Last resort | Breaks on any markup refactor |
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:
// 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);
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.
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
| Approach | How | Good for | Cost |
|---|---|---|---|
| Seed once | Load a fixed data set when the environment starts | Read-only reference data: the product catalog, countries, prices | Tests must never change it |
| Create per test through an API | A fixture calls POST /api/test/users and returns the new user | Anything a test changes: users, carts, orders | 50 to 200 ms per call; needs a test-only endpoint |
| Create through the UI | Sign up through the form in every test | Only the test of sign-up itself | 5 to 10 seconds each, and every UI bug fails every test |
| Write to the database directly | Insert rows from the test | Data no API can create | Couples 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.
// 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" },
},
],
.auth folder out of git.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()).
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();
});
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.clockso "sale ends today" doesn't fail at midnight, settimezoneIdandlocaleso dates render the same everywhere, and force feature flags to known values per test.
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?
Split the job. Most tests that only need "payment succeeded" can mock the provider's response and become fast and stable. Keep one test that goes through the real sandbox, since that is the only test that would have caught the CSP iframe bug from chapter 1.
Mark that one test as depending on an external service, give it a longer timeout and one retry, and report its failures separately so a sandbox outage doesn't block every pull request.
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
| Symptom | Likely cause | Fix |
|---|---|---|
| Passes locally, times out in CI | A fixed waitForTimeout tuned on a fast laptop; CI runners have 2 to 4 slow cores | Wait for a condition with a web-first assertion |
| Click "works" but nothing happens | The server-rendered button was clicked before React hydrated and attached its handler | Assert on something that appears only after hydration, or make the control work without JavaScript (a real form) |
| Fails only with several workers | Tests share a user, cart, or record | Each test owns its data (chapter 5) |
| Fails only after another test | Leftover state, such as a saved setting or a feature flag | Fresh data per test; run in random order now and then to find these |
| "Element is not stable" or clicks the wrong spot | Animations, toasts sliding in, a layout shift as images load | Emulate reducedMotion: "reduce" and have the app honor it; reserve image space |
| Random overlay blocks a click | Cookie banner, chat widget, or a promo modal | Set the consent cookie in storage state; block the widget |
| Fails a few times a month at night | Clock, time zone, or a third-party outage | Pin the clock and time zone; fake the third party |
| Fails rarely and the trace shows two responses | A real race in the app, such as a double submit | Fix 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.
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.
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
- 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.
- Build and start the app. Playwright's
webServeroption starts your server and waits until its URL answers before any test runs. - 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. - 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.
- Merge and report. Each shard writes a blob report; a final job merges them with
npx playwright merge-reportsinto one report and one status check.
// 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,
},
});
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.
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.
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 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.
- 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
- 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.
- 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.
- 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.
- Say how flakes are handled. Retries in CI with traces on the first retry, flaky tests reported, quarantined, and owned.
- 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
| Level | What a strong answer shows |
|---|---|
| Junior | Knows E2E tests drive a real browser, picks the right journeys, uses role locators, and avoids fixed sleeps. |
| Mid-level | Adds test data isolation, storage state, network faking for error states, and a CI plan with retries and traces. |
| Senior | Treats the suite as a budget: what blocks merges, sharding cost, flake policy, ephemeral environments, synthetic monitoring, and how failures reach the right owner. |
| Staff | Shapes 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?
An end-to-end test drives a real browser through the real built app, talking to a real or realistic server. An integration test renders part of the app in a fake DOM with mocked APIs. Only the end-to-end test sees the seams: headers such as CSP and cookies, the build and deploy, routing across full page loads, and real browser engines.
Because it is slow and can flake, I'd cover only critical paths, the journeys where a break costs money or trust, such as sign-up, sign-in, and checkout including one payment failure. Usually 10 to 50 tests, next to many more unit and integration tests.
Question 2
Your E2E suite fails about one run in three, and people just press re-run. What do you do?
First make the flakes visible: turn on retries in CI so the build reports which tests passed only on retry, and record traces on the first retry. That list is the work queue.
Then fix causes, not symptoms. Replace fixed sleeps with web-first assertions, give every test its own data through API setup so parallel workers don't collide, fake third parties, pin the clock, and turn off animations. Look at each trace for real app races, such as a click before hydration or two responses arriving out of order.
Finally a policy: a test flagged flaky is quarantined from blocking within a day, gets an owner and a deadline, and is fixed or deleted. Retries stay as a seatbelt, never as the fix.
Question 3
How do you handle sign-in across 200 end-to-end tests?
Sign in through the UI once, in a setup project that doubles as the test of the login page, and save the cookies and local storage (and IndexedDB if needed) with Playwright's storageState. Every other test starts already signed in, which saves 3 to 5 seconds per test.
For tests that change user data, I'd use one account per worker or create a user per test through a test-only API, so parallel tests can't interfere. The saved state file stays out of git, and the test-only API only exists when an environment flag is set.
Question 4
Should end-to-end tests run against a real backend or mocked APIs?
Both, for different jobs. Critical-path tests should hit our real API, because catching contract drift between frontend and backend is half of why they exist. Error states that are hard to trigger, such as a 500 or a declined card, I'd fake per test with route interception.
Third parties we don't own, like analytics and chat widgets, get blocked or faked for speed and stability. For payment I'd use the provider's sandbox in one test, since iframes and CSP only break for real, and fake it elsewhere.
Question 5
How would you keep E2E feedback on pull requests under 10 minutes as the suite grows?
Split by risk: only tests tagged critical block pull requests, in one browser; the full cross-browser suite runs nightly or in the merge queue. Run with a few workers per machine and shard across machines, remembering each shard pays a fixed start-up cost, so returns shrink after a point.
Build once and share the artifact, cache dependencies and use the official Docker image so browsers are ready, and set up data through APIs rather than the UI. Keep traces and reports as artifacts so a failure never needs a re-run to debug.
Practice it
These design problems lean on end-to-end tests.
References
- Playwright: Best practices User-facing locators, isolation, and web-first assertions from the tool's authors.
- Playwright: Auto-waiting (actionability checks) Exactly which checks run before each action.
- Playwright: Authentication Setup projects, storage state, and one account per worker.
- Playwright: Sharding Splitting a suite across machines and merging the reports.
- Playwright: Trace viewer Recording and reading traces of failed runs.
- Cypress: Best practices Selectors, test isolation, and avoiding fixed waits in Cypress.
- W3C WebDriver BiDi The standard two-way browser automation protocol.
- Google Testing Blog: Flaky tests at Google and how we mitigate them Numbers and policies from a very large suite.
Read next
- A frontend testing strategyHow 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.
- Unit and integration testsFast tests that check what users see and do: Testing Library queries, user-event, async UI without flakiness, mocking the network with MSW, and testing hooks, forms, and timers without coupling tests to how the code is built.
- Contract and visual regression testsTests that guard the edges between teams: schema checks and consumer-driven contracts stop an API change that would break a client, and visual tests catch pixel changes nobody meant, from one button to a whole design system.
- Testing accessibilityHow a team proves a UI works for people who use keyboards, screen readers, zoom, and voice control: layers of linting, automated scans, role-based unit tests, manual keyboard and screen reader passes, and real users, measured against WCAG.
- Real-user monitoring (RUM)Measuring speed on your real users' devices and networks: collecting Web Vitals and Performance API timings, sending them safely, and reading them as percentiles by device, network, country, and page to find what an average hides.
- Performance budgets and monitoringAgreed limits on what a page may cost (kilobytes of JavaScript, LCP, INP) that CI checks on every pull request and real-user monitoring watches in production, so speed stops getting worse one small change at a time.