End-to-End Test Architecture
Most end-to-end suites do not fail because browser automation is unreliable; they fail because nobody designed them. Tests accumulate one journey at a time, each one logging in through the UI, each one creating records the next test accidentally depends on, until a suite that once took four minutes takes forty and nobody trusts the result. End-to-end architecture is the set of decisions that stop this: what a test owns, what it borrows, where state comes from, how a worker is isolated from its neighbours, and which environment a run targets. This topic belongs to the broader test pyramid strategy, and it assumes you have already been deliberate about how few of these tests you need — the architecture here makes the ones you keep cheap enough to run on every pull request rather than nightly, and specific enough that a red run tells you what broke.
Architectural Scope & Boundaries
An end-to-end test drives the real application through a real browser against a real backend. That is its entire value and the source of every one of its costs. It is the only tier that can prove a deployed system actually works — that the router, the session cookie, the API contract, the database migration and the rendered DOM agree with each other. It is also the slowest, the most expensive to run in parallel, and the most sensitive to timing, so the architecture exists to keep the count low and the cost per test predictable.
The boundary worth defending is this: an end-to-end test asserts on observable user outcomes, never on implementation. It should not know that a request went to /api/v2/orders, that state lives in a reducer, or that a component memoises. Those belong to unit and component tiers, where they are cheaper to express and faster to run. If you find yourself reaching into network interception to assert a payload shape, you are writing an integration test with a browser attached, and the unit versus integration versus end-to-end mapping will tell you where it actually belongs.
A second boundary is the environment. An end-to-end suite is only meaningful against a deployment that resembles production in the ways the test cares about — the same router, the same session handling, the same database engine. It does not need the same data volume, the same third-party accounts, or the same scale. Being explicit about that distinction is what lets the suite run against an ephemeral preview deployment in ninety seconds instead of queueing behind a shared staging box that three teams are also using.
What this topic does not cover: component-level browser tests, which mount a single component in isolation and belong to Playwright component testing; request-level stubbing, which is external service simulation; and load or soak testing, which measures throughput rather than correctness and needs entirely different tooling.
Scope also constrains count. A healthy end-to-end suite covers the handful of journeys whose failure would mean the product is unusable — sign up, sign in, the primary conversion path, and whatever your business cannot afford to have silently broken for an hour. Everything else has a cheaper home. Teams that skip this decision end up with an end-to-end test per feature and a pipeline that nobody can run before lunch.
Prerequisites
Step-by-Step Implementation
Step 1 — Give the suite a shape before writing a single spec. Directory layout is architecture you get for free. Separate the three things that change for different reasons: specs (business journeys), helpers (how the UI is driven), and fixtures (what a test is handed).
// e2e/
// specs/checkout.spec.ts one journey per file
// helpers/checkout-page.ts selectors and flows
// fixtures/index.ts the test() you actually import
// setup/auth.setup.ts runs once, saves storage state
Step 2 — Configure projects rather than environment branching in test code. A Playwright project is a named run configuration. Use one to perform setup once, and make the real projects depend on it. This removes per-test login entirely.
// playwright.config.ts
import { defineConfig, devices } from '@playwright/test';
export default defineConfig({
testDir: './e2e/specs',
fullyParallel: true,
workers: process.env.CI ? 4 : undefined,
retries: process.env.CI ? 1 : 0,
reporter: [['html', { open: 'never' }], ['junit', { outputFile: 'results.xml' }]],
use: {
baseURL: process.env.E2E_BASE_URL ?? 'http://localhost:3000',
trace: 'on-first-retry',
screenshot: 'only-on-failure',
},
projects: [
{ name: 'setup', testDir: './e2e/setup', testMatch: /.*\.setup\.ts/ },
{
name: 'chromium',
use: { ...devices['Desktop Chrome'], storageState: 'e2e/.auth/user.json' },
dependencies: ['setup'],
},
],
});
Step 3 — Authenticate once and reuse the session. Logging in through the UI in every test is the single largest avoidable cost in most suites. Do it once in a setup project, write the cookies and local storage to disk, and let every other test start already signed in.
// e2e/setup/auth.setup.ts
import { test as setup, expect } from '@playwright/test';
const AUTH_FILE = 'e2e/.auth/user.json';
setup('authenticate', 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('heading', { name: 'Dashboard' })).toBeVisible();
await page.context().storageState({ path: AUTH_FILE });
});
Step 4 — Express “what the test needs” as fixtures. A fixture is a named dependency with automatic setup and teardown. Extending test once gives every spec a vocabulary of givens, and makes teardown impossible to forget.
// e2e/fixtures/index.ts
import { test as base } from '@playwright/test';
import { createOrg, deleteOrg } from './api';
type Fixtures = { org: { id: string; name: string } };
export const test = base.extend<Fixtures>({
org: async ({}, use, testInfo) => {
const org = await createOrg({ name: `org-${testInfo.workerIndex}-${Date.now()}` });
await use(org);
await deleteOrg(org.id);
},
});
export { expect } from '@playwright/test';
Two details make this pattern hold up. The setup project must run on every invocation rather than being cached as a CI artifact, so the token it captures is always fresh. And the storage state should be written per role, not per user: a suite that needs an admin, a standard member and a read-only viewer saves three files and selects between them with a project each, which keeps role coverage explicit rather than hidden inside a helper.
Step 5 — Keep selectors and flows in helpers, not specs. The helper does not have to be a class; what matters is that a selector appears exactly once in the repository. When the markup changes, one file changes.
// e2e/helpers/checkout-page.ts
import type { Page } from '@playwright/test';
export const checkoutPage = (page: Page) => ({
goto: () => page.goto('/checkout'),
cardField: () => page.getByLabel('Card number'),
submit: () => page.getByRole('button', { name: 'Pay now' }),
confirmation: () => page.getByRole('status'),
});
Step 6 — Write specs that read as journeys. With the layers in place, a spec contains only business language: what the user has, what they do, what they should see.
// e2e/specs/checkout.spec.ts
import { test, expect } from '../fixtures';
import { checkoutPage } from '../helpers/checkout-page';
test('a signed-in customer can complete a card purchase', async ({ page, org }) => {
const checkout = checkoutPage(page);
await checkout.goto();
await checkout.cardField().fill('4242424242424242');
await checkout.submit().click();
await expect(checkout.confirmation()).toContainText(`Order for ${org.name} confirmed`);
});
Step 7 — Make the environment a parameter, never a branch. Every environment difference should resolve to configuration read once at startup. The moment a spec contains if (process.env.ENV === 'staging'), the suite has two behaviours and only one of them is ever verified.
// e2e/fixtures/env.ts — resolve once, fail loudly when unset
const required = (name: string) => {
const value = process.env[name];
if (!value) throw new Error(`Missing required env var ${name}`);
return value;
};
export const env = {
baseURL: process.env.E2E_BASE_URL ?? 'http://localhost:3000',
apiURL: process.env.E2E_API_URL ?? 'http://localhost:3000/api',
adminToken: required('E2E_ADMIN_TOKEN'),
isEphemeral: Boolean(process.env.E2E_PREVIEW_URL),
};
Failing loudly on a missing variable is deliberate. A suite that silently falls back to localhost while CI believes it tested a preview deployment is worse than a suite that does not run at all, because it reports green.
Configuration Reference Table
| Option | Type | Default | Effect on architecture |
|---|---|---|---|
fullyParallel |
boolean | false |
Runs tests inside a file in parallel too; only safe once data is worker-isolated. |
workers |
number | string | half the cores | The real throughput dial. Each worker needs its own data namespace. |
projects[].dependencies |
string[] | [] |
Declares setup ordering, replacing ad-hoc global setup scripts. |
use.storageState |
string | object | none | Injects a saved session, removing UI login from every spec. |
use.baseURL |
string | none | The one place an environment is chosen; keeps URLs relative in specs. |
trace |
enum | 'off' |
'on-first-retry' gives a full replay of failures at almost no cost. |
retries |
number | 0 |
One retry surfaces genuine nondeterminism without hiding it. |
testIdAttribute |
string | data-testid |
Aligns Playwright with the attribute your component library already emits. |
Verification & Assertions
Verify the architecture, not just the app. Three checks tell you whether the structure holds. First, run the suite with --repeat-each=3 on a quiet machine: a correctly isolated suite produces identical results, and any variation points at shared state rather than a product bug.
npx playwright test --repeat-each=3 --workers=4
Second, run in a deliberately hostile order. Playwright does not guarantee file order across workers, so a suite that only passes in its natural sequence is already broken; shuffling proves independence.
npx playwright test --shard=2/4 # a subset must pass on its own
Third, assert that the session reuse actually happened. If storageState silently failed to load, tests would redirect to the login page and fail confusingly. A single guard spec makes that failure obvious and immediate.
import { test, expect } from '../fixtures';
test('the saved session is applied', async ({ page }) => {
await page.goto('/');
await expect(page.getByRole('button', { name: 'Account' })).toBeVisible();
await expect(page).not.toHaveURL(/\/login/);
});
A fourth check is worth running once, after any change to the fixture layer: delete the teardown and confirm the suite starts failing. A fixture whose cleanup can be removed without consequence was never isolating anything, and the records it leaves behind will accumulate until some unrelated test that counts rows begins to fail weeks later.
Edge Cases & Failure Modes
The saved session expires mid-run. A storage state captured at the start of a thirty-minute suite can hold a token that expires before the last spec. Diagnosis: failures cluster at the end of the run and redirect to login. Fix: shorten the run, refresh the state in the setup project on every invocation rather than caching it between CI runs, or issue test tokens with a lifetime longer than the suite.
Tests pass locally and fail in CI at higher worker counts. Diagnosis: the suite has shared data or relies on a global singleton such as a single seeded user. Reproduce it locally with --workers=8 rather than blaming the runner. Fix: namespace every created record by testInfo.workerIndex as in Step 4, and never assert on a global list count, which any parallel test can change underneath you.
A helper grows into a second application. When a page helper starts holding conditionals, retries and business logic, specs become unreadable in a new way. Diagnosis: the helper has methods that assert. Fix: helpers locate and act, specs assert. Move every expect back into the spec.
A retry passes and the failure is filed as flakiness. The most expensive failure mode at this tier is social rather than technical. Diagnosis: the run is green on attempt two, the trace is never opened, and the same test fails again next week. Fix: treat a retried pass as a red build for triage purposes — read the trace from the first attempt, decide whether it was timing or a real defect, and record the answer. Retries exist to keep the queue moving, not to make evidence disappear.
A journey becomes slow because it sets up through the UI. Creating an organisation by clicking through six screens costs twenty seconds and tests the same screens twenty times. Fix: set up through the API and assert through the UI — the same principle used when seeding a test database for integration tests, applied one tier up.
A single journey asserts on six unrelated outcomes. Long specs are tempting because setup is expensive, so it feels efficient to check everything once the page is loaded. Diagnosis: a failure message names step four of a test called full user flow, and nobody can tell which feature broke. Fix: keep one journey per spec and make setup cheap through fixtures instead, so splitting a test costs milliseconds rather than a re-login.
Performance & CI Impact
The cost model of this tier is simple: total wall clock is roughly the slowest shard, and each worker needs a browser, a CPU and its own data. A suite of sixty journeys at eight seconds each is eight minutes serially and about two minutes on four workers — acceptable on every pull request. The same suite with UI login in each spec is twenty minutes and will be moved to nightly within a month, at which point it stops protecting merges.
Three levers matter most, in order. Removing UI login through storageState typically cuts thirty to sixty per cent of run time outright. Sharding across runners turns wall clock into a money question rather than a waiting one, and pairs with sharding Vitest across GitHub Actions runners for a consistent pipeline shape. Setting trace: 'on-first-retry' costs nothing on green runs while making every red one debuggable, which is the difference between a five-minute diagnosis and a lost afternoon.
Caching is worth a specific note, because the obvious thing to cache is the wrong thing. Browser binaries and node modules should be cached aggressively; a cold npx playwright install adds a minute to every run for no benefit. Authentication state should not be cached between runs, because a stale token produces exactly the confusing end-of-suite failures described above. The rule of thumb is to cache what is expensive and immutable, and to regenerate what is cheap and time-sensitive.
Flakiness risk at this tier is structurally higher than anywhere else, so pair the architecture with a containment policy from flaky test mitigation: one retry to surface nondeterminism, a non-blocking lane for the genuinely unstable, and a hard rule that a retried pass is still investigated.
A final measurement discipline: record the suite’s wall clock as a number the team watches, not an anecdote. A weekly figure that drifts from two minutes to nine is a design problem that has been accumulating for a month, and it is far cheaper to notice at four minutes than at nine. The same instrumentation that produces that figure is described in test observability and reporting, and it is what turns “the end-to-end suite feels slow” into a decision about which journey to move down a tier.
In-Depth Guides
- Designing page objects that survive refactors — keep selectors in one place without building a second application.
- Reusing authenticated state across Playwright tests — sign in once per run and hand every spec a live session.
- Isolating end-to-end tests with per-worker data — namespace fixtures so parallel workers never collide.
- Running end-to-end tests against preview deployments — point the suite at the per-branch URL your platform already builds.
Related
- Back to Modern JavaScript Test Strategy & Pyramid Design
- Unit vs Integration vs E2E Mapping — decide which journeys earn a place at this tier.
- Continuous Integration Test Orchestration — shard, cache and gate the suite once it exists.
- How to calculate ROI for E2E tests in React apps — the arithmetic behind keeping the count low.
- Mocking network in Playwright component tests — the same browser, one tier down.
Designing Page Objects That Survive Refactors
Build Playwright page helpers that absorb markup changes: role-based locators, no assertions inside helpers, and composition over inheritance.
Reusing Authenticated State Across Playwright Tests
Sign in once per run and hand every spec a live session with Playwright storage state, including multi-role setups, token expiry, and per-worker accounts.
Isolating End-to-End Tests With Per-Worker Data
Namespace data by worker so parallel Playwright tests never collide: worker-scoped factories, deterministic names, reliable teardown, leak checks.
Running End-to-End Tests Against Preview Deployments
Point Playwright at the per-branch preview URL: waiting for the deployment, passing the URL to CI, seeding data and keeping results trustworthy.