
If your web application ships features without automated browser testing, broken user flows will eventually reach production. End-to-end testing with Playwright gives you fast, reliable browser automation that catches issues unit tests and integration tests miss entirely. This guide walks through setting up Playwright from scratch, writing maintainable test patterns, and integrating everything into your CI pipeline. Whether you’re testing a React dashboard, a Next.js app, or any web application, you’ll have a working test suite and patterns that scale beyond a handful of tests.
What Is Playwright?
Playwright is an open-source browser automation framework developed by Microsoft that supports Chromium, Firefox, and WebKit from a single API. Unlike Selenium, Playwright was built specifically for modern web applications and provides auto-waiting, network interception, and native support for multiple browser contexts. As a result, tests written with Playwright tend to be faster and less flaky than those written with traditional E2E testing tools. Additionally, Playwright runs tests in parallel by default and includes built-in tracing for debugging failures, which makes it a strong choice for teams that need confidence in their test suites.
Installing and Configuring Playwright
First, initialize Playwright in your project. The CLI wizard handles browser downloads and creates a starter configuration automatically.
# Initialize Playwright in your project
npm init playwright@latest
# Expected output:
# Getting started with writing end-to-end tests with Playwright:
# Initializing project in '.'
# ? Where to put your end-to-end tests? · tests
# ? Add a GitHub Actions workflow? · true
# ? Install Playwright browsers? · true
This command creates a playwright.config.ts file, a tests/ directory with an example test, and optionally a GitHub Actions workflow. The default configuration works for most projects, but you’ll typically want to customize a few key settings.
// playwright.config.ts
import { defineConfig, devices } from '@playwright/test';
export default defineConfig({
testDir: './tests',
fullyParallel: true,
forbidOnly: !!process.env.CI,
retries: process.env.CI ? 2 : 0,
workers: process.env.CI ? 1 : undefined,
reporter: 'html',
use: {
baseURL: 'http://localhost:3000',
trace: 'on-first-retry',
screenshot: 'only-on-failure',
},
projects: [
{
name: 'chromium',
use: { ...devices['Desktop Chrome'] },
},
{
name: 'firefox',
use: { ...devices['Desktop Firefox'] },
},
{
name: 'webkit',
use: { ...devices['Desktop Safari'] },
},
],
webServer: {
command: 'npm run dev',
url: 'http://localhost:3000',
reuseExistingServer: !process.env.CI,
},
});
The webServer block is particularly important because it automatically starts your dev server before tests run and shuts it down afterward. Consequently, you don’t need separate scripts to manage the server lifecycle. The trace: 'on-first-retry' setting captures a detailed trace file whenever a test fails and retries, which becomes invaluable for debugging in CI.
Writing Your First End-to-End Test
Playwright tests use a familiar arrange-act-assert pattern. However, Playwright’s auto-waiting eliminates the explicit waits and sleep calls that plague other E2E frameworks.
// tests/login.spec.ts
import { test, expect } from '@playwright/test';
test.describe('Login Flow', () => {
test('should log in with valid credentials', async ({ page }) => {
await page.goto('/login');
// Fill in credentials using accessible locators
await page.getByLabel('Email').fill('user@example.com');
await page.getByLabel('Password').fill('securepassword');
await page.getByRole('button', { name: 'Sign In' }).click();
// Verify redirect to dashboard
await expect(page).toHaveURL('/dashboard');
await expect(
page.getByRole('heading', { name: 'Welcome' })
).toBeVisible();
});
test('should show error for invalid credentials', async ({ page }) => {
await page.goto('/login');
await page.getByLabel('Email').fill('wrong@example.com');
await page.getByLabel('Password').fill('wrongpassword');
await page.getByRole('button', { name: 'Sign In' }).click();
// Verify error message appears without navigating away
await expect(page.getByText('Invalid email or password')).toBeVisible();
await expect(page).toHaveURL('/login');
});
});
Notice that the locators use getByRole and getByLabel instead of CSS selectors. These accessible locators are more resilient to UI changes because they target semantic meaning rather than DOM structure. As a bonus, they also verify that your application has proper accessibility markup. If you’re working with TypeScript in your project, Playwright’s TypeScript support works out of the box with no additional configuration — for more on why TypeScript matters, see our guide on TypeScript vs JavaScript differences.
Structuring Tests with the Page Object Model
As your test suite grows, duplicating selectors and interactions across test files becomes a maintenance burden. The Page Object Model (POM) pattern solves this by encapsulating page interactions into reusable classes. Each page object represents a single page or component in your application.
// tests/pages/login-page.ts
import { type Page, type Locator } from '@playwright/test';
export class LoginPage {
private readonly emailInput: Locator;
private readonly passwordInput: Locator;
private readonly submitButton: Locator;
private readonly errorMessage: Locator;
constructor(private page: Page) {
this.emailInput = page.getByLabel('Email');
this.passwordInput = page.getByLabel('Password');
this.submitButton = page.getByRole('button', { name: 'Sign 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 getErrorText() {
return this.errorMessage.textContent();
}
}
// tests/pages/dashboard-page.ts
import { type Page, type Locator } from '@playwright/test';
export class DashboardPage {
private readonly welcomeHeading: Locator;
private readonly logoutButton: Locator;
constructor(private page: Page) {
this.welcomeHeading = page.getByRole('heading', { name: 'Welcome' });
this.logoutButton = page.getByRole('button', { name: 'Log Out' });
}
async expectLoaded() {
await this.welcomeHeading.waitFor({ state: 'visible' });
}
async logout() {
await this.logoutButton.click();
}
}
Now your tests read like user stories instead of implementation details:
// tests/login.spec.ts (refactored with page objects)
import { test, expect } from '@playwright/test';
import { LoginPage } from './pages/login-page';
import { DashboardPage } from './pages/dashboard-page';
test('should log in and reach dashboard', async ({ page }) => {
const loginPage = new LoginPage(page);
const dashboardPage = new DashboardPage(page);
await loginPage.goto();
await loginPage.login('user@example.com', 'securepassword');
await dashboardPage.expectLoaded();
await expect(page).toHaveURL('/dashboard');
});
This approach keeps your tests focused on behavior rather than selectors. Furthermore, when the UI changes, you update one page object instead of dozens of test files. If you’ve already established unit testing with Jest or Vitest for your component logic, the Page Object Model is the natural complement for E2E coverage.
Test Isolation and Authentication State
One of Playwright’s strongest features is browser context isolation. Each test gets a fresh browser context by default, which means tests don’t share cookies, localStorage, or session state. While this prevents flaky interactions between tests, it also means every test that requires authentication must log in first.
For test suites with many authenticated flows, logging in before each test wastes significant time. Playwright solves this with storage state — you authenticate once and reuse the saved session across all tests.
// tests/auth.setup.ts
import { test as setup, expect } from '@playwright/test';
const authFile = 'tests/.auth/user.json';
setup('authenticate', async ({ page }) => {
await page.goto('/login');
await page.getByLabel('Email').fill('user@example.com');
await page.getByLabel('Password').fill('securepassword');
await page.getByRole('button', { name: 'Sign In' }).click();
// Wait for the redirect to confirm login succeeded
await expect(page).toHaveURL('/dashboard');
// Save signed-in state to file
await page.context().storageState({ path: authFile });
});
Then reference this setup in your configuration:
// Add to projects array in playwright.config.ts
{
name: 'setup',
testMatch: /.*\.setup\.ts/,
},
{
name: 'chromium',
use: {
...devices['Desktop Chrome'],
storageState: 'tests/.auth/user.json',
},
dependencies: ['setup'],
},
As a result, all tests in the chromium project inherit the authenticated state automatically. The setup runs once, and subsequent tests skip the login flow entirely. This pattern can cut suite execution time significantly, especially when your login flow involves OAuth redirects or multi-factor authentication steps.
Handling Network Requests and API Mocking
Playwright lets you intercept and mock network requests at the browser level, which is useful for testing error states or avoiding external API dependencies during test runs.
// tests/error-handling.spec.ts
import { test, expect } from '@playwright/test';
test('should show error state when API fails', async ({ page }) => {
// Intercept the API call and return a 500 error
await page.route('**/api/users/profile', (route) => {
route.fulfill({
status: 500,
contentType: 'application/json',
body: JSON.stringify({ error: 'Internal Server Error' }),
});
});
await page.goto('/profile');
await expect(page.getByText('Something went wrong')).toBeVisible();
await expect(page.getByRole('button', { name: 'Retry' })).toBeVisible();
});
This technique is particularly valuable for testing edge cases that are difficult to reproduce against a live backend. For instance, you can simulate timeout errors, rate limiting responses, or malformed JSON payloads. However, don’t mock everything in your E2E suite — your end-to-end tests should exercise the real backend for happy paths to catch genuine integration issues.
Running Playwright in CI/CD
End-to-end testing with Playwright truly pays off when tests run automatically on every pull request. The npm init playwright command can generate a GitHub Actions workflow, but here’s a production-ready version with caching and artifact storage.
# .github/workflows/e2e.yml
name: E2E Tests
on:
pull_request:
branches: [main]
push:
branches: [main]
jobs:
e2e:
timeout-minutes: 15
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 20
cache: 'npm'
- name: Install dependencies
run: npm ci
- name: Install Playwright browsers
run: npx playwright install --with-deps chromium
- name: Run E2E tests
run: npx playwright test --project=chromium
- name: Upload test report
uses: actions/upload-artifact@v4
if: ${{ !cancelled() }}
with:
name: playwright-report
path: playwright-report/
retention-days: 14
Notice that the workflow installs only Chromium instead of all three browsers. In CI, running against a single browser engine keeps pipeline time reasonable while still catching most issues. Additionally, uploading the HTML report as an artifact means you can inspect failures directly from the GitHub Actions UI without reproducing them locally. For a deeper dive into pipeline configuration, check out our guide on CI/CD for Node.js projects using GitHub Actions.
Debugging Failing Tests
Flaky tests are the biggest threat to E2E test adoption. Playwright provides several built-in tools to diagnose failures effectively.
Trace Viewer is the most powerful debugging tool available. When trace: 'on-first-retry' is enabled in your config, Playwright records a detailed trace on the first retry of a failed test. Open it with:
# Open the trace viewer for a specific trace file
npx playwright show-trace test-results/trace.zip
The Trace Viewer shows a timeline of every action, network request, and DOM snapshot. You can step through the test like a debugger and see exactly what the page looked like at each point in the test execution.
UI Mode is useful during local development. Run npx playwright test --ui to launch an interactive test runner that shows the browser alongside your test code. You can watch tests execute in real time, pick locators visually, and re-run individual tests instantly.
Headed Mode runs tests in a visible browser window, which helps when you need to observe what the user would actually see during a failure:
# Run tests with a visible browser window
npx playwright test --headed --project=chromium
For catching code quality issues before tests even run, consider setting up Git hooks to enforce linting and formatting as part of your pre-commit workflow.
Real-World Scenario: Adding E2E Tests to an Existing Project
Consider a mid-sized React application with 30-40 components, a Node.js backend, and no automated browser testing. The small development team relies on manual QA before each release, which takes roughly half a day per sprint. After a production incident where a broken checkout flow slipped through manual testing, the team decides to adopt Playwright.
Rather than aiming for full coverage immediately, they target only the three highest-value user flows: login, product search, and checkout. This scoped approach produces 15 tests covering the critical paths. The Page Object Model keeps the test code manageable, and the storage state pattern means authenticated tests don’t waste time re-logging in for each scenario.
Within the first two sprints, the Playwright suite catches two regressions that would have otherwise reached production: a broken form submission caused by a dependency update, and a missing error boundary on the checkout confirmation page. The key trade-off is maintenance time — when the UI changes, someone needs to update the relevant page objects. However, because the team concentrated on high-value flows instead of testing every button and tooltip, the maintenance burden stays reasonable for a small team.
The important lesson is starting small and expanding gradually. Teams that attempt to write hundreds of E2E tests from scratch typically burn out and abandon the suite within a few months. Instead, focus on the flows that cost the most when they break, then add coverage incrementally as confidence grows.
When to Use End-to-End Testing with Playwright
- Your application has critical user flows like login, checkout, or onboarding that must work across browsers
- You need to verify that frontend, backend, and database work together correctly through real user interactions
- Your team spends significant time on manual regression testing before each release
- You want to catch integration bugs that unit tests and component tests consistently miss
- Your CI/CD pipeline has enough headroom for browser-based tests, typically adding 5-15 minutes per run
When NOT to Use Playwright for E2E Testing
- You need to test individual component behavior in isolation — use a component testing library like React Testing Library instead
- Your application is primarily server-rendered with minimal client-side interaction — integration tests against the API provide better value per minute of test time
- You’re testing pure business logic that doesn’t involve the DOM — unit tests with Jest or Vitest are faster and more precisely targeted
- Your team lacks the bandwidth to maintain E2E tests — an unmaintained test suite is worse than no suite at all because it blocks deployments with false positives
Common Mistakes with Playwright E2E Tests
- Testing too many scenarios end-to-end. E2E tests are slow relative to unit tests and expensive to maintain. Reserve them for flows that cross system boundaries. For isolated component logic, keep using unit tests with Jest and Vitest.
- Using brittle CSS selectors instead of accessible locators. Selectors like
.btn-primary > span:nth-child(2)break with every UI refactor. UsegetByRole,getByLabel, andgetByTextfor resilient tests that also validate accessibility. - Not running tests in CI. End-to-end tests on a developer’s machine provide some value, but real reliability comes from running them on every pull request in a consistent, reproducible environment.
- Ignoring test isolation. Tests that depend on shared state or a specific execution order will become flaky over time. Use Playwright’s browser context isolation and the storage state pattern instead of sharing sessions between tests.
- Skipping the Page Object Model. Inline selectors in test files seem faster initially. However, after 20 tests reference the same login form, a single UI change forces updates across every file. Invest in page objects early to avoid this compounding maintenance cost.
- Not using the
webServerconfiguration. Manually starting the dev server before running tests introduces race conditions and makes CI setup fragile. ThewebServerconfig block handles startup, readiness checks, and teardown automatically.
Conclusion
End-to-end testing with Playwright provides reliable, fast browser automation that catches the integration bugs unit tests consistently miss. Start by running npm init playwright, write tests for your three most critical user flows using the Page Object Model, and integrate the suite into your CI pipeline with GitHub Actions. The storage state pattern eliminates redundant authentication, and Playwright’s Trace Viewer makes debugging failures straightforward rather than frustrating.
Focus on high-value flows first and expand coverage only when your team has the confidence and bandwidth to maintain additional tests. For your next step, explore our guide on configuring Prettier and ESLint for TypeScript projects to keep your test code at the same quality standard as your application code.