Skip to main content

Overview

An end-to-end (E2E) test drives the application through a real browser and verifies that a user journey completes across the entire system: frontend, backend, and database working together. E2E is the only test layer that verifies the system the way users experience it, but it is also the most expensive to run and maintain. The suite therefore stays small: it covers the primary journeys, reliable enough that a failure always means something. This page explains the principles that keep an E2E suite trustworthy, the patterns for writing stable tests with Playwright, and how to bridge the boundaries with external services. For the general principles of protective test code, see Test Code.

Principles

Cover primary journeys, not every case

E2E tests need a deployed environment, take seconds to minutes each, and can fail for reasons outside the code, such as network conditions, test data, and timing. Every scenario added to the suite raises the cost of a release, so each one must earn its place. Limit the suite to the journeys where breakage immediately hits the business:

Authentication

Sign-in and sign-up. When these break, every user is locked out.

Revenue-critical flows

Checkout, order placement, and other operations that convert directly into revenue.

Smoke over primary screens

Each key screen loads, and its main action completes.
This level of coverage catches what E2E exists to catch: a broken deployment, or a broken integration between parts that were each correct in isolation.

Verify details in lower layers

Anything that does not need a real browser against a real backend belongs in a lower layer. Validation branches, conditional rendering, and component logic are verified faster and more precisely by component tests; E2E only needs to confirm that the parts are wired together correctly. Whether a new scenario belongs in E2E is decided by one question: does this failure only become visible when the whole system runs together? If a component test could catch it, write the component test and leave the E2E suite unchanged.

Run E2E after deployment as a release check

E2E tests verify a deployed environment, so they run after deployment, when a release merge has deployed to the staging or production environment, rather than in the pull request pipeline. The suite acts as the release check: it confirms that the primary journeys work in the environment users are about to use. The scenarios differ between staging and production; how to select them is covered in the operations section below. When the check fails, the result feeds the release decision: halt the rollout or roll back. See Release for how deploys stay easy to reverse. This division keeps pull request feedback fast, because the slowest layer stays off the pull request’s critical path, while every release still receives a whole-system verification. See CI/CD for the pipeline design this fits into.

Fix or quarantine flaky tests immediately

A test that fails intermittently without a real defect is a source of false positives: it trains the team to re-run failures instead of reading them, until a real breakage is re-run and ignored as well. In the release pipeline this is fatal, because a failing E2E run must retain the power to stop a release. When a test turns flaky, fix it or quarantine it the day it is noticed. To quarantine, take the test out of the release check with test.fixme() and file the fix as a work item, so the rest of the suite stays trustworthy while the cause is investigated.
Playwright
The patterns in the next section prevent the most common causes. The trust mechanics are the same as in Test Code: passing and failing must both stay trustworthy.

Practices with Playwright

The first three patterns address the main sources of E2E instability; the last two keep the cost of the suite flat as screens and roles grow.

Locate elements the way users find them

CSS classes and DOM structure are implementation details: they change during styling or refactoring without any change in behavior, and every such change breaks the test. That is a false positive. Locate elements by what users perceive: role, accessible name, and label.
Playwright
When an element has no stable accessible name, prefer adding one (a label or aria-label) over falling back to getByTestId: it fixes the test and the accessibility gap at once.

Wait for state, not for time

A fixed sleep is a guess at how long the system takes, and the guess fails in both directions. Too short, and the test fails under load while the system is correct. Long enough to be safe, and the suite slows down on every run. Playwright’s web-first assertions retry automatically until the expected state appears, so the test waits exactly as long as needed.
Playwright

Give each test its own data

Tests that read state left behind by other tests fail in cascades: they cannot run alone, in parallel, or in a different order, and one broken test takes its dependents down with it. Each test prepares the account and records it needs, and asserts only on what it created.
Playwright
Make test data recognizable as test data: a fixed prefix combined with a unique suffix such as a timestamp. The unique suffix prevents collisions between parallel runs, and the recognizable prefix makes leftover data safe to clean up. The external services section below covers how to prepare this data through a test-support API rather than the UI.

Set up authentication once and reuse it

Signing in through the UI at the start of every test is slow and concentrates failures: when the login screen changes, every test in the suite fails at once. Playwright separates authentication from scenarios: a setup project signs in once per role, saves the authenticated state to a file with storageState, and every test starts already signed in.
Playwright
Prepare one test account per role (administrator, manager, staff) and run the setup for each. The sign-in flow itself stays covered, because the setup exercises it for every role on every run.

