Comprehensive Playwright end-to-end testing expertise covering browser automation, cross-browser testing, visual regression, API testing, mobile emulation, accessibility testing, test architecture, page object models, fixtures, parallel execution, CI/CD integration, debugging strategies, and production-grade E2E test patterns. Activates for playwright, e2e testing, end-to-end testing, browser automation, cross-browser testing, visual testing, screenshot testing, API testing, mobile testing, accessibility testing, test fixtures, page object model, POM, test architecture, parallel testing, playwright config, trace viewer, codegen, test debugging, flaky tests, CI integration, playwright best practices.
Inherits all available tools
Additional assets for this skill
This skill inherits all available tools. When active, it can use any tool Claude has access to.
Browser Automation:
Test Structure:
import { test, expect } from '@playwright/test';
test.describe('Authentication Flow', () => {
test.beforeEach(async ({ page }) => {
await page.goto('/login');
});
test('should login successfully', async ({ page }) => {
await page.getByLabel('Email').fill('user@example.com');
await page.getByLabel('Password').fill('password123');
await page.getByRole('button', { name: 'Login' }).click();
await expect(page).toHaveURL('/dashboard');
await expect(page.getByText('Welcome back')).toBeVisible();
});
test('should show validation errors', async ({ page }) => {
await page.getByRole('button', { name: 'Login' }).click();
await expect(page.getByText('Email is required')).toBeVisible();
await expect(page.getByText('Password is required')).toBeVisible();
});
});
Pattern: Encapsulate page interactions for maintainability
// pages/LoginPage.ts
import { Page, Locator } from '@playwright/test';
export class LoginPage {
readonly page: Page;
readonly emailInput: Locator;
readonly passwordInput: Locator;
readonly loginButton: Locator;
readonly errorMessage: Locator;
constructor(page: Page) {
this.page = page;
this.emailInput = page.getByLabel('Email');
this.passwordInput = page.getByLabel('Password');
this.loginButton = page.getByRole('button', { name: 'Login' });
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.loginButton.click();
}
async loginWithGoogle() {
await this.page.getByRole('button', { name: 'Continue with Google' }).click();
// Handle OAuth popup
}
async expectError(message: string) {
await expect(this.errorMessage).toContainText(message);
}
}
// Usage in tests
test('login flow', async ({ page }) => {
const loginPage = new LoginPage(page);
await loginPage.goto();
await loginPage.login('user@example.com', 'password123');
await expect(page).toHaveURL('/dashboard');
});
Fixtures: Reusable setup/teardown logic
// fixtures/auth.fixture.ts
import { test as base } from '@playwright/test';
import { LoginPage } from '../pages/LoginPage';
type AuthFixtures = {
authenticatedPage: Page;
loginPage: LoginPage;
};
export const test = base.extend<AuthFixtures>({
authenticatedPage: async ({ page }, use) => {
// Setup: Login before test
await page.goto('/login');
await page.getByLabel('Email').fill('user@example.com');
await page.getByLabel('Password').fill('password123');
await page.getByRole('button', { name: 'Login' }).click();
await page.waitForURL('/dashboard');
await use(page);
// Teardown: Logout after test
await page.getByRole('button', { name: 'Logout' }).click();
},
loginPage: async ({ page }, use) => {
const loginPage = new LoginPage(page);
await loginPage.goto();
await use(loginPage);
},
});
export { expect } from '@playwright/test';
// Usage
test('authenticated user can view profile', async ({ authenticatedPage }) => {
await authenticatedPage.goto('/profile');
await expect(authenticatedPage.getByText('Profile Settings')).toBeVisible();
});
Pattern: Test backend APIs alongside E2E flows
import { test, expect } from '@playwright/test';
test.describe('API Testing', () => {
test('should fetch user data', async ({ request }) => {
const response = await request.get('/api/users/123');
expect(response.ok()).toBeTruthy();
expect(response.status()).toBe(200);
const data = await response.json();
expect(data).toMatchObject({
id: 123,
email: expect.any(String),
name: expect.any(String),
});
});
test('should handle authentication', async ({ request }) => {
const response = await request.post('/api/auth/login', {
data: {
email: 'user@example.com',
password: 'password123',
},
});
expect(response.ok()).toBeTruthy();
const { token } = await response.json();
expect(token).toBeTruthy();
// Use token in subsequent requests
const profileResponse = await request.get('/api/profile', {
headers: {
Authorization: `Bearer ${token}`,
},
});
expect(profileResponse.ok()).toBeTruthy();
});
test('should mock API responses', async ({ page }) => {
await page.route('/api/users', async (route) => {
await route.fulfill({
status: 200,
body: JSON.stringify([
{ id: 1, name: 'John Doe' },
{ id: 2, name: 'Jane Smith' },
]),
});
});
await page.goto('/users');
await expect(page.getByText('John Doe')).toBeVisible();
await expect(page.getByText('Jane Smith')).toBeVisible();
});
});
Pattern: Screenshot comparison for UI changes
import { test, expect } from '@playwright/test';
test.describe('Visual Regression', () => {
test('homepage matches baseline', async ({ page }) => {
await page.goto('/');
await expect(page).toHaveScreenshot('homepage.png', {
fullPage: true,
animations: 'disabled',
});
});
test('component states', async ({ page }) => {
await page.goto('/components');
// Default state
const button = page.getByRole('button', { name: 'Submit' });
await expect(button).toHaveScreenshot('button-default.png');
// Hover state
await button.hover();
await expect(button).toHaveScreenshot('button-hover.png');
// Disabled state
await page.evaluate(() => {
document.querySelector('button')?.setAttribute('disabled', 'true');
});
await expect(button).toHaveScreenshot('button-disabled.png');
});
test('responsive screenshots', async ({ page }) => {
await page.goto('/');
// Desktop
await page.setViewportSize({ width: 1920, height: 1080 });
await expect(page).toHaveScreenshot('homepage-desktop.png');
// Tablet
await page.setViewportSize({ width: 768, height: 1024 });
await expect(page).toHaveScreenshot('homepage-tablet.png');
// Mobile
await page.setViewportSize({ width: 375, height: 667 });
await expect(page).toHaveScreenshot('homepage-mobile.png');
});
});
Pattern: Test responsive behavior and touch interactions
import { test, expect, devices } from '@playwright/test';
test.use(devices['iPhone 13 Pro']);
test.describe('Mobile Experience', () => {
test('should render mobile navigation', async ({ page }) => {
await page.goto('/');
// Mobile menu should be visible
await expect(page.getByRole('button', { name: 'Menu' })).toBeVisible();
// Desktop nav should be hidden
await expect(page.getByRole('navigation').first()).toBeHidden();
});
test('touch gestures', async ({ page }) => {
await page.goto('/gallery');
const image = page.getByRole('img').first();
// Swipe left
await image.dispatchEvent('touchstart', { touches: [{ clientX: 300, clientY: 200 }] });
await image.dispatchEvent('touchmove', { touches: [{ clientX: 100, clientY: 200 }] });
await image.dispatchEvent('touchend');
await expect(page.getByText('Next Image')).toBeVisible();
});
test('landscape orientation', async ({ page }) => {
await page.setViewportSize({ width: 812, height: 375 }); // iPhone landscape
await page.goto('/video');
await expect(page.locator('video')).toHaveCSS('width', '100%');
});
});
// Test across multiple devices
for (const deviceName of ['iPhone 13', 'Pixel 5', 'iPad Pro']) {
test.describe(`Device: ${deviceName}`, () => {
test.use(devices[deviceName]);
test('critical user flow', async ({ page }) => {
await page.goto('/');
// Test critical flow on each device
});
});
}
Pattern: Automated accessibility checks
import { test, expect } from '@playwright/test';
import AxeBuilder from '@axe-core/playwright';
test.describe('Accessibility', () => {
test('should not have accessibility violations', async ({ page }) => {
await page.goto('/');
const accessibilityScanResults = await new AxeBuilder({ page })
.withTags(['wcag2a', 'wcag2aa', 'wcag21a', 'wcag21aa'])
.analyze();
expect(accessibilityScanResults.violations).toEqual([]);
});
test('keyboard navigation', async ({ page }) => {
await page.goto('/form');
// Tab through form fields
await page.keyboard.press('Tab');
await expect(page.getByLabel('Email')).toBeFocused();
await page.keyboard.press('Tab');
await expect(page.getByLabel('Password')).toBeFocused();
await page.keyboard.press('Tab');
await expect(page.getByRole('button', { name: 'Submit' })).toBeFocused();
// Submit with Enter
await page.keyboard.press('Enter');
});
test('screen reader support', async ({ page }) => {
await page.goto('/');
// Check ARIA labels
await expect(page.getByRole('navigation', { name: 'Main' })).toBeVisible();
await expect(page.getByRole('main')).toHaveAttribute('aria-label', 'Main content');
// Check alt text
const images = page.getByRole('img');
for (const img of await images.all()) {
await expect(img).toHaveAttribute('alt');
}
});
});
Pattern: Monitor performance metrics
import { test, expect } from '@playwright/test';
test.describe('Performance', () => {
test('page load performance', async ({ page }) => {
await page.goto('/');
const performanceMetrics = await page.evaluate(() => {
const perfData = window.performance.timing;
return {
loadTime: perfData.loadEventEnd - perfData.navigationStart,
domContentLoaded: perfData.domContentLoadedEventEnd - perfData.navigationStart,
firstPaint: performance.getEntriesByType('paint')[0]?.startTime || 0,
};
});
expect(performanceMetrics.loadTime).toBeLessThan(3000); // 3s max
expect(performanceMetrics.domContentLoaded).toBeLessThan(2000); // 2s max
});
test('Core Web Vitals', async ({ page }) => {
await page.goto('/');
const vitals = await page.evaluate(() => {
return new Promise((resolve) => {
new PerformanceObserver((list) => {
const entries = list.getEntries();
const lcp = entries.find(e => e.entryType === 'largest-contentful-paint');
const fid = entries.find(e => e.entryType === 'first-input');
const cls = entries.find(e => e.entryType === 'layout-shift');
resolve({ lcp: lcp?.startTime, fid: fid?.processingStart, cls: cls?.value });
}).observe({ entryTypes: ['largest-contentful-paint', 'first-input', 'layout-shift'] });
});
});
expect(vitals.lcp).toBeLessThan(2500); // Good LCP
expect(vitals.fid).toBeLessThan(100); // Good FID
expect(vitals.cls).toBeLessThan(0.1); // Good CLS
});
});
playwright.config.ts:
import { defineConfig, devices } from '@playwright/test';
export default defineConfig({
testDir: './e2e',
fullyParallel: true,
forbidOnly: !!process.env.CI,
retries: process.env.CI ? 2 : 0,
workers: process.env.CI ? 1 : undefined,
reporter: [
['html'],
['junit', { outputFile: 'test-results/junit.xml' }],
['json', { outputFile: 'test-results/results.json' }],
],
use: {
baseURL: process.env.BASE_URL || 'http://localhost:3000',
trace: 'on-first-retry',
screenshot: 'only-on-failure',
video: 'retain-on-failure',
},
projects: [
// Desktop browsers
{
name: 'chromium',
use: { ...devices['Desktop Chrome'] },
},
{
name: 'firefox',
use: { ...devices['Desktop Firefox'] },
},
{
name: 'webkit',
use: { ...devices['Desktop Safari'] },
},
// Mobile browsers
{
name: 'Mobile Chrome',
use: { ...devices['Pixel 5'] },
},
{
name: 'Mobile Safari',
use: { ...devices['iPhone 13'] },
},
// Tablet browsers
{
name: 'iPad',
use: { ...devices['iPad Pro'] },
},
],
webServer: {
command: 'npm run dev',
url: 'http://localhost:3000',
reuseExistingServer: !process.env.CI,
timeout: 120 * 1000,
},
});
GitHub Actions:
name: E2E Tests
on: [push, pull_request]
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
- uses: actions/setup-node@v3
with:
node-version: 18
- name: Install dependencies
run: npm ci
- name: Install Playwright browsers
run: npx playwright install --with-deps
- name: Run E2E tests
run: npm run test:e2e
env:
BASE_URL: https://staging.example.com
- name: Upload test results
if: always()
uses: actions/upload-artifact@v3
with:
name: playwright-report
path: playwright-report/
retention-days: 30
- name: Upload traces
if: failure()
uses: actions/upload-artifact@v3
with:
name: playwright-traces
path: test-results/
Tools & Techniques:
// 1. Debug mode (headed browser + slow motion)
test('debug example', async ({ page }) => {
await page.goto('/');
await page.pause(); // Pauses execution, opens inspector
});
// 2. Console logs
test('capture console', async ({ page }) => {
page.on('console', msg => console.log(`Browser: ${msg.text()}`));
await page.goto('/');
});
// 3. Network inspection
test('inspect network', async ({ page }) => {
page.on('request', request => console.log('Request:', request.url()));
page.on('response', response => console.log('Response:', response.status()));
await page.goto('/');
});
// 4. Screenshots on failure
test.afterEach(async ({ page }, testInfo) => {
if (testInfo.status !== testInfo.expectedStatus) {
await page.screenshot({
path: `screenshots/${testInfo.title}.png`,
fullPage: true
});
}
});
// 5. Trace viewer
// Run: npx playwright test --trace on
// View: npx playwright show-trace trace.zip
Common Debugging Commands:
# Run in headed mode (see browser)
npx playwright test --headed
# Run with UI mode (interactive debugging)
npx playwright test --ui
# Run single test
npx playwright test tests/login.spec.ts
# Debug specific test
npx playwright test tests/login.spec.ts --debug
# Generate test code
npx playwright codegen http://localhost:3000
Patterns for Reliability:
// 1. Proper waiting strategies
test('wait for content', async ({ page }) => {
await page.goto('/');
// ❌ BAD: Fixed delays
// await page.waitForTimeout(5000);
// ✅ GOOD: Wait for specific conditions
await page.waitForLoadState('networkidle');
await page.waitForSelector('.content', { state: 'visible' });
await page.getByText('Welcome').waitFor();
});
// 2. Retry logic for external dependencies
test('api with retry', async ({ page }) => {
await page.goto('/');
let retries = 3;
while (retries > 0) {
try {
const response = await page.waitForResponse(
response => response.url().includes('/api/data') && response.ok(),
{ timeout: 5000 }
);
expect(response.ok()).toBeTruthy();
break;
} catch (error) {
retries--;
if (retries === 0) throw error;
await page.reload();
}
}
});
// 3. Test isolation
test.describe.configure({ mode: 'parallel' });
test.beforeEach(async ({ page }) => {
// Clear state before each test
await page.context().clearCookies();
await page.context().clearPermissions();
});
// 4. Deterministic test data
test('use fixtures', async ({ page }) => {
// Seed database with known data
await page.request.post('/api/test/seed', {
data: { userId: 'test-123', email: 'test@example.com' }
});
await page.goto('/users/test-123');
await expect(page.getByText('test@example.com')).toBeVisible();
// Cleanup
await page.request.delete('/api/test/users/test-123');
});
e2e/
├── fixtures/
│ ├── auth.fixture.ts
│ ├── data.fixture.ts
│ └── mock.fixture.ts
├── pages/
│ ├── LoginPage.ts
│ ├── DashboardPage.ts
│ └── ProfilePage.ts
├── tests/
│ ├── auth/
│ │ ├── login.spec.ts
│ │ ├── signup.spec.ts
│ │ └── logout.spec.ts
│ ├── user/
│ │ ├── profile.spec.ts
│ │ └── settings.spec.ts
│ └── api/
│ ├── users.spec.ts
│ └── posts.spec.ts
└── playwright.config.ts
*.spec.ts or *.test.ts*Page.ts*.fixture.tsshould allow user to login with valid credentials// global-setup.ts
import { chromium, FullConfig } from '@playwright/test';
async function globalSetup(config: FullConfig) {
const browser = await chromium.launch();
const page = await browser.newPage();
await page.goto('http://localhost:3000/login');
await page.getByLabel('Email').fill('user@example.com');
await page.getByLabel('Password').fill('password123');
await page.getByRole('button', { name: 'Login' }).click();
await page.waitForURL('http://localhost:3000/dashboard');
// Save signed-in state
await page.context().storageState({ path: 'auth.json' });
await browser.close();
}
export default globalSetup;
// playwright.config.ts
export default defineConfig({
globalSetup: require.resolve('./global-setup'),
use: {
storageState: 'auth.json',
},
});
test('open in new tab', async ({ context }) => {
const page = await context.newPage();
await page.goto('/');
const [newPage] = await Promise.all([
context.waitForEvent('page'),
page.getByRole('link', { name: 'Open in new tab' }).click()
]);
await newPage.waitForLoadState();
await expect(newPage).toHaveURL('/new-page');
});
test('upload file', async ({ page }) => {
await page.goto('/upload');
const fileChooserPromise = page.waitForEvent('filechooser');
await page.getByRole('button', { name: 'Upload' }).click();
const fileChooser = await fileChooserPromise;
await fileChooser.setFiles('path/to/file.pdf');
await expect(page.getByText('file.pdf uploaded')).toBeVisible();
});
test('download file', async ({ page }) => {
await page.goto('/downloads');
const downloadPromise = page.waitForEvent('download');
await page.getByRole('link', { name: 'Download Report' }).click();
const download = await downloadPromise;
await download.saveAs(`/tmp/${download.suggestedFilename()}`);
expect(download.suggestedFilename()).toBe('report.pdf');
});