ScreenshotNeo

BlogHow-to

How to Write End-to-End Tests for Websites

Learn to build a focused, maintainable website E2E suite with Playwright: choose workflows, write resilient tests, isolate state, and run checks in CI.

By the ScreenshotNeo team4 October 202611 min read

End-to-end (E2E) tests exercise a website through a browser, often reaching the backend and third-party integrations. They are most useful for checking complete user workflows that matter to release confidence, such as signing in, completing a purchase, or confirming that data persists across screens. They take more setup and maintenance than narrower tests, so start with a small number of important journeys rather than trying to test every page.

This guide uses Playwright Test with JavaScript. It shows how to choose workflows, write user-facing checks, prepare test state, run tests locally and in CI, and investigate failures. The same principles apply to Cypress and other browser testing tools.

1. Choose the workflows that need browser coverage

Start with a user outcome, not a page or component. A useful E2E test follows a cohesive path across the parts of the system that must work together. For example, a checkout test might verify that a user can add an item, enter required details, submit an order, and see a confirmation.

E2E tests complement component, API, and accessibility tests; they do not replace them. Browser-driven tests involve more infrastructure and maintenance. Keep the first suite focused on the workflows whose failure would materially affect users or a release. Cypress describes E2E testing as exercising an app through the browser to its backend and integrations, and discusses the distinct roles of E2E, component, API, and accessibility testing in its testing types guide.

A practical first set

  • Authentication: a user can sign in and reach a protected area; invalid credentials produce a visible error.
  • Primary transaction: a user can complete the key action, such as placing an order or submitting a booking.
  • Persistence: a saved change remains visible after navigating to another screen or reloading.
  • Release smoke path: the minimum high-value journey still works against the release candidate.

Include a meaningful failure outcome where it matters. A form that accepts valid input but silently loses it is not covered by a test that only checks that the submit button can be clicked.

2. Set up Playwright and a predictable app environment

Install Playwright Test in a JavaScript project. The following commands create a basic setup and install the browser binaries:

npm init -y
npm install --save-dev @playwright/test
npx playwright install

Create playwright.config.js in the project root. Adjust the command and URL for the application. Playwright can start the development server for local runs and CI, then reuse an already-running server locally.

// playwright.config.js
const { defineConfig } = require('@playwright/test');

module.exports = defineConfig({
  testDir: './tests',
  fullyParallel: true,
  retries: process.env.CI ? 2 : 0,
  reporter: process.env.CI ? 'html' : 'list',
  use: {
    baseURL: 'http://127.0.0.1:3000',
    trace: 'on-first-retry',
    screenshot: 'only-on-failure',
    video: 'retain-on-failure',
  },
  webServer: {
    command: 'npm run dev -- --host 127.0.0.1',
    url: 'http://127.0.0.1:3000',
    reuseExistingServer: !process.env.CI,
    timeout: 120 * 1000,
  },
});

Add a test script to package.json, or run the equivalent command directly:

{
  "scripts": {
    "test:e2e": "playwright test"
  }
}

Before writing tests, decide how the app obtains repeatable data. A test environment should have known users and records, a predictable backend, and credentials stored in the environment rather than committed to source control. Prefer creating or resetting test data through an API, fixture, or dedicated seed process. Avoid depending on production data or on another test having run first.

3. Write a complete test from navigation to visible outcome

Here is a runnable example for a sign-in workflow. It assumes the app has a login page with labeled email and password fields, and a dashboard heading after successful authentication. Set E2E_EMAIL and E2E_PASSWORD in the test environment to credentials for a dedicated test account.

// tests/sign-in.spec.js
const { test, expect } = require('@playwright/test');

test('a user can sign in and reach the dashboard', async ({ page }) => {
  const email = process.env.E2E_EMAIL;
  const password = process.env.E2E_PASSWORD;
  if (!email || !password) {
    throw new Error('Set E2E_EMAIL and E2E_PASSWORD for the test account');
  }

  await page.goto('/login');
  await page.getByLabel('Email address').fill(email);
  await page.getByLabel('Password').fill(password);
  await page.getByRole('button', { name: 'Sign in' }).click();

  await expect(page).toHaveURL(/dashboard/);
  await expect(page.getByRole('heading', { name: 'Dashboard' })).toBeVisible();
});

The test performs the same meaningful actions a user does and checks the result the user can observe. A successful click alone is not proof that a workflow completed; assert the resulting page, message, or persisted value.

4. Pick locators that survive ordinary UI changes

