End-to-End Testing

End-to-End Testing

End-to-end tests drive the real system the way a user does. They are slow, few and irreplaceable: the only tests that can prove a person can actually finish a task.

intermediate15 min readUpdated Sep 16, 2026
checkout.spec.ts
ts
// tests/checkout.spec.ts
import { test, expect } from "@playwright/test";

test("a signed-in user can buy a plan", async ({ page }) => {
  await page.goto("/pricing");

  await page.getByRole("button", { name: "Choose Pro" }).click();
  await page.getByLabel("Card number").fill("4242 4242 4242 4242");
  await page.getByRole("button", { name: "Pay" }).click();

  await expect(
    page.getByRole("heading", { name: "You're on Pro" }),
  ).toBeVisible();
});
Scope
The whole system
Speed
Seconds per test
Count
Few and high value
Common tools
Playwright, Cypress
Runs in
A real browser
Owns
Critical user journeys

Why it matters

What e2e testing buys you

Confidence in the whole path

An e2e test crosses the UI, the API, the database and back, so it is the only test that can prove a user can actually finish a task.

A regression safety net

A handful of journey tests catch the integration breakages that unit tests miss: a renamed field, a broken redirect, a missing migration.

Documentation that executes

A readable spec describes the product in the user's language, and unlike a wiki page it fails the build when it goes stale.

The big picture

The three layers of an e2e test

Seed the world, drive the user's path, and assert what a person would see.

The stack

Arrange

A real database, API and frontend, seeded to a known state. If the environment is not reproducible, no assertion can be trusted.

The journey

Act

Drive the interface the way a person does, through roles, labels and visible text rather than implementation details.

The outcome

Assert

Check what the user sees and, when it matters, confirm the side effect through the API or the database.

At a glance

The e2e toolkit

Navigate

page.goto loads a route and the test begins from a real URL.

Locate

getByRole, getByLabel and getByText find elements as a user would.

Act

click, fill, type and select drive the interface.

Assert

expect(locator).toBeVisible() retries until the outcome is true.

Authenticate

Reuse a saved storageState instead of logging in every test.

Observe

Wait on responses, block third parties and assert on API calls.

Flow

The journey behind every passing test

A good e2e test arranges the world, performs one journey and leaves the environment as it found it.

  1. 1

    Seed data through the API

    Create the user, plan and any records the journey needs with API calls, not by clicking through setup screens.

  2. 2

    Open the app

    Navigate to the entry route with an authenticated session already in place so the test starts where the user does.

  3. 3

    Perform the action

    Drive the interface through role and label locators, one user step at a time, without pausing for arbitrary time.

  4. 4

    Assert the visible outcome

    Check the thing the user came to see. When the result is a side effect, confirm it through the API as well.

  5. 5

    Capture evidence on failure

    Keep traces, screenshots and video so a red build in CI can be diagnosed without a local reproduction.

  6. 6

    Reset state

    Delete or expire what the test created, or run each test against isolated data so order never matters.

A short history

How browser testing grew up

  1. 2006

    Selenium arrives

    Browser automation becomes possible, and end-to-end testing becomes a real practice with a steep learning curve.

    06
  2. 2015

    Cypress is founded

    A developer-friendly runner puts the browser in the same process and makes debugging far easier.

    15
  3. 2020

    Playwright ships

    A cross-browser library with auto-waiting resets expectations for how stable browser tests can be.

    20
  4. 2021

    Playwright Test

    A first-party runner adds fixtures, parallelism, tracing and sharding to the library.

    21
  5. Today

    A focused quality gate

    Teams keep e2e suites small and high value, running them on pull requests and before release.

    Today

The complete guide

End-to-End Testing: Everything you need to know

What end-to-end testing actually means

End-to-end testing drives the real system from a user’s point of view. Instead of calling a function or a route in isolation, an e2e test opens the application, performs the same steps a person would, and checks that the outcome a person would see actually happened.

