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.

The four layers of an end-to-end suite Specs sit on top of fixtures, which sit on interaction helpers, which sit on a seeded environment. Each layer only talks to the one below it, so a UI change touches helpers rather than every spec. Specs — one user journey each reads like a story: arrive, act, assert Fixtures — what the test is given logged-in page, seeded org, feature flags Interaction helpers — how the UI is driven selectors and flows live here, nowhere else Environment — the app under test base URL, database, worker-scoped data reads gives drives runs one direction only
Each layer depends only on the layer beneath it, so a redesigned login screen changes one helper instead of ninety specs.

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 });
});
Login once in a setup project, then reuse the saved session A sequence showing the setup project signing in through the UI and writing storage state to disk, after which each spec loads that state and starts on an authenticated page without touching the login form. setup project login form user.json fill + submit storageState() runs once per invocation checkout.spec.ts billing.spec.ts authenticated page no login step, no redirect cookies + storage
One scripted login per run replaces one UI login per spec — usually the largest single saving available to an end-to-end suite.

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.

Shared data versus worker-scoped data under parallel runs Two workers writing to one shared account collide and produce order-dependent failures; the same two workers each owning a namespaced organisation run independently and pass in any order. Shared account — collides worker 1 worker 2 acme-test-org one row, two writers order-dependent failure Worker-scoped — independent worker 1 org-1-1726… worker 2 org-2-1726…
Parallelism is safe only when each worker owns its data; a shared fixture row turns throughput into flakiness.

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