Playwright: End-to-End Testing That Doesn't Make You Hate Testing
Learn Playwright from setup to CI integration. Selectors, assertions, auto-waiting, fixtures, parallel testing, and real-world best practices.
End-to-end testing has a reputation problem. Tests are slow, flaky, and break every time someone changes a button's text. Teams write them reluctantly, maintain them grudgingly, and eventually disable the ones that fail too often.
Playwright changes that. Built by Microsoft, it's fast, reliable, and has features that make you actually want to write tests. Auto-waiting eliminates most flakiness. The test generator writes selectors for you. Parallel execution keeps your CI fast. And the debugging experience is genuinely pleasant.
If you've been burned by Selenium or early Cypress, give this a shot. It's a different experience.
Setup
# Create a new project
npm init playwright@latest
# This will:
# - Install Playwright and browsers
# - Create playwright.config.ts
# - Create a sample test in tests/
# - Optionally set up GitHub Actions CI
Or add to an existing project:
npm install -D @playwright/test
npx playwright install
The playwright install command downloads Chromium, Firefox, and WebKit browsers. Yes, you can test across all three.
Your First Test
// tests/example.spec.ts
import { test, expect } from '@playwright/test';
test('homepage has correct title', async ({ page }) => {
await page.goto('https://example.com');
await expect(page).toHaveTitle(/Example Domain/);
});
test('can navigate to a page', async ({ page }) => {
await page.goto('https://example.com');
await page.click('a');
await expect(page).toHaveURL(/iana.org/);
});
Run it:
npx playwright test
By default, tests run headless (no visible browser). To see the browser:
npx playwright test --headed
Or use the UI mode, which is the best way to develop tests:
npx playwright test --ui
Selectors: Finding Elements
Playwright recommends user-facing selectors -- the same things a real user would identify elements by:
// By role (preferred -- accessible and resilient)
page.getByRole('button', { name: 'Submit' });
page.getByRole('heading', { name: 'Welcome' });
page.getByRole('link', { name: 'Sign up' });
// By text
page.getByText('Welcome to our site');
page.getByText('Welcome', { exact: false }); // Partial match
// By label (for form fields)
page.getByLabel('Email address');
page.getByLabel('Password');
// By placeholder
page.getByPlaceholder('Enter your email');
// By test ID (when nothing else works)
page.getByTestId('submit-button');
// CSS selectors (escape hatch)
page.locator('.my-class');
page.locator('#my-id');
page.locator('[data-testid="my-element"]');
The role-based selectors are the best choice. They work the way a user interacts with the page, they're resilient to UI changes, and they double as accessibility validation. If getByRole('button', { name: 'Submit' }) can't find your button, your button probably has an accessibility problem.
Assertions
Playwright's assertions auto-retry until they pass or timeout. This eliminates the "element not ready yet" problem:
// Page assertions
await expect(page).toHaveTitle('Dashboard');
await expect(page).toHaveURL(/dashboard/);
// Element assertions
const heading = page.getByRole('heading', { name: 'Welcome' });
await expect(heading).toBeVisible();
await expect(heading).toHaveText('Welcome back, Alice');
await expect(heading).toHaveCSS('color', 'rgb(0, 0, 0)');
// Form element assertions
const input = page.getByLabel('Email');
await expect(input).toHaveValue('alice@example.com');
await expect(input).toBeEditable();
await expect(input).toBeFocused();
// List assertions
const items = page.getByRole('listitem');
await expect(items).toHaveCount(5);
await expect(items).toHaveText(['Item 1', 'Item 2', 'Item 3', 'Item 4', 'Item 5']);
// Negative assertions
await expect(page.getByText('Error')).not.toBeVisible();
Page Interactions
// Clicking
await page.getByRole('button', { name: 'Save' }).click();
await page.getByRole('button', { name: 'Save' }).dblclick();
await page.getByRole('link', { name: 'About' }).click();
// Typing
await page.getByLabel('Username').fill('alice');
await page.getByLabel('Username').clear();
await page.getByLabel('Search').type('hello', { delay: 100 }); // Types slowly
// Keyboard
await page.keyboard.press('Enter');
await page.keyboard.press('Control+a');
// Select dropdown
await page.getByLabel('Country').selectOption('US');
await page.getByLabel('Country').selectOption({ label: 'United States' });
// Checkbox and radio
await page.getByLabel('Accept terms').check();
await page.getByLabel('Accept terms').uncheck();
await expect(page.getByLabel('Accept terms')).toBeChecked();
// File upload
await page.getByLabel('Upload file').setInputFiles('path/to/file.pdf');
// Hover
await page.getByText('Menu').hover();
Auto-Waiting: Why Playwright Tests Are Less Flaky
This is Playwright's killer feature. Every action automatically waits for the element to be:
- Attached to the DOM
- Visible
- Stable (not animating)
- Enabled
- Not obscured by other elements
await page.waitForSelector('.button') before clicking. You just click. Playwright handles the timing.
// This just works, even if the button appears after an API call
await page.getByRole('button', { name: 'Submit' }).click();
// Assertions auto-retry for up to 5 seconds by default
await expect(page.getByText('Success')).toBeVisible();
If something truly isn't going to appear, the test fails after the timeout with a clear error message. No hanging forever.
The Test Generator
Don't want to write selectors manually? Let Playwright do it:
npx playwright codegen https://your-site.com
This opens a browser where you interact with your app normally. Playwright records your actions and generates test code in real time. The generated selectors are usually good enough to use as-is or as a starting point.
Fixtures: Reusable Test Setup
Fixtures provide isolated test data and setup. The built-in page fixture gives you a fresh browser page for each test.
Custom fixtures:
// fixtures.ts
import { test as base } from '@playwright/test';
type MyFixtures = {
authenticatedPage: any;
todoPage: any;
};
export const test = base.extend<MyFixtures>({
authenticatedPage: async ({ page }, use) => {
// Login before the test
await page.goto('/login');
await page.getByLabel('Email').fill('test@example.com');
await page.getByLabel('Password').fill('password123');
await page.getByRole('button', { name: 'Sign in' }).click();
await page.waitForURL('/dashboard');
// Provide the page to the test
await use(page);
// Cleanup after the test (optional)
await page.goto('/logout');
},
todoPage: async ({ authenticatedPage }, use) => {
await authenticatedPage.goto('/todos');
await use(authenticatedPage);
}
});
export { expect } from '@playwright/test';
// tests/todos.spec.ts
import { test, expect } from '../fixtures';
test('can add a todo', async ({ todoPage }) => {
// Already logged in and on the todos page
await todoPage.getByPlaceholder('What needs to be done?').fill('Write tests');
await todoPage.keyboard.press('Enter');
await expect(todoPage.getByText('Write tests')).toBeVisible();
});
Parallel Execution
Tests run in parallel by default. Each test gets its own browser context (like an incognito window), so they're fully isolated.
// playwright.config.ts
import { defineConfig } from '@playwright/test';
export default defineConfig({
testDir: './tests',
fullyParallel: true,
workers: process.env.CI ? 2 : undefined, // Use all CPUs locally
retries: process.env.CI ? 2 : 0,
reporter: process.env.CI ? 'github' : 'html',
use: {
baseURL: 'http://localhost:3000',
trace: 'on-first-retry',
screenshot: 'only-on-failure',
},
projects: [
{
name: 'chromium',
use: { browserName: 'chromium' },
},
{
name: 'firefox',
use: { browserName: 'firefox' },
},
{
name: 'webkit',
use: { browserName: 'webkit' },
},
],
webServer: {
command: 'npm run dev',
url: 'http://localhost:3000',
reuseExistingServer: !process.env.CI,
},
});
The webServer config automatically starts your dev server before tests and shuts it down after.
CI Integration
Playwright provides a GitHub Actions template:
# .github/workflows/playwright.yml
name: Playwright Tests
on:
push:
branches: [main]
pull_request:
branches: [main]
jobs:
test:
timeout-minutes: 60
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 20
- run: npm ci
- run: npx playwright install --with-deps
- run: npx playwright test
- uses: actions/upload-artifact@v4
if: always()
with:
name: playwright-report
path: playwright-report/
retention-days: 30
When tests fail in CI, the uploaded report shows screenshots, traces, and full step-by-step playback. The trace viewer is incredible for debugging -- it lets you step through the test like a debugger, showing the page state at every action.
Visual Comparisons
Playwright can take screenshots and compare them against baselines:
test('dashboard looks correct', async ({ page }) => {
await page.goto('/dashboard');
await expect(page).toHaveScreenshot('dashboard.png');
});
test('chart renders correctly', async ({ page }) => {
await page.goto('/analytics');
const chart = page.locator('.chart-container');
await expect(chart).toHaveScreenshot('analytics-chart.png', {
maxDiffPixelRatio: 0.01, // Allow 1% pixel difference
});
});
First run creates the baseline screenshots. Subsequent runs compare against them. Differences show up as visual diffs in the HTML report.
# Update baselines when UI intentionally changes
npx playwright test --update-snapshots
API Testing
Playwright isn't just for browsers. It can test APIs directly:
import { test, expect } from '@playwright/test';
test('API returns user data', async ({ request }) => {
const response = await request.get('/api/users/1');
expect(response.ok()).toBeTruthy();
const user = await response.json();
expect(user.name).toBe('Alice');
expect(user.email).toContain('@');
});
test('API creates a new post', async ({ request }) => {
const response = await request.post('/api/posts', {
data: {
title: 'Test Post',
body: 'This is a test.',
},
headers: {
Authorization: 'Bearer test-token',
},
});
expect(response.status()).toBe(201);
const post = await response.json();
expect(post.id).toBeDefined();
});
test('seed data via API, then test UI', async ({ page, request }) => {
// Create test data via API
await request.post('/api/todos', {
data: { title: 'Buy groceries', completed: false }
});
// Test the UI
await page.goto('/todos');
await expect(page.getByText('Buy groceries')).toBeVisible();
});
Mixing API calls with browser tests is powerful. Seed data via the API, then verify the UI displays it correctly.
Best Practices
Use role-based selectors.getByRole('button', { name: 'Save' }) is better than .save-btn. It's more readable, more resilient, and validates accessibility.
Keep tests independent. Each test should work in isolation. Don't rely on test execution order. Use fixtures for shared setup.
Test user behaviors, not implementation. Test "user can add an item to cart" not "clicking #add-btn increments the cart-count div". The first survives refactors; the second breaks every time you change a class name.
Use the HTML reporter. npx playwright show-report opens an interactive report with screenshots, traces, and timing. Use it.
Don't over-test. E2E tests are expensive to run. Test critical user flows (signup, checkout, core features). Use unit tests and integration tests for edge cases.
Common Mistakes
Testing third-party services. Don't test that Stripe's payment form works. Mock external services or test up to the integration boundary. Not usingwebServer config. Starting your server manually before tests is error-prone. Let Playwright handle it.
Ignoring flaky tests. A flaky test that passes "most of the time" is worse than no test. Fix it or delete it. Enable retries in CI as a safety net, but investigate persistent flakiness.
Screenshots as the only assertions. Visual tests are fragile across platforms and browser versions. Use them for layout verification, but rely on content assertions for business logic.
What's Next
You now have a solid foundation in Playwright: selectors, assertions, fixtures, parallel execution, CI integration, visual testing, and API testing. From here, explore component testing (testing React/Vue components in isolation), mobile emulation, and advanced fixtures for complex setup scenarios.
Write more tests and build testing skills at CodeUp.