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:
getByRole— buttons, links, headings and inputs by accessible role and name.getByLabel— form controls by their label.getByText— visible text.getByPlaceholder,getByAltText,getByTitle— other visible attributes.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/3so 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
waitForTimeoutto “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:3000so 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.