That definition has two consequences. First, the test crosses every boundary in the system: browser, frontend code, HTTP, API, database and back. Second, the assertions are about behaviour, not implementation. You assert that a confirmation is visible, not that a particular function was called.

Because the whole stack is involved, e2e tests are the closest thing to a guarantee that a user can finish a task. They are also the slowest and most expensive tests you will write, which is exactly why you write few of them and choose them carefully.

Where e2e fits in the test pyramid

The test pyramid is a rule of thumb for how much of each kind of test to write. The base is wide: many fast unit tests. The middle is narrower: integration tests across a few components. The top is small: a handful of end-to-end journeys.

  • Unit tests are milliseconds, run without infrastructure, and pin down pure logic and edge cases.
  • Integration tests are seconds, exercise a route or a module with real collaborators, and catch wiring bugs.
  • End-to-end tests are tens of seconds, run against a deployed stack, and prove that a user can complete a critical task.

The shape matters because cost grows as you climb and feedback slows. A bug a unit test can find should be found by a unit test. Reserve e2e for the journeys where a broken path means a broken product: sign up, log in, checkout, publish, invite. If a test does not need a browser to be meaningful, it probably should not use one.

A useful sanity check: for each e2e test, ask what unit or integration test would have caught the same bug. If the answer is “one that is easy to write”, the e2e test is in the wrong layer.

Choosing the journeys worth automating

You cannot automate every path, so choose the ones where failure costs the most and where a bug is most likely to slip through the layers below.

Score a candidate journey on two axes: business impact and integration risk. High impact plus high risk is where e2e earns its keep.

  • High impact, high risk — sign up, login, checkout, password reset. Automate these first.
  • High impact, low risk — the marketing home page. Cover it with a smoke test, not a full journey.
  • Low impact, high risk — an admin export used monthly. A single test is enough, or a scripted check.
  • Low impact, low risk — settings toggles and cosmetic states. Leave them to unit and component tests.

The journeys that cross the most boundaries are the most valuable, because they are the ones a unit test cannot reach. A checkout touches the cart, pricing, payment, the order service and email — exactly the wiring that breaks silently when one team renames a field.

Write the list down and revisit it each quarter. The set of critical journeys changes as the product grows, and yesterday’s important flow can become today’s dead code.

Choosing a tool: Playwright or Cypress

Two tools dominate modern browser testing, and both are good.

Playwright drives Chromium, Firefox and WebKit through one API. It runs tests in parallel worker processes by default, supports multiple browser projects, has auto-waiting locators, and ships a trace viewer that records a run frame by frame. Its storageState makes authentication reuse simple, and its request fixture gives you an API client in the same test. It is the common default for new suites.

Cypress runs in the browser’s event loop, which makes debugging feel immediate: you can inspect the DOM at any point and time-travel through commands in the runner. Its API is friendly and its error messages are excellent. The trade-off is that cross-browser support and parallelism have historically needed more setup.

The choice rarely matters as much as the discipline. A well-written Playwright suite and a well-written Cypress suite both work. Pick one, learn its waiting model, and spend your energy on isolation and locators.

The anatomy of a test

Almost every e2e test has the same three parts, usually called arrange, act and assert.

test("a signed-in user can upgrade to Pro", async ({ page }) => {
  // arrange: the account and plan already exist
  // act: perform the journey
  await page.goto("/pricing");
  await page.getByRole("button", { name: "Choose Pro" }).click();

  // assert: the user sees the result
  await expect(
    page.getByRole("heading", { name: "You're on Pro" }),
  ).toBeVisible();
});

Keep the arrange step out of the UI where you can. Creating a user by clicking through a signup form adds minutes to a suite and couples every test to the signup flow. Create it through the API instead, then start the test already logged in.

One journey per test is the other habit worth forming. When a test covers five things and fails, you learn that one of five things broke. When it covers one, the name of the failing test is the diagnosis.

Writing a journey that reads well

An e2e test is read far more often than it is written. A reader should be able to tell what it protects without opening the app.

