How to Write End-to-End Tests
Most E2E test suites fail not because the framework is inadequate but because the tests themselves are poorly designed. Brittle selectors, shared state between tests, hardcoded waits, and unclear test boundaries cause the majority of maintenance problems. Following a disciplined approach from the start prevents these issues and keeps the suite productive over months and years of development.
Step 1: Identify Critical User Flows
Before writing any test code, list the user journeys that matter most to your application. These are the paths that, if broken, would directly impact revenue, user trust, or core functionality. For an e-commerce application, the critical flows typically include account registration, login, product search, adding items to cart, checkout with payment, and order confirmation. For a SaaS product, they might be onboarding, core feature usage, billing, and account management.
Prioritize flows by business impact rather than technical complexity. A simple login flow that every user hits is more important to test than a complex admin dashboard used by three people. Aim to identify 10 to 20 critical flows for your first E2E suite. You can always add more tests later, but starting with the highest-value flows ensures the suite provides meaningful protection from day one.
Document each flow as a brief user story: "As a returning customer, I log in, search for a product, add it to my cart, and complete checkout with a saved credit card." This documentation becomes the specification for your test cases and helps non-technical stakeholders understand what the tests cover.
Step 2: Set Up Test Infrastructure
A reliable test suite needs a stable environment to run against. This means a dedicated test instance of your application with its own database, configured with known data and isolated from production traffic. The environment should be reproducible, so that any developer or CI machine can spin it up and get identical results.
Docker Compose is the most common approach for local and CI test environments. Define services for your application server, database, and any dependencies (Redis, message queues, mock services) in a single compose file. Each test run starts with a fresh environment, eliminating state leakage between runs.
Configure your test framework's reporter to generate artifacts that help with debugging. At minimum, capture screenshots on failure and generate an HTML report. For CI runs, also enable video recording or trace capture so that developers can diagnose failures without having to reproduce them locally.
Step 3: Choose Stable Selectors
Element selectors are the single most important factor in test reliability. A selector that breaks when a CSS class is renamed or a div wrapper is added forces you to update the test even though the application's behavior has not changed. The most resilient selector strategies, in order of preference, are:
Accessibility roles and labels: Use getByRole('button', { name: 'Submit' }) or getByLabel('Email address'). These selectors reflect how screen readers and assistive technologies identify elements, which means they verify accessibility as a side effect of testing functionality.
Data-testid attributes: Add data-testid="checkout-button" to elements that are difficult to select by role or text. These attributes exist solely for testing, are never modified during styling changes, and clearly signal to other developers that the element is referenced in tests.
Text content: Use getByText('Add to Cart') for buttons and links whose visible text is stable. This approach breaks if the copy changes, but for core UI elements with established labels, text selectors are intuitive and readable.
Avoid using CSS class selectors (.btn-primary), DOM hierarchy selectors (div > ul > li:nth-child(3)), or auto-generated IDs. These are implementation details that change frequently and have no semantic meaning in the test.
Step 4: Structure Tests with Page Objects
The page object model (POM) encapsulates all selectors and interactions for a specific page or component in a single class or module. Instead of writing page.getByTestId('email-input').fill('user@example.com') in every test that involves the login form, you create a LoginPage class with a login(email, password) method. When the login form changes, you update one class instead of every test that touches login.
A well-designed page object exposes actions (login, addToCart, submitForm) rather than raw selectors. The test reads like a user narrative: loginPage.login('user@example.com', 'password') followed by dashboardPage.verifyWelcomeMessage('Welcome, User'). This abstraction makes tests easier to read, write, and maintain.
Keep page objects focused. One page object per page or major component. Do not create a single "BasePage" with hundreds of methods. If two pages share common elements (like a navigation bar), extract those into a separate component object that the page objects compose.
Step 5: Write the Test Cases
Each test case should follow the Arrange, Act, Assert pattern. Arrange sets up the test state (navigate to the page, log in, seed data). Act performs the user actions being tested (fill out a form, click buttons, navigate between pages). Assert verifies the outcome (check URL, verify displayed text, confirm an API response).
Write tests from the user's perspective. Describe what the user does and what they see, not what the code does internally. A good test name reads like a capability statement: "customer can complete checkout with a credit card" or "user sees an error when submitting an empty form." Avoid technical implementation names like "test POST /api/orders endpoint."
Each test must be independent. It should not rely on state created by a previous test, and it should clean up after itself. If test B depends on test A creating a user account, then test B will fail when run in isolation or when test A fails. Instead, test B should create its own user account (preferably through an API shortcut rather than the UI) as part of its arrange phase.
Step 6: Manage Test Data
Every E2E test needs data to work with: user accounts, products, orders, configuration settings. The two main strategies for test data are database seeding (loading a known dataset before each run) and API-based creation (generating data through the application's API at the start of each test).
Database seeding works well for data that every test needs, like product catalogs or configuration records. Use migration scripts or fixture files that the CI pipeline loads before starting the test suite. API-based creation works better for test-specific data, like a unique user account for each test case. Creating data through the API ensures it passes through the same validation logic that production data does.
Generate unique identifiers for test data to avoid collisions when tests run in parallel. Append timestamps or random strings to usernames, email addresses, and other unique fields. This ensures that two tests creating user accounts simultaneously do not conflict with each other.
Step 7: Handle Asynchronous Behavior
Web applications are inherently asynchronous. Clicking a button might trigger an API call that takes 200 milliseconds or 5 seconds depending on network conditions. The test needs to wait for the result without waiting too long or not long enough.
Modern frameworks like Playwright and Cypress handle most waiting automatically. Playwright's auto-waiting pauses until an element is visible, stable, and actionable before performing an action. Cypress's assertion retry mechanism keeps checking a condition until it passes or times out. Use these built-in mechanisms instead of adding explicit sleep statements.
For operations that involve network requests, wait for the specific request to complete rather than using a fixed delay. In Playwright, use page.waitForResponse() to pause until a particular API endpoint responds. In Cypress, use cy.intercept() with cy.wait('@alias') to wait for a named request. These approaches are both faster (they proceed as soon as the response arrives) and more reliable (they are not affected by network speed variations).
Step 8: Review and Refine
After writing your initial tests, run the full suite at least 10 times consecutively. Any test that fails intermittently during these runs has a reliability problem that needs to be fixed before the test is merged. Common causes include missing waits for network responses, selectors that match multiple elements, and test data that collides between parallel workers.
Review test code with the same rigor as production code. Look for duplicated selectors that should be extracted into page objects, hardcoded values that should be constants or fixtures, and tests that verify implementation details instead of user-visible behavior. Well-maintained test code pays dividends every time the application changes.
Track your test suite's reliability over time. Most CI platforms can report test pass rates by individual test case. Any test that fails more than 1% of the time without a corresponding application bug is a flaky test that undermines confidence in the entire suite. Fix or quarantine flaky tests immediately rather than letting the team develop a habit of re-running failures.
Reliable E2E tests are built on stable selectors, independent test cases, deterministic test data, and framework-native waiting mechanisms. Invest in page objects and test infrastructure early, and treat test code maintenance as a first-class engineering responsibility.