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.
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 withtest.fixme() and file the fix as a work item, so the rest of the suite stays trustworthy while the cause is investigated.
Playwright
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
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
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 withstorageState, and every test starts already signed in.
Playwright
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
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. 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 withtest.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.Related pages
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.