Playwright E2E Testing: Catching Bugs with Browser Automation
Prologue: The Bug QA Found in Three Clicks
85% unit test coverage. Integration tests running. Then QA logged in, opened the profile editor, hit save — three clicks — and the UI broke.
Why? Each component worked in isolation. The bug was in the connection between them: a race condition between form state and the API response. Exactly the kind of bug unit tests can never catch.
That day, I added E2E tests. I chose Playwright, and I haven't regretted it.
The Test Pyramid
Understanding the test pyramid makes E2E's role clear:
/\
/E2E\ ← Slow and expensive, but validates real user experience
/------\
/Integr. \ ← Module interaction tests
/----------\
/ Unit \ ← Fast and cheap, individual functions/components
/--------------\
Unit tests: "Does this function return the right value?"
Integration tests: "Do these components work correctly together?"
E2E tests: "Can a real user actually complete this task?"
E2E is the slowest and most expensive to maintain. Reserve it for critical user scenarios: login, checkout, core CRUD flows.
Playwright vs Cypress: A Quick Comparison
Both are solid tools. Here's how to choose:
| Playwright | Cypress |
|---|
| Browser support | Chromium, Firefox, WebKit | Primarily Chromium |
| Parallel execution | Built-in | Paid plan needed |
| Language support | JS, TS, Python, Java, C# | JS/TS only |
| iframe support | Excellent | Limited |
| Network interception | Powerful | Good |
| Learning curve | Steeper | Gentler |
| Debugging UI | Trace Viewer | Time Travel |
Choose Playwright when: you need multi-browser support, have a Python/Java team, or need complex network mocking.
Choose Cypress when: JS only, fast onboarding priority, prefer visual debugging.
Installation and Setup
npm init playwright@latest
The interactive setup handles browser installs and creates playwright.config.ts.
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',
video: 'retain-on-failure',
},
projects: [
{ name: 'chromium', use: { ...devices['Desktop Chrome'] } },
{ name: 'firefox', use: { ...devices['Desktop Firefox'] } },
{ name: 'webkit', use: { ...devices['Desktop Safari'] } },
{ name: 'Mobile Chrome', use: { ...devices['Pixel 5'] } },
],
webServer: {
command: 'npm run dev',
url: 'http://localhost:3000',
reuseExistingServer: !process.env.CI,
},
})
Writing Your First Tests
// tests/homepage.spec.ts
import { test, expect } from '@playwright/test'
test('homepage loads correctly', async ({ page }) => {
await page.goto('/')
await expect(page).toHaveTitle(/My Blog/)
await expect(page.getByRole('navigation')).toBeVisible()
await expect(page.getByRole('article').first()).toBeVisible()
})
test('login flow', async ({ page }) => {
await page.goto('/login')
await page.getByLabel('Email').fill('user@example.com')
await page.getByLabel('Password').fill('password123')
await page.getByRole('button', { name: 'Log in' }).click()
await expect(page).toHaveURL('/dashboard')
await expect(page.getByText('Welcome')).toBeVisible()
})
Locator Strategy
// Recommended: role-based (also tests accessibility)
page.getByRole('button', { name: 'Submit' })
page.getByRole('heading', { name: 'Dashboard' })
// Recommended: label-based
page.getByLabel('Email address')
// Recommended: placeholder
page.getByPlaceholder('Enter your email')
// Recommended: visible text
page.getByText('Sign in')
// Acceptable: test ID (last resort)
page.getByTestId('submit-button')
// Avoid: CSS selectors (brittle)
page.locator('.btn-primary')
Priority order: role > label > placeholder > text > testId > CSS
Page Object Pattern
Without page objects, 10 tests each containing login logic means 10 edits when the login UI changes.
// tests/pages/LoginPage.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 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)
}
}
// tests/auth.spec.ts
import { test, expect } from '@playwright/test'
import { LoginPage } from './pages/LoginPage'
import { DashboardPage } from './pages/DashboardPage'
test.describe('Authentication', () => {
test('successful login with valid credentials', async ({ page }) => {
const loginPage = new LoginPage(page)
const dashboardPage = new DashboardPage(page)
await loginPage.goto()
await loginPage.login('user@example.com', 'password123')
await dashboardPage.expectLoggedIn('Alice')
})
test('login fails with wrong password', async ({ page }) => {
const loginPage = new LoginPage(page)
await loginPage.goto()
await loginPage.login('user@example.com', 'wrongpassword')
await loginPage.expectError('Incorrect password')
})
})
Handling Async Operations
Playwright's auto-waiting covers most cases. For everything else:
// Wait for element state
await expect(page.getByText('Saved!')).toBeVisible()
await expect(page.getByText('Loading...')).not.toBeVisible()
// Wait for URL change
await page.waitForURL('/dashboard')
// Wait for specific network request
const responsePromise = page.waitForResponse('/api/users')
await page.getByRole('button', { name: 'Load users' }).click()
const response = await responsePromise
expect(response.status()).toBe(200)
// Wait for page to be fully loaded
await page.waitForLoadState('networkidle')
Network Interception
test('shows error on API failure', async ({ page }) => {
await page.route('/api/users/*', route => {
route.fulfill({
status: 500,
contentType: 'application/json',
body: JSON.stringify({ error: 'Internal Server Error' }),
})
})
await page.goto('/users/1')
await expect(page.getByRole('alert')).toContainText('Failed to load data')
})
test('shows loading state on slow network', async ({ page }) => {
await page.route('/api/data', async route => {
await new Promise(resolve => setTimeout(resolve, 2000))
await route.continue()
})
await page.goto('/data-page')
await expect(page.getByTestId('loading-spinner')).toBeVisible()
await expect(page.getByTestId('loading-spinner')).not.toBeVisible()
})
Reusing Authentication State
Logging in before every test is slow. Use storageState to save and reuse auth.
// tests/auth.setup.ts
import { test as setup, expect } from '@playwright/test'
import path from 'path'
const authFile = path.join(__dirname, '../.playwright/user.json')
setup('save authentication state', async ({ page }) => {
await page.goto('/login')
await page.getByLabel('Email').fill('user@example.com')
await page.getByLabel('Password').fill('password123')
await page.getByRole('button', { name: 'Log in' }).click()
await expect(page).toHaveURL('/dashboard')
await page.context().storageState({ path: authFile })
})
// playwright.config.ts
projects: [
{ name: 'setup', testMatch: /.*\.setup\.ts/ },
{
name: 'authenticated',
use: { storageState: '.playwright/user.json' },
dependencies: ['setup'],
},
{ name: 'unauthenticated' },
]
Visual Regression Testing
Catch unintended UI changes with screenshot comparisons:
test('dashboard visual regression', async ({ page }) => {
await page.goto('/dashboard')
await page.waitForLoadState('networkidle')
await expect(page).toHaveScreenshot('dashboard-full.png')
await expect(page.getByRole('main')).toHaveScreenshot('dashboard-main.png')
})
Configure pixel tolerance:
await expect(page).toHaveScreenshot('dashboard.png', {
maxDiffPixels: 100,
maxDiffPixelRatio: 0.01,
threshold: 0.2,
})
Trace Viewer: Debugging Failures
When tests fail, Playwright saves trace files. Open them with:
npx playwright show-trace test-results/failed-test/trace.zip
Trace Viewer shows step-by-step screenshots, network requests, console logs, and DOM state — essentially a video of your test run.
For interactive debugging:
npx playwright test --debug tests/login.spec.ts
CI Integration (GitHub Actions)
name: E2E Tests
on:
push:
branches: [main]
pull_request:
branches: [main]
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: '20'
cache: 'npm'
- run: npm ci
- run: npx playwright install --with-deps
- run: npx playwright test
env:
BASE_URL: ${{ secrets.STAGING_URL }}
- uses: actions/upload-artifact@v4
if: ${{ !cancelled() }}
with:
name: playwright-report
path: playwright-report/
retention-days: 30
Practical Tips for Stable Tests
1. Never use hard-coded sleeps
// Never do this
await page.waitForTimeout(3000)
// Do this instead
await expect(page.getByText('Saved!')).toBeVisible()
2. Keep tests isolated
test.beforeEach(async ({ page }) => {
await page.goto('/')
})
// Or reset via API
test.beforeEach(async ({ request }) => {
await request.post('/api/test/reset')
})
3. Focus on critical user flows
E2E tests are expensive. Don't try to test everything:
- Login/logout
- Checkout flow
- Core CRUD scenarios
- Error recovery
Minor UI interactions belong in unit/component tests.
Conclusion: E2E Tests Are Insurance
E2E tests are slow and have maintenance costs. But they catch in code review the bug that QA finds in three clicks in staging. They prevent production incidents before deployment.
Think of them as insurance. The cost feels real day-to-day, but when you need them, the value is obvious. Five to ten critical user scenario tests with Playwright will change how confident you feel about every deployment.