Web Application Testing with Playwright
Comprehensive E2E testing patterns for web applications.
Quick Start
Python Setup
from playwright.sync_api import sync_playwright
with sync_playwright() as p: browser = p.chromium.launch(headless=True) page = browser.new_page() page.goto('http://localhost:3000') page.wait_for_load_state('networkidle') # ... test logic browser.close()
JavaScript/TypeScript Setup
import { test, expect } from '@playwright/test';
test('example test', async ({ page }) => { await page.goto('http://localhost:3000'); await expect(page.locator('h1')).toContainText('Welcome'); });
Server Management
Using Helper Scripts
Single server:
python scripts/with_server.py --server "npm run dev" --port 5173 -- python your_test.py
Multiple servers:
python scripts/with_server.py
--server "cd backend && python server.py" --port 3000
--server "cd frontend && npm run dev" --port 5173
-- python your_test.py
Playwright Config (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'] } }, { name: 'Mobile Chrome', use: { ...devices['Pixel 5'] } }, { name: 'Mobile Safari', use: { ...devices['iPhone 12'] } }, ],
webServer: { command: 'npm run dev', url: 'http://localhost:3000', reuseExistingServer: !process.env.CI, }, });
Selectors
Best Practices
// BEST: Test IDs (most reliable) page.locator('[data-testid="submit-button"]') page.getByTestId('submit-button')
// GOOD: Role-based (accessible) page.getByRole('button', { name: 'Submit' }) page.getByRole('heading', { level: 1 }) page.getByRole('link', { name: 'Learn more' })
// GOOD: Label-based (forms) page.getByLabel('Email address') page.getByPlaceholder('Enter your email')
// GOOD: Text content page.getByText('Welcome back') page.getByText(/welcome/i) // Case-insensitive regex
// AVOID: CSS selectors (brittle) page.locator('.btn-primary') // Class might change page.locator('#submit') // ID might change
Selector Chaining
// Find within a container const form = page.locator('form[data-testid="login-form"]'); await form.getByLabel('Email').fill('user@example.com'); await form.getByRole('button', { name: 'Log in' }).click();
// Filter results await page.getByRole('listitem') .filter({ hasText: 'Product 1' }) .getByRole('button', { name: 'Add to cart' }) .click();
Common Test Patterns
Authentication Flow
import { test, expect } from '@playwright/test';
test.describe('Authentication', () => { test('successful login', 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();
// Verify redirect to dashboard
await expect(page).toHaveURL('/dashboard');
await expect(page.getByText('Welcome back')).toBeVisible();
});
test('invalid credentials show error', 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: 'Log in' }).click();
await expect(page.getByText('Invalid credentials')).toBeVisible();
await expect(page).toHaveURL('/login'); // Still on login page
});
test('logout', async ({ page }) => { // Login first (or use authenticated state) await page.goto('/dashboard'); await page.getByRole('button', { name: 'Logout' }).click();
await expect(page).toHaveURL('/login');
}); });
Form Submission
test.describe('Contact Form', () => { test.beforeEach(async ({ page }) => { await page.goto('/contact'); });
test('submits form with valid data', async ({ page }) => { await page.getByLabel('Name').fill('John Doe'); await page.getByLabel('Email').fill('john@example.com'); await page.getByLabel('Message').fill('This is a test message'); await page.getByRole('button', { name: 'Send' }).click();
await expect(page.getByText('Message sent successfully')).toBeVisible();
});
test('shows validation errors for empty fields', async ({ page }) => { await page.getByRole('button', { name: 'Send' }).click();
await expect(page.getByText('Name is required')).toBeVisible();
await expect(page.getByText('Email is required')).toBeVisible();
});
test('validates email format', async ({ page }) => { await page.getByLabel('Name').fill('John'); await page.getByLabel('Email').fill('invalid-email'); await page.getByRole('button', { name: 'Send' }).click();
await expect(page.getByText('Invalid email address')).toBeVisible();
}); });
Navigation Testing
test.describe('Navigation', () => { test('main menu links work', async ({ page }) => { await page.goto('/');
// Test each navigation link
const navLinks = [
{ name: 'Home', url: '/' },
{ name: 'About', url: '/about' },
{ name: 'Products', url: '/products' },
{ name: 'Contact', url: '/contact' },
];
for (const link of navLinks) {
await page.getByRole('link', { name: link.name }).click();
await expect(page).toHaveURL(link.url);
}
});
test('breadcrumbs show correct path', async ({ page }) => { await page.goto('/products/category/item-1');
const breadcrumbs = page.getByRole('navigation', { name: 'Breadcrumb' });
await expect(breadcrumbs.getByText('Home')).toBeVisible();
await expect(breadcrumbs.getByText('Products')).toBeVisible();
await expect(breadcrumbs.getByText('Category')).toBeVisible();
}); });
CRUD Operations
test.describe('Product Management', () => { test('creates new product', async ({ page }) => { await page.goto('/admin/products'); await page.getByRole('button', { name: 'Add Product' }).click();
await page.getByLabel('Name').fill('New Product');
await page.getByLabel('Price').fill('29.99');
await page.getByLabel('Description').fill('Product description');
await page.getByRole('button', { name: 'Save' }).click();
await expect(page.getByText('Product created')).toBeVisible();
await expect(page.getByText('New Product')).toBeVisible();
});
test('edits existing product', async ({ page }) => { await page.goto('/admin/products');
// Find product row and click edit
await page.getByRole('row', { name: /Existing Product/ })
.getByRole('button', { name: 'Edit' })
.click();
await page.getByLabel('Name').fill('Updated Product');
await page.getByRole('button', { name: 'Save' }).click();
await expect(page.getByText('Product updated')).toBeVisible();
await expect(page.getByText('Updated Product')).toBeVisible();
});
test('deletes product with confirmation', async ({ page }) => { await page.goto('/admin/products');
// Click delete button
await page.getByRole('row', { name: /Product to Delete/ })
.getByRole('button', { name: 'Delete' })
.click();
// Handle confirmation dialog
await page.getByRole('button', { name: 'Confirm' }).click();
await expect(page.getByText('Product deleted')).toBeVisible();
await expect(page.getByText('Product to Delete')).not.toBeVisible();
}); });
Modal/Dialog Testing
test.describe('Modal Dialogs', () => { test('opens and closes modal', async ({ page }) => { await page.goto('/');
// Open modal
await page.getByRole('button', { name: 'Open Modal' }).click();
await expect(page.getByRole('dialog')).toBeVisible();
// Close with X button
await page.getByRole('button', { name: 'Close' }).click();
await expect(page.getByRole('dialog')).not.toBeVisible();
});
test('closes modal on escape key', async ({ page }) => { await page.goto('/');
await page.getByRole('button', { name: 'Open Modal' }).click();
await expect(page.getByRole('dialog')).toBeVisible();
await page.keyboard.press('Escape');
await expect(page.getByRole('dialog')).not.toBeVisible();
});
test('closes modal on backdrop click', async ({ page }) => { await page.goto('/');
await page.getByRole('button', { name: 'Open Modal' }).click();
await expect(page.getByRole('dialog')).toBeVisible();
// Click outside modal
await page.locator('.modal-backdrop').click({ position: { x: 10, y: 10 } });
await expect(page.getByRole('dialog')).not.toBeVisible();
}); });
Waiting Strategies
Explicit Waits
// Wait for element await page.waitForSelector('[data-testid="content"]');
// Wait for element state await page.getByRole('button').waitFor({ state: 'visible' }); await page.getByRole('button').waitFor({ state: 'hidden' });
// Wait for navigation await page.waitForURL('/dashboard'); await page.waitForURL(//user/\d+/);
// Wait for network await page.waitForResponse('/api/users'); await page.waitForResponse(response => response.url().includes('/api/') && response.status() === 200 );
// Wait for load state await page.waitForLoadState('networkidle'); await page.waitForLoadState('domcontentloaded');
Auto-Waiting
// Playwright auto-waits for these await page.click('button'); // Waits for button to be actionable await page.fill('input', 'text'); // Waits for input to be editable await expect(locator).toBeVisible(); // Waits up to timeout
Assertions
Common Assertions
// Visibility await expect(locator).toBeVisible(); await expect(locator).toBeHidden(); await expect(locator).not.toBeVisible();
// Text content await expect(locator).toHaveText('exact text'); await expect(locator).toContainText('partial'); await expect(locator).toHaveText(/regex/i);
// Attributes await expect(locator).toHaveAttribute('href', '/about'); await expect(locator).toHaveClass(/active/); await expect(locator).toHaveId('main-content');
// Input values await expect(locator).toHaveValue('input value'); await expect(locator).toBeChecked(); await expect(locator).toBeDisabled(); await expect(locator).toBeEditable();
// Count await expect(locator).toHaveCount(5);
// Page assertions await expect(page).toHaveURL('/dashboard'); await expect(page).toHaveTitle('Dashboard | My App');
Soft Assertions
// Continue test even if assertion fails await expect.soft(locator).toHaveText('text'); await expect.soft(locator).toBeVisible();
// Check all soft assertions at end expect(test.info().errors).toHaveLength(0);
API Testing Integration
Mock API Responses
test('shows loading and data states', async ({ page }) => { // Intercept API request await page.route('/api/users', async route => { await route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify([ { id: 1, name: 'John' }, { id: 2, name: 'Jane' }, ]), }); });
await page.goto('/users'); await expect(page.getByText('John')).toBeVisible(); await expect(page.getByText('Jane')).toBeVisible(); });
test('handles API errors gracefully', async ({ page }) => { await page.route('/api/users', route => route.fulfill({ status: 500 }) );
await page.goto('/users'); await expect(page.getByText('Failed to load users')).toBeVisible(); });
Wait for API Calls
test('submits form and waits for API', async ({ page }) => { await page.goto('/contact');
// Start waiting for API response before triggering it const responsePromise = page.waitForResponse('/api/contact');
await page.getByLabel('Email').fill('test@example.com'); await page.getByRole('button', { name: 'Submit' }).click();
const response = await responsePromise; expect(response.status()).toBe(200); });
Visual Testing
Screenshots
test('homepage visual test', async ({ page }) => { await page.goto('/');
// Full page screenshot await expect(page).toHaveScreenshot('homepage.png', { fullPage: true, }); });
test('component visual test', async ({ page }) => { await page.goto('/');
// Element screenshot await expect(page.getByTestId('header')).toHaveScreenshot('header.png'); });
Screenshot Options
await page.screenshot({ path: 'screenshots/test.png', fullPage: true, animations: 'disabled', // Reduce flakiness mask: [page.locator('.dynamic-content')], // Hide changing content });
Authentication Reuse
Save Auth State
// auth.setup.ts import { test as setup, expect } from '@playwright/test';
const authFile = 'playwright/.auth/user.json';
setup('authenticate', async ({ page }) => { await page.goto('/login'); await page.getByLabel('Email').fill('user@example.com'); await page.getByLabel('Password').fill('password'); await page.getByRole('button', { name: 'Sign in' }).click();
await page.waitForURL('/dashboard');
// Save signed-in state await page.context().storageState({ path: authFile }); });
Use Auth State
// playwright.config.ts export default defineConfig({ projects: [ { name: 'setup', testMatch: /.*.setup.ts/ }, { name: 'tests', dependencies: ['setup'], use: { storageState: 'playwright/.auth/user.json', }, }, ], });
Accessibility Testing
import { test, expect } from '@playwright/test'; import AxeBuilder from '@axe-core/playwright';
test.describe('Accessibility', () => { test('homepage has no a11y violations', async ({ page }) => { await page.goto('/');
const results = await new AxeBuilder({ page }).analyze();
expect(results.violations).toEqual([]);
});
test('form is accessible', async ({ page }) => { await page.goto('/contact');
const results = await new AxeBuilder({ page })
.include('form')
.analyze();
expect(results.violations).toEqual([]);
}); });
Performance Testing
test('page loads within performance budget', async ({ page }) => { await page.goto('/');
const metrics = await page.evaluate(() => JSON.stringify(window.performance.timing) ); const timing = JSON.parse(metrics);
const loadTime = timing.loadEventEnd - timing.navigationStart; expect(loadTime).toBeLessThan(3000); // 3 seconds });
test('tracks Core Web Vitals', async ({ page }) => { await page.goto('/');
const lcp = await page.evaluate(() => { return new Promise(resolve => { new PerformanceObserver(list => { const entries = list.getEntries(); resolve(entries[entries.length - 1].startTime); }).observe({ type: 'largest-contentful-paint', buffered: true }); }); });
expect(lcp).toBeLessThan(2500); // Good LCP is < 2.5s });
Debug Helpers
Debugging Commands
Run with headed browser
npx playwright test --headed
Run with debugging UI
npx playwright test --debug
Run specific test
npx playwright test -g "test name"
Show report
npx playwright show-report
In-Test Debugging
test('debug example', async ({ page }) => { await page.goto('/');
// Pause execution await page.pause();
// Take screenshot await page.screenshot({ path: 'debug.png' });
// Log to console console.log(await page.content());
// Slow down await page.setDefaultTimeout(30000); });
Console Logs
Python: Capture browser console
page.on('console', lambda msg: print(f'Browser log: {msg.text}')) page.on('pageerror', lambda err: print(f'Browser error: {err}'))
// TypeScript: Capture browser console page.on('console', msg => console.log('Browser:', msg.text())); page.on('pageerror', err => console.log('Error:', err));
Test Organization
File Structure
tests/ ├── e2e/ │ ├── auth/ │ │ ├── login.spec.ts │ │ └── logout.spec.ts │ ├── products/ │ │ ├── listing.spec.ts │ │ └── details.spec.ts │ └── checkout/ │ └── flow.spec.ts ├── fixtures/ │ └── test-data.ts └── utils/ └── helpers.ts
Custom Fixtures
// fixtures/test-fixtures.ts import { test as base } from '@playwright/test';
type Fixtures = { authenticatedPage: Page; };
export const test = base.extend<Fixtures>({ authenticatedPage: async ({ page }, use) => { await page.goto('/login'); await page.fill('[name="email"]', 'test@example.com'); await page.fill('[name="password"]', 'password'); await page.click('button[type="submit"]'); await page.waitForURL('/dashboard'); await use(page); }, });
// Usage test('authenticated test', async ({ authenticatedPage }) => { await authenticatedPage.goto('/profile'); // Already logged in });
Common Pitfalls
Pitfall Problem Solution
Race conditions Test checks before page updates Use waitFor or expect with retries
Flaky selectors CSS classes change Use data-testid or role selectors
Hard-coded waits page.waitForTimeout(3000)
Wait for specific conditions
Not waiting for hydration JS not executed waitForLoadState('networkidle')
Shared state Tests affect each other Use isolated storage/auth per test
Ignoring errors Uncaught exceptions Check page.on('pageerror')
Checklist
-
Use stable selectors (test IDs, roles, labels)
-
Wait for appropriate conditions (not arbitrary timeouts)
-
Test both happy path and error states
-
Include accessibility checks
-
Test responsive breakpoints
-
Mock external API dependencies
-
Capture screenshots on failure
-
Run tests in CI/CD
-
Keep tests independent
-
Organize tests by feature