Generate the screen-by-role smoke from data

The smoke check from the principles, where every primary screen loads for every role, is a matrix, and writing each cell by hand does not scale. Define the screens once as data, together with the roles allowed to see each, and generate the tests in a loop.
Playwright
The same table verifies both directions: an allowed role sees the screen’s heading, and a disallowed role lands on the fallback instead of the protected content. Adding a screen or a role is a one-line change, and an access-control regression (a screen exposed to the wrong role) fails the same suite that checks rendering.

Bridging external services

A journey that leaves the browser, such as third-party sign-in, email delivery, or two-factor authentication, cannot be driven by the browser test alone, and depending on the live external service makes the run nondeterministic. The test environment bridges these boundaries with two mechanisms: stub APIs that stand in for the external service behind the backend, and test-support APIs that let the test prepare and observe state it cannot reach through the UI. This is the E2E form of the principle in Test Code: mock the external boundary, never the logic under test. In E2E the whole backend is the logic under test, so the boundary to fix sits behind it, at the edge where the system calls services the team does not control.

Prepare and observe state through a test-support API

Some state a scenario needs cannot be created or read through the UI: a fresh organization for a signup flow, a one-time password for two-factor authentication, the body of an email the application just sent. Expose these as test-support endpoints in the test environment: creating and cleaning up test data, issuing codes, and reading delivered mail. With these in place, the scenario crosses the boundary without manual steps. Effects that happen outside the browser, such as an email arriving, are beyond the reach of web-first assertions, so waiting for state takes a different form here: poll with a bounded timeout instead of sleeping a fixed time.
Playwright

Keep stubs and test-support APIs out of production

Test-support endpoints are powerful by design: they create accounts, issue credentials, and read mail. That power makes them dangerous anywhere users can reach.
Require authentication on every test-support endpoint, serve them from a namespace separate from the public API, and enable them only in test environments. A test-support endpoint reachable in production is a security incident waiting to happen.
The same isolation applies to scenarios: journeys that create data through these endpoints run against staging, not production. The operations section below covers how to encode that restriction.

Watch for drift between stubs and the real service

A stub is a copy of the external service’s contract, and copies drift: the provider changes a response shape or an authentication flow, the stub keeps answering the old way, and the suite passes while the real integration is broken. That is a false negative. A stub that is too permissive hides exactly the failures the integration exists to prevent. Two defenses work together. Keep each stub thin: implement only the endpoints and fields the application actually reads, so there is less surface to drift. And keep a small number of scenarios that exercise the real integration against the staging environment, where real external-service accounts can be used safely. The real-integration scenarios catch drift; the stubs keep the rest of the suite deterministic.

Operating the suite in the release pipeline

Select scenarios per environment

The same suite does not need to run everywhere in full. Scenarios that create data or call external services run against staging; production runs the read-only smoke: screens render, and journeys without side effects complete. Encode the restriction in the test itself rather than in pipeline configuration, so the rule survives pipeline changes.
Playwright

Investigate failures with traces

A failure in a deployed environment is hard to reproduce locally, so collect the evidence at run time. Playwright’s trace records every action together with screenshots, console output, and network activity; configure the pipeline to keep traces for failed runs, and a failed run becomes a recording to inspect rather than a mystery to reproduce. Structuring long scenarios with test.step pays off here: the trace shows which step failed, not just which line.

Treat a retry pass as a failure signal

Automatic retries separate transient infrastructure issues from persistent breakage, and they are worth enabling against a deployed environment. A test that passes on retry, however, is not a success: it is flaky, and it goes on the fix list. Counting retry passes as success hides exactly the instability that erodes trust in the suite.

Keep the suite fast enough to gate releases

The suite guards every release, so its runtime sits on the release’s critical path. Run tests in parallel, which the per-test data isolation makes safe, and hold the line on suite size: when a new scenario arrives, first check whether an existing scenario already covers the journey, or whether a lower layer could verify it instead.

Test Code

The principles of protective tests: trustworthy passing and failing, and mocking only at boundaries.

CI/CD

The pipeline that runs each test layer, and the automated deploys that trigger the E2E suite.

Release

Separating deploy from release, and choosing what a release check protects.