Use locators that describe controls from a user’s perspective. Playwright recommends role and accessible name, label, and text locators. A test ID can be a deliberate test contract when the visible attributes do not uniquely identify an element. See the official locator guide and best practices.

Use Example When it fits
Role and name page.getByRole('button', { name: 'Save changes' }) Buttons, links, headings, and other controls with a meaningful accessible name.
Label page.getByLabel('Email address') Form fields associated with a label.
Visible text page.getByText('Order confirmed') Content a user should see, especially a confirmation or status.
Test ID page.getByTestId('cart-count') A stable, explicit test hook where user-facing attributes are insufficient.

For example, add data-testid="cart-count" to the count only if there is no sufficiently clear role, label, or text locator. Avoid selectors such as .container > div:nth-child(2) button.primary and long XPath chains: these encode incidental layout and can fail after harmless markup or styling changes. A good locator helps stability, but does not by itself make a test accessible or complete.

5. Wait for conditions, not a guessed amount of time

Playwright checks that an element is actionable before actions such as clicking, and its async assertions retry until the expected condition is met or the timeout is reached. Prefer those behaviors to fixed sleeps. The official writing tests guide explains actions and retrying assertions.

// Wait for the user-visible result; the assertion retries.
await expect(page.getByRole('status')).toHaveText('Changes saved');

// Wait for a specific page state after a user action.
await page.getByRole('link', { name: 'Account settings' }).click();
await expect(page.getByRole('heading', { name: 'Account settings' })).toBeVisible();

Avoid arbitrary waits such as await page.waitForTimeout(3000) as a synchronization strategy. A fixed delay can waste time when the page is fast and still fail when it is slower. If a workflow depends on a particular response or state, wait for that condition and assert the resulting user-visible behavior. Use network events only when the network event itself is part of what the test needs to coordinate; a successful response does not always mean the UI has updated correctly.

6. Isolate tests and handle authentication deliberately

Each test should be runnable by itself. Create the data it needs, avoid order assumptions, and keep cookies and storage isolated. Playwright’s page fixture gives each test its own page context. Shared backend state still needs care: use unique records, reset fixtures, or clean up after a test so parallel tests cannot overwrite each other.

For a test specifically about signing in, exercise the login form as in the example above. For other tests that need an authenticated user, repeating the full login UI flow in every test can add setup time and duplicate coverage. Cypress recommends programmatic login as a best practice for this case. Choose an approved setup mechanism that creates the right authenticated state, and retain a separate browser test for the login workflow itself. Never put real user credentials in source control.

Use separate test accounts or namespaced test records when parallel runs could collide. Make cleanup safe to repeat: a cleanup step should not fail just because a prior attempt already removed the record. For external integrations, use a test account, sandbox, or controlled substitute where possible; do not let a test send real purchases or messages unintentionally.

7. Run the suite in CI and diagnose failures

Run browser tests regularly, ideally on each commit and pull request, as Playwright recommends in its best practices. Install the project dependencies and Playwright browsers in CI, provide test secrets through the CI secret store, start the app server as part of the job, and run the same test command used locally. Linux can be a lower-cost CI environment, though the documentation gives no quantified savings. Cypress likewise advises setting up the app server as part of the test environment rather than trying to start it from Cypress scripts.

# Typical CI steps; adapt to the CI provider and lockfile.
npm ci
npx playwright install --with-deps
npm run test:e2e

Keep CI and local configuration aligned where possible. Set CI=true in the CI environment so the example config enables retries and the HTML reporter. Retries can help collect diagnostics for intermittent failures, but a test that only passes after retries still deserves investigation.

When a run fails, inspect the assertion message and trace first. The example config captures a trace on the first retry, a screenshot on failure, and retains video for failed tests. These artifacts can help distinguish a wrong locator, an unexpected application state, a slow dependency, or a genuine product defect. Keep CI artifacts access-controlled if they might contain private page content or test data.

8. Common mistakes and fixes