Name the test after the user outcome, not the implementation. “a signed-in user can upgrade to Pro” beats “test checkout button click”. Use the present tense and the user’s vocabulary.

Structure the test as a sequence of user actions, one per line, with blank lines between the phases.

test("an admin can invite a teammate", async ({ page }) => {
  await page.goto("/team");

  await page.getByRole("button", { name: "Invite" }).click();
  await page.getByLabel("Email").fill("[email protected]");
  await page.getByRole("button", { name: "Send invite" }).click();

  await expect(page.getByText("Invitation sent")).toBeVisible();
});

Extract repeated journeys into helpers, but keep the test itself readable. A helper called upgradeToPro(page) is good; a helper that hides the entire test is not. The test should still show the steps.

Finally, comment the “why”, not the “what”. The code already says which button was clicked; a comment is only useful when it explains why the test exists or why a step looks odd.

Locators: talk to the user, not the DOM

A locator is how the test finds an element. The single biggest improvement you can make to an e2e suite is to choose locators the way a user does.

Playwright orders them from most to least preferred:

  1. getByRole — buttons, links, headings and inputs by accessible role and name.
  2. getByLabel — form controls by their label.
  3. getByText — visible text.
  4. getByPlaceholder, getByAltText, getByTitle — other visible attributes.
  5. getByTestId — a stable test id when nothing user-facing fits.
await page.getByRole("button", { name: "Add to cart" }).click();
await page.getByLabel("Email").fill("[email protected]");
await expect(page.getByRole("heading", { name: "Your cart" })).toBeVisible();

This ordering is not aesthetic. Role and label locators fail when an element is inaccessible, so the test doubles as an accessibility check. They also survive refactors: renaming a CSS class does not break them, but it does break .btn.btn--primary > span.

Use getByTestId deliberately, not by default. A test id is a contract you maintain, and a suite full of them tells you nothing about whether the interface makes sense.

Handling authentication

Logging in through the UI before every test is the most common source of slow, flaky e2e suites. A login is a form, a network round trip and a redirect, repeated hundreds of times.

The fix is to authenticate once and reuse the session. Playwright calls the saved session storageState — cookies and local storage serialised to a file.

// tests/auth.setup.ts
import { test as setup, expect } from "@playwright/test";

setup("authenticate", async ({ page }) => {
  await page.goto("/login");
  await page.getByLabel("Email").fill(process.env.E2E_EMAIL!);
  await page.getByLabel("Password").fill(process.env.E2E_PASSWORD!);
  await page.getByRole("button", { name: "Sign in" }).click();

  await expect(page.getByRole("heading", { name: "Dashboard" })).toBeVisible();
  await page.context().storageState({ path: "playwright/.auth/user.json" });
});

A setup project writes the file, and the browser projects depend on it and load it through use.storageState. Tests that need a different user get a different state file.

// playwright.config.ts
export default defineConfig({
  projects: [
    { name: "setup", testMatch: /auth\.setup\.ts/ },
    {
      name: "chromium",
      use: { storageState: "playwright/.auth/user.json" },
      dependencies: ["setup"],
    },
  ],
});

You should still keep one test that signs in through the interface. The login form is a user journey in its own right, and the shortcut should never be the only coverage of it.

Making tests deterministic

Determinism is the whole game. A test that sometimes passes is worse than one that always fails, because it teaches the team to ignore red.

Three rules cover most of it.

Never sleep. waitForTimeout(3000) is either too short and flaky, or too long and slow. Wait for the condition instead: await expect(locator).toBeVisible(). Web-first assertions retry until they pass or time out, which is exactly what you want.

Control the network. Mock third parties and unstable dependencies with page.route, so a slow payment sandbox cannot fail your suite. Assert on requests your app makes when that is the behaviour under test.

await page.route("**/api/recommendations", (route) =>
  route.fulfill({ json: { items: [] } }),
);

Freeze time and randomness. A test that depends on “today” or a random id will fail on a boundary. Inject a clock, or seed the values the test reads.

