E2E Testing in Practice: Playwright
Unit tests validate individual units and integration tests validate combinations, but only E2E can answer whether users can actually use the product. This article covers Playwright's core capabilities, Page Objects, CI integration, and what E2E should and should not test.
Key Points
- Topic Focus: React、TypeScript
- Suitable for: indie hackers, full-stack engineers, AI application developers
- Core value: quickly understand background, practical paths, common pitfalls, and engineering advice
The testing pyramid has been discussed for a decade, yet most projects stop at integration testing as they move up it. Unit tests mock everything, and integration tests only cover combinations of components. Only E2E can truly answer, "Can a user click that button in Chrome and complete checkout?" The problem is that E2E has always been criticized for being slow, expensive, flaky, and difficult to maintain. Half of that criticism is justified; the other half comes down to inadequate tools.
I have used Selenium, Cypress, TestCafe, and Playwright. Selenium dominated the previous generation, but its API is verbose and its waits involve guesswork. Cypress has an elegant architecture but supports only Chromium, and its commercial features cost money. TestCafe remains niche. After Microsoft launched Playwright in 2020, the field largely consolidated around it. For a new project choosing an E2E tool today, Playwright is the default answer. This article explains why and how to use it reliably.
1. Why E2E Is Necessary: The Layer That Answers "Can Users Actually Use It?"
From bottom to top, the testing pyramid consists of unit tests, integration tests, and E2E tests. Each layer answers a different question.
- Unit tests: Given an input, does this function produce the correct output? They are fast, isolated, and the most numerous.
- Integration tests: When these modules are combined, does data flow through them correctly? They run at a moderate speed and exist in moderate numbers.
- E2E tests: Can a real user complete a task by following the entire workflow in a real browser? They are slow, expensive, and the least numerous.
Consider a checkout flow. Unit tests can verify whether the cart calculates the correct amount, and integration tests can verify whether the cart component works with the API. But only E2E can catch the following problems:
- After the user logs in, the token is written to a cookie. However, the cookie is
Securewhile your staging environment uses HTTP, so the browser rejects it and sends the user back to the login page after every refresh. - The payment button's onClick triggers fetch, but a toast with a higher z-index covers the button and prevents it from being clicked.
- The router uses
window.history.pushState, but SSR fallback is not configured, so visiting/orders/123directly returns a 404. - A third-party analytics script loads slowly and blocks an
awaitin the main flow, causing checkout to hang.
These bugs have one thing in common: every individual module works, but the combination breaks in a real environment. Unit tests cannot find them because cookies, the network, and browser behavior are all mocked. E2E removes this entire layer of mocks and runs real workflows in a real browser, catching a whole category of "real integration-layer problems."
The costs are equally clear: E2E is slow (milliseconds for unit tests versus seconds for E2E), expensive (CI must run browsers), and prone to flakiness (the network, animations, and timing are all variables). The right approach is therefore to use a small number of precise E2E tests to protect critical paths and many unit tests to protect boundary logic. E2E does not replace unit testing; it fills the layer that unit tests cannot reach.
2. Playwright's Core Advantages: Why It Consolidated the Market
Playwright is built by Microsoft, with many team members coming from the original Puppeteer team. Since its 2020 release, it has used developer experience as its primary advantage. Here are its core strengths, in order of importance:
1. Cross-browser support: one codebase for all three engines. Chromium, Firefox, and WebKit are fully supported through an identical API. Safari has always been the hardest browser to test because it uses WebKit. In the past, your options were an expensive BrowserStack subscription or a self-hosted Mac farm. Playwright turns WebKit testing into a single local command. Note that WebKit ≠ Safari: Playwright uses the open-source WebKit build, which differs slightly from the actual Safari browser, but it can still catch 95% of compatibility problems.
npx playwright install chromium firefox webkit
2. Auto-waiting eliminates sleep. This is Playwright's most underrated feature. When you write await page.click('#submit'), it does not click immediately. It first waits until the element is actionable (attached to the DOM, visible, stable, and able to receive events), for up to 30 seconds. You can completely discard black magic such as sleep(1000). Habitually adding cy.wait(500) is the root cause of many flaky Cypress tests.
// Playwright waits automatically; no sleep required
await page.click('#submit'); // Wait until the button can be clicked
await page.waitForResponse('/api/login'); // Explicitly wait for an endpoint
await expect(page.locator('.welcome')).toBeVisible(); // Assertions also wait
3. Network interception makes API mocking a first-class feature. page.route() lets you intercept any request at the browser layer to change its status or body, delay it, or abort it outright. This means E2E tests do not have to depend on a real backend. You can test even when the backend is unavailable or when you need to exercise an error path.
// Intercept the login endpoint and simulate a failure
await page.route('**/api/login', async (route) => {
await route.fulfill({
status: 401,
contentType: 'application/json',
body: JSON.stringify({ error: 'invalid credentials' }),
});
});
// Simulate an endpoint timeout to test the loading state and timeout handling
await page.route('**/api/orders', (route) =>
new Promise((resolve) => setTimeout(() => resolve(route.abort()), 5000))
);
4. Parallel execution keeps it fast. Playwright runs test files in parallel worker processes, using one worker per CPU core by default. A suite of 60 tests might take five minutes serially but only a minute and a half with four workers. With test.describe.configure({ mode: 'parallel' }), tests can also run in parallel within a file.
5. Trace Viewer is a flaky-test killer. When a test fails, Playwright can record a complete execution trace: DOM snapshots, network requests, console logs, screenshots, and video for every step. Open it with npx playwright show-trace trace.zip and scrub through the timeline like a recording to identify the root cause of flakiness. This tool reduced the time I spend debugging E2E tests by 70%.
There are several additional advantages: codegen (npx playwright codegen records interactions and generates code automatically), clearly prioritized locator semantics, built-in accessibility selectors, fixtures that make setup and teardown elegant, and increasingly mature component testing. In short, it offers the best overall experience of any E2E tool from the past five years, without exception.
3. Getting Started: Run Your First Test in Five Minutes
npm init playwright@latest
# Choose TypeScript, put tests in the tests directory, and install browsers
The scaffold generates playwright.config.ts, creates an example test, and installs all three browsers. Its configuration file is the most intuitive I have seen in any tool:
import { defineConfig, devices } from '@playwright/test';
export default defineConfig({
testDir: './tests',
fullyParallel: true, // File-level parallelism
forbidOnly: !!process.env.CI, // Disallow test.only in CI
retries: process.env.CI ? 2 : 0, // Retry twice in CI, never locally
workers: process.env.CI ? 2 : undefined, // Limit workers in CI
reporter: process.env.CI
? [['github'], ['html', { open: 'never' }]]
: 'list',
use: {
baseURL: 'http://localhost:3000',
trace: 'on-first-retry', // Record a trace only on the first retry to save space
screenshot: 'only-on-failure',
video: 'retain-on-failure',
},
projects: [
{ name: 'chromium', use: { ...devices['Desktop Chrome'] } },
{ name: 'webkit', use: { ...devices['Desktop Safari'] } },
{ name: 'firefox', use: { ...devices['Desktop Firefox'] } },
{ name: 'mobile', use: { ...devices['iPhone 15'] } },
],
webServer: {
command: 'pnpm dev',
url: 'http://localhost:3000',
reuseExistingServer: !process.env.CI,
},
});
I consistently follow several configuration practices:
- Use
trace: 'on-first-retry', not'on'. Trace files are sizable, and recording every run consumes too much disk space. Recording only on the first retry provides evidence when a test is flaky without wasting storage. - Configure
retriesdifferently for local development and CI. Local retries hide problems, while CI retries can filter out genuine flakes. This distinction matters: while writing tests locally, disable retries so bugs surface immediately. - Use
webServerto start the application with the tests. It starts the service withpnpm dev, waits until theurlis reachable, and then runs the tests, requiring no extra CI configuration. Locally,reuseExistingServerreuses an already-running development server so you can rerun tests immediately after changing code. - Use projects for browsers and devices. The same test runs multiple times, instantly multiplying coverage. It also multiplies the cost, so I am selective in CI: pull requests run one Chromium project, while nightly jobs run the full set.
The first test:
import { test, expect } from '@playwright/test';
test('user can log in', async ({ page }) => {
await page.goto('/login');
await page.getByLabel('Email').fill('[email protected]');
await page.getByLabel('Password').fill('correct-password');
await page.getByRole('button', { name: 'Log in' }).click();
await expect(page).toHaveURL('/dashboard');
await expect(page.getByText('Welcome back')).toBeVisible();
});
Several details matter here. getByLabel and getByRole are semantic selectors and have the highest priority; the reason is explained below. Both fill and click include auto-waiting, so no waitFor is needed. The expect assertion also retries automatically until it passes or times out. The entire test contains no sleep, which is the foundation of its stability.
4. The Page Object Pattern: Keep Tests Out of "Copy-and-Paste Hell"
Without architecture, test code eventually decays. The first test looks clean, but by the tenth test you will notice that each one repeats "enter email, enter password, click login, wait for navigation." One day the login page UI changes and the password field moves from getByLabel('Password') to getByPlaceholder('Enter your password'). You then have to edit ten files. This is the problem the Page Object pattern solves.
The core idea is to encapsulate what each page looks like in a class so tests express only what to do. When the page changes, update only the Page Object. When the workflow changes, update only the test.
// pages/LoginPage.ts
import { Page, Locator, expect } from '@playwright/test';
export class LoginPage {
readonly page: Page;
readonly emailInput: Locator;
readonly passwordInput: Locator;
readonly submitButton: Locator;
readonly errorMessage: Locator;
constructor(page: Page) {
this.page = page;
this.emailInput = page.getByLabel('Email');
this.passwordInput = page.getByLabel('Password');
this.submitButton = page.getByRole('button', { name: 'Log in' });
this.errorMessage = page.getByRole('alert');
}
async goto() {
await this.page.goto('/login');
}
async login(email: string, password: string) {
await this.emailInput.fill(email);
await this.passwordInput.fill(password);
await this.submitButton.click();
}
async expectError(message: string) {
await expect(this.errorMessage).toContainText(message);
}
}
The test code immediately becomes cleaner:
import { test } from '@playwright/test';
import { LoginPage } from '../pages/LoginPage';
test('login succeeds', async ({ page }) => {
const login = new LoginPage(page);
await login.goto();
await login.login('[email protected]', 'correct-password');
await expect(page).toHaveURL('/dashboard');
});
test('an incorrect password displays an error', async ({ page }) => {
const login = new LoginPage(page);
await login.goto();
await login.login('[email protected]', 'wrong-password');
await login.expectError('Incorrect email or password');
});
A few rules for Page Objects:
- A Page Object should encapsulate only "operations the user can see," not the details of assertion logic. However, page-level state assertions such as "the user should be redirected to the dashboard after logging in" can reasonably live in a Page Object. My boundary is: business assertions belong in tests, while UI state assertions may live in Page Objects.
- Use business language for method names (
login,addToCart,checkout), not technical language (fillEmailInput,clickSubmitButton). Tests should read like descriptions of business workflows. - Do not turn a Page Object into a God Object. One class should represent one page or one clearly defined page region. Split checkout into
CartPage,ShippingPage,PaymentPage, andConfirmationPageinstead of putting everything into oneCheckoutFlow, which will grow until it becomes unmaintainable. - Use helpers or fixtures for cross-page workflows. For example, many tests need an authenticated state. Injecting a "pre-authenticated page" through a fixture is much faster than making every test repeat the entire login flow.
Playwright's official guidance now favors a more "lightweight" style: use locators directly in tests and do not require Page Objects for small projects. My rule is: adopt Page Objects once you exceed 20 tests. Otherwise, duplicated code will come back to hurt you.
5. What to Test and What Not to Test: Defining the E2E Boundary
This is the easiest part to get wrong. E2E is slow and expensive, and testing the wrong things is worse than not testing at all. It wastes CI time while creating a false sense of security. I have one hard rule: E2E should test only critical paths related to money and trust.
What to test: the happy paths with the highest user value:
- Registration: Can users complete registration, receive a verification email, and activate their account?
- Login/logout: This is the core identity workflow. One outage can trigger three times as many complaints.
- Checkout/payment: This directly affects revenue. If this path is down for an hour, the loss is visible.
- Core business actions: Publishing an article, submitting an order, or uploading a file: the action for which the product exists.
- Critical error paths: Payment failures, network disconnections, and insufficient permissions. Users encounter these frequently, and a poor experience directly causes churn.
What not to test: leave these to unit or integration tests:
- CSS details and visual regression: Whether a button has a 4px border radius or the correct color belongs in Visual Regression Testing (Playwright also provides toHaveScreenshot) or Storybook + Chromatic. Visual assertions in E2E are slow and flaky.
- Pure function logic: Amount calculations, date formatting, and form validation rules belong in unit tests, which run in milliseconds.
- Internal component state: React state changes and prop propagation are adequately covered by component tests (Playwright component testing or Testing Library). Using E2E here is overkill.
- Third-party library behavior: You do not need to test whether React Router can route or axios can issue a request. That is the library's responsibility.
As a reference point, I usually keep a SaaS application's E2E suite at 20-50 tests covering 5-10 core paths. Each test takes 5-15 seconds, and the complete suite takes 2-5 minutes. Beyond that scale, you are probably testing things that do not belong in E2E or have redundant test designs. Remove what should be removed. An E2E suite is like a garden: without pruning, it fills with weeds.
Another antipattern deserves attention: do not pursue coverage metrics with E2E tests. E2E coverage is naturally lower than unit-test coverage because it follows user paths rather than code paths. Treating E2E as a coverage tool pressures teams to write many low-value tests for edge-case code. The metric for E2E is whether critical paths are protected, not line coverage.
6. CI Integration: Make E2E Run on Every Commit
E2E tests that are not in CI might as well not exist. Running locally is not enough. What matters is whether they run automatically on pull requests and whether evidence is available when they fail. GitHub Actions + Playwright is the standard setup:
name: E2E
on:
pull_request:
push:
branches: [main]
schedule:
- cron: '0 4 * * *' # Run the full suite at 4 a.m. daily to detect flaky tests
concurrency:
group: e2e-${{ github.ref }}
cancel-in-progress: true
jobs:
e2e:
runs-on: ubuntu-latest
timeout-minutes: 30
steps:
- uses: actions/checkout@v4
- uses: pnpm/action-setup@v4
with: { version: 9 }
- uses: actions/setup-node@v4
with:
node-version: '20'
cache: 'pnpm'
- run: pnpm install --frozen-lockfile
# Important: cache browser binaries; 30 seconds initially, 3 seconds afterward
- name: Cache Playwright browsers
uses: actions/cache@v4
with:
path: ~/.cache/ms-playwright
key: playwright-${{ runner.os }}-${{ hashFiles('pnpm-lock.yaml') }}
- name: Install browser binaries
run: pnpm exec playwright install --with-deps chromium
- name: Run E2E
run: pnpm exec playwright test --project=chromium
# Upload artifacts on failure: HTML reports, traces, videos, and screenshots
- name: Upload report
if: ${{ !cancelled() }}
uses: actions/upload-artifact@v4
with:
name: playwright-report
path: playwright-report/
retention-days: 7
- name: Upload traces
if: ${{ failure() }}
uses: actions/upload-artifact@v4
with:
name: playwright-traces
path: test-results/
retention-days: 3
Several lessons from running E2E in CI:
- Cache browser binaries.
playwright installdownloads more than 100 MB of browser binaries. Downloading them on every run without a cache is slow and wastes bandwidth. Tie the key to the lockfile so the cache is automatically invalidated when Playwright is upgraded. - Run only Chromium on pull requests and the full matrix nightly.
--project=chromiumoffers the best cost-benefit ratio for pull requests. Put Firefox, WebKit, and mobile into a scheduled cron job that runs once a day and catches flaky tests and compatibility problems overnight. - Upload reports with
if: ${{ !cancelled() }}. Upload the HTML report even if tests fail. Developers can open the artifact to see visual results and screenshots from every failed step. Upload traces and videos separately withfailure()because they are the core debugging evidence. - Always add
timeout-minutes: 30. A flaky test or hung environment can cause E2E to run indefinitely. A hard limit prevents it from consuming the entire CI quota. - Use native GitHub failure notifications. A failed
check_runautomatically appears on the pull request, so there is no need to add Slack webhooks unless the team does not monitor GitHub.
If the team uses Vercel Preview deployments, there is a more elegant option: instead of running a local development server on a pull request, wait for Vercel to finish deploying the preview URL and run E2E directly against that URL. This tests an environment much closer to production and eliminates differences caused by the local development server. A script can dynamically inject the pull request's preview URL into Playwright's baseURL.
7. Stability: Six Rules for Flake-Free E2E
Flakiness is E2E's original sin. A test that passes only sometimes is worse than no test at all. It causes the team to lose trust in CI, makes people habitually ignore red status checks, and ensures that no one responds when a real problem appears. I have distilled years of reducing flakiness into six rules:
1. Selector priority: data-testid > semantics > CSS > text. Playwright's official priority order exists for good reasons:
// Best: data-testid is decoupled from implementation and exists specifically for tests
page.getByTestId('checkout-button')
// Second choice: semantic roles support accessibility and encourage good HTML
page.getByRole('button', { name: 'Checkout' })
// Next: label, placeholder, or alt
page.getByLabel('Email')
// Avoid: CSS selectors break during UI refactoring
page.locator('.btn-primary.large.checkout')
// Worst: raw text breaks as soon as localization changes
page.locator('text=Checkout')
CSS selectors are a major source of flakiness because frontend className changes are common and immediately break tests. Text selectors are a time bomb in multilingual projects. data-testid may look "ugly," but it is the cleanest contract between tests and business code. Use it when appropriate instead of sacrificing test stability for "clean code."
2. Never write waitForTimeout. page.waitForTimeout(1000) is the API that should be most strictly prohibited in E2E. It is fundamentally a sleep: either too short, causing flakiness, or too long, wasting time. The correct approach is to wait for events: wait for an element to appear, a request to finish, or a URL to change. Playwright's auto-waiting already covers 80% of cases. For the remaining 20%, use explicit mechanisms such as waitForResponse, waitForURL, and expect().toBeVisible().
// Bad: fixed waiting is both slow and flaky
await page.waitForTimeout(2000);
await page.click('#next');
// Good: wait for the endpoint response; event-driven
await Promise.all([
page.waitForResponse('**/api/data'),
page.click('#next'),
]);
Wrapping the click and waitForResponse in Promise.all is a classic pattern. The click triggers the request, and execution continues only after the response arrives, avoiding both missed waits and unnecessary waiting.
3. Isolate tests and never share state. Every test must run independently, in any order, and in parallel. By default, Playwright gives each test a fresh browser context, equivalent to an incognito window, so cookies and localStorage are automatically isolated. But manually placing a token in localStorage or using a shared global variable breaks that guarantee. If a test suite "must run in order to pass," its isolation is broken, and that suite will eventually become flaky.
4. Mock external dependencies, but do not mock everything. Third-party APIs such as SMS, payment, and email services are uncontrollable in CI and must be mocked; if they fail, your CI should not fail with them. However, do not mock your own backend, or you will be testing a fake system. Intercept third parties with Playwright's page.route() while sending requests to your own APIs through the real stack. That is the ideal balance.
test.beforeEach(async ({ page }) => {
// Intercept all third-party services
await page.route('**/segment.io/**', (route) => route.fulfill({ status: 200, body: '' }));
await page.route('**/sentry.io/**', (route) => route.fulfill({ status: 200, body: '' }));
// Do not intercept first-party APIs; use the real stack
});
5. Retries are medicine, not food. retries: 2 can filter genuine flakes in CI, but retries must never become an excuse for poorly written tests. Whenever a retry is used, ask yourself: What is the root cause of this flake? It is probably a waitForTimeout that should be replaced by an event-based wait or an unstable selector. Retries are painkillers: take them when necessary, but do not become dependent on them. Long-term use hides the underlying condition.
6. Always investigate flaky tests. Whenever a flake appears, either fix the test by improving its selector or waiting strategy, or fix the product by resolving a race condition or adding a loading state. Playwright's Trace Viewer is invaluable for investigations. Download trace.zip after a failure and scrub through the timeline to inspect the DOM and network activity at every step. Eight times out of ten, the root cause is immediately obvious. If you leave flaky tests unresolved, your CI will be useless within six months.
One more note about "test data": an E2E suite using the real stack must prepare its data. The cleanest approach is for each test to set up its own test data, such as creating a test user or order through an API, and tear it down afterward. Playwright fixtures combined with test.extend are ideal for this. Sharing one "test account" causes tests to interfere with one another and produces catastrophic flakiness under parallel execution.
Summary
- E2E tests the layer unit tests cannot reach: Unit tests validate units and integration tests validate combinations, but only E2E catches "real environment" problems involving cookies, networks, browser behavior, and SSR. It supplements rather than replaces other tests: use a small, precise set to protect critical paths and many unit tests to protect boundaries.
- Playwright's market leadership is no accident: It provides cross-browser support (one codebase for Chromium, Firefox, and WebKit), auto-waiting (no more sleep), network interception (first-class API mocking), parallel execution, and Trace Viewer (a flaky-test killer). Its overall experience currently has no rival.
- Page Objects provide the architecture that keeps E2E maintainable: Encapsulate what pages look like in classes so tests express only what to do. Adopt them beyond 20 tests, name methods in business language, and use fixtures to inject authenticated state into cross-page workflows.
- What to test matters more than how to test it: E2E should protect only "money and trust": registration, login, checkout, core business actions, and critical error paths. CSS details, pure functions, and internal component state belong in unit tests. Keep E2E suites at 20-50 tests and measure critical-path coverage, not line coverage.
- Stability comes from discipline, not tools: Follow the selector priority
data-testid > semantics > CSS > text, never writewaitForTimeout, strictly isolate tests, mock third parties but not your own backend, treat retries as medicine rather than food, and always investigate flakes.
One final sentence: The value of E2E testing is not in having many tests, but in ensuring that every test follows a real user journey and provides evidence and a trace when it fails so the problem can be diagnosed. One hundred flaky E2E tests are worth less than ten stable ones. Stability is the only currency in E2E; without it, the entire test suite is just noise.
Related
Related Content
Article
Is Next.js App Router a Good Fit for a Technical Community?
Technical trends, engineering practices, and independent development perspectives centered on Next.js and React.
Article
SEO Design for a Content Community with Next.js
Technical trends, engineering practices, and an indie developer's perspective on Next.js and TypeScript.
Article
The Engineering Value of TypeScript in AI Application Frontends
Technical trends, engineering practices, and an independent developer perspective on TypeScript and React.
Article
What Value Do Server Components Offer Content Sites?
Technical trends and engineering practices around Next.js and React, from an independent developer's perspective.