How to Set Up E2E Tests with Playwright from Scratch
TL;DR
Playwright is the E2E testing standard in 2026. It supports Chromium, Firefox, and WebKit from one API, has auto-waits that eliminate flaky tests, and ships a trace viewer for debugging. Setup is npx playwright install and you are writing tests within minutes. The patterns that make tests maintainable: Page Object Model for reusable selectors, API request interception for consistent test data, and storageState for fast auth bypass.
Key Takeaways
npx playwright install— downloads browsers, sets up config automatically- Auto-wait — Playwright waits for elements before clicking (no sleep needed)
- Locators — prefer
getByRole,getByLabelover CSS selectors - Page Object Model — encapsulate page interactions for maintainable tests
storageState— save auth state once, reuse across tests (fast auth bypass)
Why Playwright Won
Playwright emerged from the team that originally built Puppeteer, and it shows — the design reflects hard lessons from running browser automation at Microsoft scale. The two features that distinguish it from everything else are auto-waiting and genuine multi-browser support.
Auto-waiting means every action Playwright takes — click, fill, press, check — automatically waits for the target element to be visible, attached to the DOM, enabled, and not animating before acting. You never write await page.waitForSelector(...) or await page.waitForTimeout(2000). This eliminates the single most common cause of flaky tests: timing races where the test moves faster than the UI renders. When a Playwright test fails, it almost always means a real bug, not a race condition.
True multi-browser support rounds out the picture. Playwright uses patched browser binaries for Chromium, Firefox, and WebKit (the Safari engine), giving consistent behavior across all three and letting you catch Safari-specific bugs in CI before users encounter them. Cypress, by contrast, only supports Chrome-family browsers by default.
In 2026, if you are starting a new E2E test suite, Playwright is the default choice. Its weekly download count has roughly doubled year-over-year since 2022, and it is the recommended testing framework in the official Next.js, Remix, and Nuxt documentation.
Installation
Playwright's interactive setup handles everything: browser downloads, config file creation, and an optional GitHub Actions workflow:
npm init playwright@latest
The installer prompts you with several questions:
Do you want to use TypeScript or JavaScript? › TypeScript
Where to put your end-to-end tests? › tests
Add a GitHub Actions workflow? › true
Install Playwright browsers? › true
Choose TypeScript — the type definitions make locator methods and configuration significantly easier to work with. After the installer runs, it downloads Chromium, Firefox, and WebKit (about 300MB total) and creates these files:
playwright.config.ts — Test configuration
tests/
example.spec.ts — Sample test to verify setup
.github/
workflows/
playwright.yml — CI workflow (if you chose yes)
Alternatively, if you are adding Playwright to an existing project:
npm install -D @playwright/test
npx playwright install # Downloads Chromium, Firefox, WebKit
npx playwright install-deps # System dependencies (Linux only)
Verify the installation works:
npx playwright test # Runs the example tests
npx playwright show-report # Opens the HTML report in browser
Your First Test
The example test that Playwright generates is a good starting template, but here is a more realistic first test that shows Playwright's core behavior: auto-waiting, locators, and assertions.
// tests/contact-form.spec.ts
import { test, expect } from '@playwright/test';
test('contact form submits successfully', async ({ page }) => {
// Navigate to the form
await page.goto('/contact');
// Fill the form — Playwright waits for elements to be ready automatically
// No need for waitForSelector or sleep()
await page.getByLabel('Full name').fill('Jane Smith');
await page.getByLabel('Email address').fill('jane@example.com');
await page.getByLabel('Message').fill('Hello, I have a question about pricing.');
// Click the submit button
await page.getByRole('button', { name: 'Send message' }).click();
// Assert the success state
await expect(page.getByRole('alert')).toContainText('Message sent');
await expect(page.getByRole('alert')).toHaveAccessibleName(/success/i);
});
Why there are no await page.waitFor...() calls. Playwright's auto-wait mechanism watches for elements to meet a specific condition before acting on them. When you call page.getByLabel('Email address').fill(...), Playwright automatically waits for the element to be visible, enabled, and stable before typing. This eliminates the most common cause of flaky tests: timing issues where the test runs faster than the UI renders.
The default timeout for auto-wait is 30 seconds (configurable). If an element is not found within that timeout, the test fails with a clear error message that shows exactly which element was missing.
playwright.config.ts
The config file controls browser selection, parallel execution, base URL, and artifacts:
// playwright.config.ts
import { defineConfig, devices } from '@playwright/test';
export default defineConfig({
testDir: './tests',
fullyParallel: true, // Tests run in parallel across workers
forbidOnly: !!process.env.CI, // Fail CI if test.only is committed
retries: process.env.CI ? 2 : 0,
workers: process.env.CI ? 1 : undefined,
reporter: [
['html', { open: 'never' }],
['list'],
],
use: {
baseURL: process.env.BASE_URL || 'http://localhost:3000',
trace: 'on-first-retry', // Capture trace on first failure
screenshot: 'only-on-failure',
video: 'retain-on-failure',
},
projects: [
{ name: 'setup', testMatch: /.*\.setup\.ts/ },
{
name: 'chromium',
use: { ...devices['Desktop Chrome'], storageState: 'playwright/.auth/user.json' },
dependencies: ['setup'],
},
{
name: 'firefox',
use: { ...devices['Desktop Firefox'], storageState: 'playwright/.auth/user.json' },
dependencies: ['setup'],
},
{
name: 'mobile',
use: { ...devices['iPhone 14'], storageState: 'playwright/.auth/user.json' },
dependencies: ['setup'],
},
],
webServer: {
command: 'npm run dev',
url: 'http://localhost:3000',
reuseExistingServer: !process.env.CI,
timeout: 120_000,
},
});
Locators: The Right Way to Select Elements
The locator strategy you choose determines whether your tests are resilient or brittle. CSS selectors break when developers refactor styles or rename classes. Accessible locators based on semantic meaning survive refactors.
Prefer these locators (most resilient first):
// getByRole — based on ARIA role and accessible name
// Most resilient: role and name reflect user-visible meaning
await page.getByRole('button', { name: 'Submit' }).click();
await page.getByRole('heading', { name: 'Dashboard' }).isVisible();
await page.getByRole('textbox', { name: 'Email' }).fill('user@example.com');
await page.getByRole('checkbox', { name: 'Remember me' }).check();
// getByLabel — for form inputs associated with a label element
await page.getByLabel('Email address').fill('user@example.com');
await page.getByLabel('Password').fill('secret123');
// getByPlaceholder — for inputs without visible labels
await page.getByPlaceholder('Search packages...').fill('react');
// getByText — for non-interactive content
await page.getByText('Welcome back, Jane').isVisible();
// getByTestId — for programmatic test hooks (last resort)
// Requires adding data-testid attributes to your HTML
await page.getByTestId('user-avatar').click();
Avoid these locators:
// CSS selectors — break on style refactors
await page.locator('.btn-primary').click(); // Bad
await page.locator('#submit-button').click(); // Bad
// XPath — verbose and brittle
await page.locator('//button[@class="submit"]').click(); // Bad
// nth-child selectors — fragile to DOM reordering
await page.locator('ul > li:nth-child(3)').click(); // Bad
Why role-based locators are more resilient:
Role-based locators test the same interface that screen readers and assistive technology use. When a developer refactors a <button> from a div-with-onclick to a real <button>, the test continues working. When they change the button's CSS class, the test continues working. The test only breaks when the button's text or role changes, which is when you would want the test to break.
Page Object Model
For anything beyond a few tests, the Page Object Model (POM) prevents test code from becoming unmaintainable. A page object encapsulates all locators and actions for a given page, so when the UI changes, you update one file instead of every test.
// tests/pages/login.page.ts
import { type Page, type Locator, expect } from '@playwright/test';
export class LoginPage {
readonly page: Page;
readonly emailInput: Locator;
readonly passwordInput: Locator;
readonly submitButton: Locator;
readonly errorAlert: Locator;
constructor(page: Page) {
this.page = page;
this.emailInput = page.getByLabel('Email');
this.passwordInput = page.getByLabel('Password');
this.submitButton = page.getByRole('button', { name: 'Sign in' });
this.errorAlert = 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 expectErrorMessage(message: string) {
await expect(this.errorAlert).toContainText(message);
}
}
// tests/pages/dashboard.page.ts
import { type Page, type Locator, expect } from '@playwright/test';
export class DashboardPage {
readonly page: Page;
readonly heading: Locator;
readonly createButton: Locator;
readonly searchInput: Locator;
readonly projectList: Locator;
constructor(page: Page) {
this.page = page;
this.heading = page.getByRole('heading', { name: 'Dashboard' });
this.createButton = page.getByRole('button', { name: 'Create project' });
this.searchInput = page.getByPlaceholder('Search projects...');
this.projectList = page.getByRole('list', { name: 'Projects' });
}
async goto() {
await this.page.goto('/dashboard');
await expect(this.heading).toBeVisible();
}
async createProject(name: string) {
await this.createButton.click();
await this.page.getByLabel('Project name').fill(name);
await this.page.getByRole('button', { name: 'Create' }).click();
await expect(
this.projectList.getByRole('listitem').filter({ hasText: name })
).toBeVisible();
}
}
Using page objects in tests:
// tests/dashboard.spec.ts
import { test, expect } from '@playwright/test';
import { DashboardPage } from './pages/dashboard.page';
test.describe('Dashboard', () => {
test('creates a new project', async ({ page }) => {
const dashboard = new DashboardPage(page);
await dashboard.goto();
await dashboard.createProject('My Test Project');
await expect(
dashboard.projectList.getByRole('listitem').filter({ hasText: 'My Test Project' })
).toBeVisible();
});
});
Test Data: API Requests and State
Creating test data through the UI is slow and flaky. Use API requests to set up state directly, then test the UI rendering.
// Create test data via API — much faster than UI setup
test('shows user projects', async ({ page, request }) => {
// Create a project via API before testing the UI
const response = await request.post('/api/projects', {
data: { name: 'API-created Project', status: 'active' },
headers: { Authorization: `Bearer ${process.env.TEST_API_TOKEN}` },
});
expect(response.ok()).toBeTruthy();
const project = await response.json();
// Now test the UI
await page.goto('/dashboard');
await expect(
page.getByRole('listitem').filter({ hasText: 'API-created Project' })
).toBeVisible();
// Cleanup
await request.delete(`/api/projects/${project.id}`, {
headers: { Authorization: `Bearer ${process.env.TEST_API_TOKEN}` },
});
});
For setting browser state directly without going through the UI:
// Set localStorage before navigating (useful for feature flags, preferences)
test('uses dark mode when preference is set', async ({ page }) => {
await page.goto('/');
await page.evaluate(() => {
localStorage.setItem('theme', 'dark');
});
await page.reload();
await expect(page.locator('html')).toHaveClass(/dark/);
});
Authentication Pattern
Log in once and reuse the session across all tests. This is the most important performance optimization in any Playwright suite.
// tests/auth.setup.ts — runs once before all tests
import { test as setup, expect } from '@playwright/test';
import path from 'path';
const authFile = path.join(__dirname, '../playwright/.auth/user.json');
setup('authenticate', async ({ page }) => {
await page.goto('/login');
await page.getByLabel('Email').fill('test@example.com');
await page.getByLabel('Password').fill('testpassword123');
await page.getByRole('button', { name: 'Sign in' }).click();
// Wait for successful auth
await expect(page).toHaveURL('/dashboard');
// Save the auth state — cookies + localStorage
// This file gets loaded by all subsequent tests
await page.context().storageState({ path: authFile });
});
The playwright.config.ts shown earlier references this file in the storageState property of each test project. Playwright loads the saved cookies and localStorage before every test, so tests start already authenticated without logging in again.
Add the auth file to .gitignore:
playwright/.auth/
CI Configuration
# .github/workflows/e2e.yml
name: E2E Tests
on: [push, pull_request]
jobs:
playwright:
runs-on: ubuntu-latest
timeout-minutes: 30
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
# --with-deps installs OS-level browser dependencies (Debian/Ubuntu only)
- name: Run Playwright tests
run: npx playwright test
env:
BASE_URL: ${{ vars.BASE_URL }}
TEST_API_TOKEN: ${{ secrets.TEST_API_TOKEN }}
- name: Upload test report
if: always() # Upload even if tests fail
uses: actions/upload-artifact@v4
with:
name: playwright-report
path: playwright-report/
retention-days: 30
- name: Upload trace on failure
if: failure()
uses: actions/upload-artifact@v4
with:
name: playwright-traces
path: test-results/
retention-days: 7
The --with-deps flag on npx playwright install installs the system-level OS dependencies (GTK, Nss, etc.) needed to run browsers on Ubuntu. Without this, browsers fail to launch in CI.
Debugging: Trace Viewer
The trace viewer is Playwright's most powerful debugging tool. It captures a full recording of every test: DOM snapshots, network requests, console logs, and screenshots at every step.
Enable traces for all failing tests (already set in the config above):
// playwright.config.ts
use: {
trace: 'on-first-retry', // Capture on first retry (captures failures)
// or 'on' for every test (slower but thorough during debugging)
}
Run with traces and open the viewer:
# Run tests with trace for every test
npx playwright test --trace on
# Open the trace viewer for a specific trace file
npx playwright show-trace test-results/my-test/trace.zip
# Or open all traces from the last run
npx playwright show-report
The trace viewer shows:
- A timeline of every action with before/after DOM snapshots
- The exact locator that was used and whether it matched
- Network requests made during the test
- Console output
- The full test source code with the failing line highlighted
When a test fails in CI and you cannot reproduce it locally, download the trace artifact from GitHub Actions and open it with npx playwright show-trace. You get a complete playback of exactly what happened.
API Mocking for Consistent Test Data
One of the most useful Playwright features for testing against live backends is page.route(), which intercepts HTTP requests and returns controlled responses. This is invaluable for testing error states, loading states, and edge cases that are hard to reproduce through a real API.
// Intercept and mock an API call
test('shows user list from API', async ({ page }) => {
await page.route('/api/users', (route) => {
route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify([
{ id: '1', name: 'Alice', email: 'alice@test.com', role: 'admin' },
{ id: '2', name: 'Bob', email: 'bob@test.com', role: 'member' },
]),
});
});
await page.goto('/users');
await expect(page.getByText('Alice')).toBeVisible();
await expect(page.getByText('admin')).toBeVisible();
});
// Test error state — hard to reproduce with a real API
test('shows error message when API fails', async ({ page }) => {
await page.route('/api/users', (route) => {
route.fulfill({ status: 500, body: 'Internal Server Error' });
});
await page.goto('/users');
await expect(page.getByRole('alert')).toContainText('Failed to load users');
await expect(page.getByRole('button', { name: 'Retry' })).toBeVisible();
});
// Test loading state by adding artificial delay
test('shows loading spinner', async ({ page }) => {
await page.route('/api/users', async (route) => {
await new Promise(resolve => setTimeout(resolve, 100));
route.fulfill({ status: 200, body: JSON.stringify([]) });
});
await page.goto('/users');
// Assert loading state appears before data resolves
await expect(page.getByTestId('loading-spinner')).toBeVisible();
});
API mocking is particularly useful for testing pages that depend on data that changes in your database over time. A real API might return different users each week; a mocked route returns exactly the same users every time, making your assertions deterministic.
Avoiding Common Flaky Test Patterns
Flaky tests are the main reason teams lose confidence in their E2E test suite. Understanding the patterns that cause flakiness helps you write tests that stay green.
Never use page.waitForTimeout() (the Playwright equivalent of setTimeout). This is the single most common cause of flaky tests. Fixed delays are either too short (the UI hasn't loaded yet) or too long (wasting CI time). Replace every waitForTimeout with a proper assertion that waits for a specific UI condition:
// Bad — fixed delay
await page.waitForTimeout(2000);
await page.getByRole('button', { name: 'Submit' }).click();
// Good — wait for the specific element that indicates readiness
await expect(page.getByRole('button', { name: 'Submit' })).toBeEnabled();
await page.getByRole('button', { name: 'Submit' }).click();
Avoid page.locator('.class-name') CSS selectors. When a designer refactors styles or a component library update changes class names, these selectors break silently. Use semantic locators (getByRole, getByLabel) that describe what the user sees, not implementation details.
Be careful with page.waitForNavigation(). In modern single-page applications, navigation is often handled by JavaScript without a full page load. Playwright's auto-wait handles most of these cases, but for complex navigation flows, waiting for a specific URL or element is more reliable:
// Instead of waitForNavigation (can be unreliable in SPAs):
await page.getByRole('button', { name: 'Go to dashboard' }).click();
await expect(page).toHaveURL('/dashboard');
// or wait for a landmark element on the new page:
await expect(page.getByRole('heading', { name: 'Dashboard' })).toBeVisible();
Isolate test state. Tests that depend on database state left by other tests are fragile. Each test should either create its own data (via API as shown above) or work with mocked data. The auth setup pattern (using storageState) is correct — it shares read-only session state that does not change between tests.
Visual Regression Testing with Playwright
Playwright supports screenshot comparisons out of the box. toHaveScreenshot() captures a reference screenshot on the first run and compares subsequent runs against it, failing if pixels differ beyond a configurable threshold.
// tests/visual.spec.ts
import { test, expect } from '@playwright/test';
test('homepage visual regression', async ({ page }) => {
await page.goto('/');
// Capture and compare screenshot
await expect(page).toHaveScreenshot('homepage.png', {
maxDiffPixelRatio: 0.01, // Allow up to 1% pixel difference
});
});
test('button component visual', async ({ page }) => {
await page.goto('/components/button');
const button = page.getByRole('button', { name: 'Primary action' });
await expect(button).toHaveScreenshot('button-primary.png');
});
Reference screenshots are stored in a __screenshots__ directory alongside your test files and should be committed to version control. When a test fails due to visual differences, the HTML report shows a side-by-side diff with the changed pixels highlighted.
Update screenshots after intentional UI changes:
npx playwright test --update-snapshots
Visual regression is most useful for component libraries and design system testing, where you want to catch unintentional visual regressions from CSS changes. For application testing, functional assertions with toBeVisible() and toContainText() are usually more appropriate — screenshot tests are brittle when content changes frequently.
Playwright vs Cypress: When to Choose Each
Playwright has surpassed Cypress in adoption for new projects, but Cypress still has advantages in specific scenarios.
Choose Playwright when:
- You need to test against multiple browsers (Firefox, WebKit/Safari)
- Your app runs in an iframe or multiple tabs
- You need fine-grained network interception
- You want the trace viewer for debugging failures
- Your team uses TypeScript — Playwright's types are excellent
Choose Cypress when:
- Your team already has a large Cypress suite (migration cost is real)
- You want in-browser DevTools integration during test development
- Your test scenarios focus exclusively on Chrome
- You prefer Cypress's component testing for isolated UI testing
The most important factor is momentum: if your team has extensive Cypress knowledge and a large test suite, the maintenance cost of a Playwright migration may outweigh the benefits. For new projects with no existing E2E tests, Playwright is the better default in 2026.
Summary
Playwright has become the default E2E testing choice for good reasons: auto-wait eliminates flaky timing, accessible locators make tests resilient, and the trace viewer makes debugging failures tractable. The patterns that scale — Page Object Model, API setup for test data, session reuse — are all first-class features with Playwright's official support.
Start with the auth setup pattern and Page Object Model from the beginning. Retrofitting a large test suite to use these patterns is significantly more work than building them in from the start.
The investment in E2E tests pays off most when combined with fast CI runs. Keep your Playwright suite focused on critical user journeys — login, signup, checkout, core product flows — rather than trying to cover every edge case. Unit tests and integration tests handle edge cases more efficiently. E2E tests verify that the critical paths work end-to-end across the full stack. With Playwright's parallelism and the authentication caching pattern, a well-structured suite of 50-100 critical path tests typically runs in under 5 minutes in CI, which is fast enough to run on every pull request without slowing down your team.
Further Reading
See the live comparison
View playwright vs. cypress on PkgPulse →