There is also the browser itself: animations, autofocus and transitions create races that look like application bugs. Disable animations in the test environment when you can, and prefer assertions on the final state.

Cross-browser and responsive coverage

A journey that works in Chromium may fail in WebKit, and a layout that works on a laptop may be unusable on a phone. Cross-browser and viewport coverage is one of the few things e2e does that nothing else can.

Playwright makes it a configuration problem. Define a project per browser and reuse the same tests.

// playwright.config.ts
export default defineConfig({
  projects: [
    { name: "chromium", use: { ...devices["Desktop Chrome"] } },
    { name: "firefox", use: { ...devices["Desktop Firefox"] } },
    { name: "webkit", use: { ...devices["Desktop Safari"] } },
    { name: "mobile", use: { ...devices["iPhone 13"] } },
  ],
});

Not every test needs every browser. Run the full suite on Chromium for fast feedback, and run the critical journeys across all projects on a schedule or before release. That keeps pull requests quick without giving up coverage.

When a bug is browser-specific, add a regression test in the affected project. A comment that links to the issue is worth more than a vague “Safari is weird” note.

Managing test data

Data is where e2e suites quietly become unreliable. Two tests that both create a user called [email protected] will pass alone and fail together.

Seed through the API. It is faster than the UI, it fails loudly when seeding breaks, and it keeps the test focused on the journey rather than on setup.

const res = await request.post("/api/test/users", {
  data: { email: `ada+${Date.now()}@example.com` },
});
expect(res.ok()).toBeTruthy();
const user = await res.json();

Isolate data per test or per worker. Unique emails, tenant-scoped records, or a fresh namespace per worker all work. What matters is that no two tests depend on the same row.

Clean up what you create, or make cleanup unnecessary by scoping data to a test run and deleting the whole scope at the end. Leaked records accumulate, slow the database and eventually cause failures that have nothing to do with the current change.

Running against a real stack

An e2e test needs somewhere to run: a frontend, an API and a database, all on the same version of the code. The most reproducible option is a docker compose stack that starts the whole thing with one command.

docker compose -f docker-compose.e2e.yml up -d --wait
pnpm exec playwright test
docker compose -f docker-compose.e2e.yml down -v

The --wait flag matters: it makes Compose return only when the health checks pass, which removes the “connection refused on the first test” race. The -v on the way down discards the volumes so the next run starts clean.

Preview environments take this further. A platform builds the branch, deploys it to a temporary URL and exposes it to the test job. That is the closest to production an e2e run can get, and it lets product and QA click through the same build the tests use.

Point the suite at the environment with an environment variable — BASE_URL — and never hard-code a host. The same suite should run locally, in CI and against a preview.

When the journey is an API, not a browser

Not every end-to-end journey needs a browser. A webhook, a background job, a CLI and a service-to-service flow are all end-to-end in the sense that matters: they exercise the real system.

Playwright’s request fixture is a full API client, so an API journey looks almost identical to a browser one.

test("a paid order is fulfilled end to end", async ({ request }) => {
  const order = await request.post("/api/orders", {
    data: { sku: "pro-plan", quantity: 1 },
  });
  expect(order.status()).toBe(201);

  await request.post("/api/payments/webhook", {
    data: { orderId: (await order.json()).id, status: "paid" },
  });

  const res = await request.get(`/api/orders/${(await order.json()).id}`);
  expect(await res.json()).toMatchObject({ status: "fulfilled" });
});

These tests are faster and far less flaky than browser tests, so use them for the flows that genuinely do not need a UI. For pure API coverage at a lower level, Supertest is the lighter tool.

Running e2e in CI

E2E tests belong on pull requests and before a release, not on every save. In CI, a few flags make the difference between a useful gate and an ignored one.

  • Run headless. There is no display; Playwright and Cypress both default to headless in CI.
  • Shard the suite. Split files across machines with --shard=1/3 so wall-clock time stays flat as the suite grows.
  • Retry once. A single retry distinguishes a genuine failure from an infrastructure hiccup, as long as you track the retry rate rather than letting it hide flakiness.
  • Upload artifacts on failure. Traces, screenshots and video are how a red build in CI becomes diagnosable without a local reproduction.