Symptom or mistake Likely cause Fix
Test fails after a harmless redesign Locator depends on CSS classes, DOM nesting, or position. Use a role/name, label, or visible text; reserve a test ID for an explicit test hook.
Intermittent timeout after clicking The test assumes the next state is immediate, or the application is waiting on a dependency. Assert the expected URL, heading, status, or content. Inspect the trace and app logs to find the slow or missing transition.
Test passes locally but fails in CI Missing environment variables, browser dependencies, test data, or server startup; local state may also mask an assumption. Provision CI secrets and data explicitly, install browser dependencies, start the server in the job, and reproduce with the CI command.
One test fails only when the full suite runs Tests share or mutate records, cookies, storage, or global state. Make setup independent, give parallel tests distinct data, and remove ordering assumptions.
Adding a longer fixed sleep seems to help The test is synchronized to elapsed time instead of the state it needs. Replace the sleep with an assertion or condition wait. Diagnose why the state is delayed rather than continually increasing the timeout.
Tests are slow because every case logs in through the UI Unrelated workflows repeat authentication setup. Use a suitable programmatic authenticated-state setup for those cases, while keeping a focused E2E test for sign-in.
A browser test is treated as the full accessibility check Successful clicks and selector choices are mistaken for accessibility coverage. Add accessibility-focused checks to the test strategy. Cypress notes that selector techniques alone do not provide complete accessibility coverage.

9. Performance, reliability, and cost

  • Keep browser coverage focused. E2E tests cover valuable integration paths, but they require more infrastructure and maintenance than narrower tests. Cover detailed component behavior at a lower level when a full browser journey is not needed.
  • Control sources of variability. Use repeatable data, known test accounts, stable dependencies, and isolated state. These practices reduce failures caused by one run affecting another.
  • Use parallel runs carefully. Parallelism can shorten elapsed suite time, but shared records or external resources can introduce collisions. Make test data independent before increasing concurrency.
  • Budget for browser installation and artifacts. CI must provision browser dependencies and may store traces, screenshots, and videos. Choose retention and access policies appropriate to your CI environment and the sensitivity of captured data.
  • Pick a framework for the project. Playwright and Cypress both support browser-based E2E work. Compare the browser needs, language and ecosystem fit, local debugging, CI setup, state strategy, and the team’s ability to maintain the infrastructure. The cited documentation does not establish a universal winner or provide a supported speed comparison.

10. A compact checklist for the first suite

  • Each test starts from a user outcome and checks a meaningful workflow.
  • Critical authentication, transaction, and persistence outcomes are covered where relevant.
  • Locators use roles and names, labels, or visible text where possible; test IDs are explicit contracts.
  • Assertions wait for the expected browser state; fixed sleeps are not used to hide timing issues.
  • Test data and authentication setup are repeatable, isolated, and safe under parallel execution.
  • CI starts the application, installs browser dependencies, supplies secrets, and runs the suite regularly.
  • Failure artifacts and logs are available to diagnose a real defect or environment problem.
  • Browser tests complement component, API, and accessibility checks.

Or skip the browser setup

If you need screenshots of pages as part of review or documentation alongside your browser tests, ScreenshotNeo provides a website screenshot API and MCP server for developers. A single GET request can return a PNG, JPEG, WebP, or PDF. See the API documentation.

curl -G "https://api.screenshotneo.com/v1/shot" -d access_key=YOUR_API_KEY --data-urlencode url=https://stripe.com -o shot.webp
import requests

r = requests.get(
    "https://api.screenshotneo.com/v1/shot",
    params={"access_key": "YOUR_API_KEY", "url": "https://stripe.com"},
    timeout=90,
)
open("shot.webp", "wb").write(r.content)
const q = new URLSearchParams({ access_key: 'YOUR_API_KEY', url: 'https://stripe.com' });
const res = await fetch(`https://api.screenshotneo.com/v1/shot?${q}`);
if (!res.ok) throw new Error(`Screenshot request failed: ${res.status}`);
await Bun.write('shot.webp', res);

ScreenshotNeo accepts cookie or consent banners like a visitor and removes more than 60 known consent platforms, newsletter popups, and chat widgets before the shot; each of those steps can be turned off. Bot checks, blank pages, failed loads, timeouts, and cache hits are not billed, and response headers say the page verdict and billing result. Its MCP server gives AI agents tools to take screenshots, get page information, and capture PDFs. The Free plan includes 1,000 screenshots per month without a card; paid plans start at $5 for 3,000 screenshots. Every feature is on every plan.

Create a free ScreenshotNeo account for 1,000 screenshots a month with no card.

FAQ

How many E2E tests should I write first?

There is no useful universal count. Begin with the few workflows whose failure would most affect users or release confidence, then expand when a real coverage gap appears.

Should every test use the UI to log in?

Keep UI login coverage for the authentication workflow. For unrelated tests that need a signed-in user, an approved programmatic state setup can avoid repeating that journey.

Do role-based locators guarantee an accessible website?

No. They make tests target user-facing semantics, but passing locator-based checks does not replace dedicated accessibility testing.

Can E2E tests replace API and component tests?

No. Use browser journeys where the integration and user-visible outcome matter; use narrower checks for behavior that does not need a full browser path.