- run: pnpm exec playwright test --shard=${{ matrix.shard }}/3 --retries=1
- uses: actions/upload-artifact@v4
  if: ${{ !cancelled() }}
  with:
    name: report-${{ matrix.shard }}
    path: playwright-report/

Retries are a safety net, not a fix. If a test needs a retry to pass consistently, it has a real race or a real isolation bug; the retry is only buying you time to find it.

Debugging a failing run

The first question after a failure is always the same: what did the browser actually see? A tool that can answer it without a local reproduction turns a two-hour investigation into a two-minute one.

Playwright records a trace — screenshots, DOM snapshots, network and console — and the trace viewer replays the run step by step. Keep traces on failure, then open the report and scrub to the moment the assertion failed.

pnpm exec playwright test --trace on-first-retry
pnpm exec playwright show-trace test-results/checkout/trace.zip

Locally, run the suite in headed mode or in UI mode to watch the test and pause it. Cypress offers the same idea through its interactive runner, which keeps a command log you can time-travel through.

pnpm exec playwright test --ui
pnpm exec playwright test --headed --debug

The habit that makes debugging fast is to assert on the thing that is actually wrong. A failure at expect(page).toHaveURL(/\/checkout/) tells you the navigation never happened, which is a very different investigation from a failure at the confirmation heading.

Fighting flakiness

Flakiness is the tax on e2e testing, and it is almost always one of a few causes.

  • A missing await or a race. The test acted before the app settled. Fix it with a web-first assertion, not a sleep.
  • Shared data. Two tests touched the same row. Isolate the data per test or per worker.
  • An external dependency. A third-party script or API was slow. Mock it, or exclude it from the critical path.
  • A brittle locator. The test bound to markup that changed. Move up to a role or label locator.
  • An animation. The element was present but not yet stable. Disable animations in the test environment.

The way to fix flakiness is to treat every flake as a bug with a cause, quarantine it, and fix the cause. A suite that retries its way to green is a suite nobody trusts, and a suite nobody trusts is worse than no suite at all.

Keeping a suite healthy over time

An e2e suite has a natural tendency to grow until it is slow and red. Keeping it healthy is an ongoing practice, not a one-time setup.

Review the suite the way you review the product. When a feature is removed, delete its test in the same pull request. A test that no longer protects anything is pure cost.

Track the numbers that matter: total runtime, pass rate, and the retry rate. A rising retry rate is the earliest signal that flakiness is creeping in, long before the suite becomes visibly unreliable.

Own the suite. A shared e2e suite with no owner drifts: failures get retried, tests get skipped, and within a quarter nobody trusts a red run. Assign a rotation or a small group to keep it green, and make fixing a flaky test a first-class task rather than an interruption.

Keep the suite fast enough to run on every pull request. When it outgrows that, shard it, parallelise it, or move the least critical journeys to a nightly run. A quality gate that takes an hour is a quality gate people route around.

What not to cover with e2e

The temptation is to test everything through the browser because it feels like the real thing. Resist it. E2E is the most expensive layer, so spend it where only it can help.

Do not cover with e2e:

  • Pure logic and edge cases. Date formatting, price calculation, validation rules — these belong in unit tests that run in milliseconds.
  • Every error path. A 500 page is worth one journey; the twenty ways the API can fail are integration tests.
  • Exhaustive input combinations. E2E covers the representative path, not the matrix.
  • Component behaviour. A dropdown opening belongs in a component test, which is faster and more precise.
  • Anything already covered below. Duplicating a unit test at the e2e layer adds cost and a new failure mode without adding confidence.

The rule of thumb: if the bug could be found without a browser, find it without a browser.

Best practices

  • Write few e2e tests and make each one a critical journey.
  • Seed data through the API and start tests already authenticated.
  • Use role, label and text locators; treat test ids as a last resort.
  • Wait for conditions with web-first assertions, never with waitForTimeout.
  • Isolate data per test or per worker so order never matters.
  • Keep one journey per test so a failure names the cause.
  • Point the suite at BASE_URL; run it locally, in CI and on previews.
  • Capture traces and video on failure and upload them as CI artifacts.
  • Quarantine and fix flakes; never normalise retries as a fix.
  • Keep at least one real login test even when you reuse storageState.

Common mistakes

  • Logging in through the UI before every test.
  • Selecting elements by CSS class or DOM position.
  • Adding waitForTimeout to “fix” a race.
  • Sharing a seeded user or record across tests.
  • Running against a hand-started local server instead of a reproducible stack.
  • Testing unit-level logic through the browser.
  • Hard-coding localhost:3000 so the suite only runs on one machine.
  • Uploading no artifacts, then being unable to debug a CI failure.
  • Letting a flaky test stay red-and-retried instead of quarantining it.

Where to go next

E2E testing is the top of the pyramid, and it is strongest when the layers below it are healthy. Read Supertest for the fast API tests that should cover most of your HTTP surface, and Playwright for a deeper tour of the tool itself. If you are weighing the alternative, Cypress covers the other major runner, and Docker explains how to stand up the reproducible stack your suite depends on.

In practice

Four pieces of a real e2e suite

A journey, a login shortcut, seeded data and the CI job that runs them.

tests/checkout.spec.ts
import { test, expect } from "@playwright/test";

test("a signed-in user can upgrade to Pro", async ({ page }) => {
  await page.goto("/pricing");

  await page.getByRole("button", { name: "Choose Pro" }).click();
  await expect(page).toHaveURL(/\/checkout/);

  await page.getByLabel("Card number").fill("4242 4242 4242 4242");
  await page.getByLabel("Expiry").fill("12/30");
  await page.getByLabel("CVC").fill("123");
  await page.getByRole("button", { name: "Pay" }).click();

  await expect(
    page.getByRole("heading", { name: "You're on Pro" }),
  ).toBeVisible();
});

Role-based locators vs CSS selectors

Role and label locators describe what the user sees and fail when the element is inaccessible. CSS selectors break on every refactor and quietly test markup instead of behaviour.

Prefer
await page
  .getByRole("button", { name: "Add to cart" })
  .click();
Avoid
await page
  .locator("div.product > button.btn.btn--primary")
  .click();

Wait for a condition vs sleep

Web-first assertions retry until the condition holds. A fixed sleep is either too short and flaky or too long and slow, and it hides the race it was meant to paper over.

Prefer
await expect(
  page.getByText("Order confirmed"),
).toBeVisible();
Avoid
await page.waitForTimeout(5000);
await expect(
  page.getByText("Order confirmed"),
).toBeVisible();

Trade-offs

The honest cost of end-to-end tests

E2E tests are the only ones that prove a user can finish a task, and the most expensive ones to own. Spend them carefully.

Strengths

  • They test what users do

    Only an e2e test exercises the real UI, API and database together, so it catches the wiring bugs that no unit test can see.

  • They survive refactors

    Written against roles and visible text, a journey test keeps passing while components, frameworks and internal APIs change.

  • They are a shared language

    Product, QA and engineering can read the same spec and agree on what working means.

Trade-offs

  • They are slow and costly

    A browser test takes seconds and real infrastructure, so a suite of hundreds turns every pull request into a coffee break.

  • They are flaky by nature

    Timing, networks, animations and shared data all conspire to fail tests that are not carefully isolated and awaited.

  • They give vague failures

    A red journey test tells you something in a long path broke. Pinning down what takes traces and, often, a local reproduction.

FAQ

Frequently asked questions

Keep learning

Related topics from the roadmap.

$ start learning

Ready to start learning End-to-End Testing?

Our interactive tutorial walks you through End-to-End Testing step by step — with quizzes and real code you can run in the browser.