| name | Playwright Expert |
| description | Expert in Playwright end-to-end testing for web applications. Use when writing E2E tests, debugging Playwright tests, setting up test infrastructure, handling test flakiness, or when the user mentions Playwright, browser testing, E2E testing, or UI automation. |
Playwright Expert
A specialized skill for creating robust, maintainable end-to-end tests using Playwright. This skill helps you write reliable browser automation tests for modern web applications.
Instructions
Core Workflow
Understand testing requirements
- Ask what needs to be tested (user flows, features, pages)
- Identify the application framework (React, Vue, Angular, etc.)
- Determine test scope (smoke tests, regression, full E2E suite)
- Ask about existing test infrastructure
Setup and configuration
- Verify Playwright installation
- Configure playwright.config.ts appropriately
- Set up test project structure
- Configure browsers, viewports, base URLs
- Set up CI/CD integration if needed
Write maintainable tests
- Use Page Object Model (POM) pattern for complex apps
- Implement proper selectors (prefer data-testid, role-based)
- Add proper waits and assertions
- Handle authentication and state management
- Write reusable fixtures and helpers
Debug and fix flaky tests
- Use Playwright's debugging tools
- Identify race conditions and timing issues
- Implement proper wait strategies
- Add trace collection for failures
Best practices
- Keep tests independent and isolated
- Use meaningful test descriptions
- Implement proper cleanup and teardown
- Optimize test execution (parallel, sharding)
Playwright Best Practices
Selector Strategy (Priority Order)
User-facing attributes (best):
await page.getByRole('button', { name: 'Submit' }); await page.getByLabel('Email address'); await page.getByPlaceholder('Enter email'); await page.getByText('Welcome');Data-testid (good for non-semantic elements):
await page.getByTestId('user-profile');CSS/XPath (avoid if possible, brittle):
await page.locator('.submit-button');
Wait Strategies
// Auto-waiting (preferred - built into Playwright)
await page.click('button'); // Automatically waits for element
// Explicit waits when needed
await page.waitForSelector('[data-testid="results"]');
await page.waitForResponse(resp => resp.url().includes('/api/data'));
await page.waitForLoadState('networkidle');
// Assertions with auto-retry
await expect(page.getByText('Success')).toBeVisible();
await expect(page).toHaveURL(/dashboard/);
Page Object Model Pattern
// pages/LoginPage.ts
export class LoginPage {
readonly page: Page;
readonly emailInput: Locator;
readonly passwordInput: Locator;
readonly submitButton: 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' });
}
async login(email: string, password: string) {
await this.emailInput.fill(email);
await this.passwordInput.fill(password);
await this.submitButton.click();
await this.page.waitForURL(/dashboard/);
}
async goto() {
await this.page.goto('/login');
}
}
// test.spec.ts
test('user can login', async ({ page }) => {
const loginPage = new LoginPage(page);
await loginPage.goto();
await loginPage.login('user@example.com', 'password');
await expect(page.getByText('Welcome')).toBeVisible();
});
Fixtures for Reusability
// fixtures.ts
import { test as base } from '@playwright/test';
type Fixtures = {
authenticatedPage: Page;
};
export const test = base.extend<Fixtures>({
authenticatedPage: async ({ page }, use) => {
// Login before each test
await page.goto('/login');
await page.fill('[name="email"]', 'user@example.com');
await page.fill('[name="password"]', 'password');
await page.click('button[type="submit"]');
await page.waitForURL(/dashboard/);
await use(page);
// Cleanup if needed
},
});
// Usage
test('view profile', async ({ authenticatedPage }) => {
await authenticatedPage.goto('/profile');
// Test authenticated features
});
API Mocking and Interception
// Mock API responses
await page.route('**/api/users', route => {
route.fulfill({
status: 200,
body: JSON.stringify([
{ id: 1, name: 'John Doe' },
{ id: 2, name: 'Jane Smith' }
])
});
});
// Intercept and modify requests
await page.route('**/api/config', route => {
const response = await route.fetch();
const json = await response.json();
json.feature_flag_enabled = true;
route.fulfill({ response, json });
});
Handling Authentication
// Save auth state
test('save auth state', async ({ page }) => {
await page.goto('/login');
await page.fill('[name="email"]', 'user@example.com');
await page.fill('[name="password"]', 'password');
await page.click('button[type="submit"]');
await page.waitForURL(/dashboard/);
// Save storage state
await page.context().storageState({ path: 'auth.json' });
});
// Reuse auth state in config
// playwright.config.ts
export default defineConfig({
use: {
storageState: 'auth.json',
},
});
Screenshot and Video
// Take screenshots
await page.screenshot({ path: 'screenshot.png', fullPage: true });
// Configure in playwright.config.ts
export default defineConfig({
use: {
screenshot: 'only-on-failure',
video: 'retain-on-failure',
trace: 'retain-on-failure',
},
});
Debugging Flaky Tests
Common Causes and Solutions
Race Conditions
// Bad: Doesn't wait for API call await page.click('button'); await expect(page.getByText('Success')).toBeVisible(); // Good: Wait for API response await Promise.all([ page.waitForResponse(resp => resp.url().includes('/api/submit')), page.click('button') ]); await expect(page.getByText('Success')).toBeVisible();Animation/Transition Issues
// Disable animations in test mode // playwright.config.ts use: { actionTimeout: 10000, navigationTimeout: 30000, } // Or add CSS to disable animations await page.addStyleTag({ content: '* { animation: none !important; transition: none !important; }' });Non-deterministic Selectors
// Bad: Order-dependent await page.locator('.user-card').first().click(); // Good: Specific identifier await page.getByTestId('user-card-123').click();Timing Issues
// Use Playwright's auto-waiting instead of manual delays // Bad await page.click('button'); await page.waitForTimeout(3000); // Arbitrary delay // Good await page.click('button'); await page.waitForSelector('[data-testid="result"]'); // or await expect(page.getByTestId('result')).toBeVisible();
Debug Tools
// Debug mode
await page.pause(); // Opens Playwright Inspector
// Slow down execution
test.use({ launchOptions: { slowMo: 100 } });
// Enable verbose logging
DEBUG=pw:api npx playwright test
// Trace viewer (after test runs with trace: 'on')
npx playwright show-trace trace.zip
Configuration Best Practices
// playwright.config.ts
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'],
['json', { outputFile: 'test-results.json' }],
['junit', { outputFile: 'test-results.xml' }]
],
use: {
baseURL: process.env.BASE_URL || 'http://localhost:3000',
trace: 'retain-on-failure',
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'] },
},
// Mobile
{
name: 'Mobile Chrome',
use: { ...devices['Pixel 5'] },
},
],
webServer: {
command: 'npm run dev',
url: 'http://localhost:3000',
reuseExistingServer: !process.env.CI,
},
});
CI/CD Integration
# GitHub Actions example
name: Playwright 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 Playwright tests
run: npx playwright test
- uses: actions/upload-artifact@v3
if: always()
with:
name: playwright-report
path: playwright-report/
retention-days: 30
Critical Rules
Always Do
- Use auto-waiting features (don't add arbitrary timeouts)
- Prefer user-facing selectors (getByRole, getByLabel, etc.)
- Keep tests independent (no shared state between tests)
- Use Page Object Model for complex applications
- Add proper assertions (use expect with auto-retry)
- Enable traces and screenshots for debugging
- Run tests in parallel when possible
- Use fixtures for common setup
Never Do
- Never use
waitForTimeout()unless absolutely necessary - Never rely on brittle CSS selectors (prefer data-testid or semantic selectors)
- Never share state between tests
- Never ignore flaky tests (fix them!)
- Never commit
.auth.jsonor sensitive data - Never use
first(),last(),nth()without good reason (non-deterministic)
Knowledge Base
- Playwright API: Expert in all Playwright APIs and features
- Browser Automation: Deep understanding of browser automation challenges
- Test Patterns: Page Object Model, fixtures, custom matchers
- Debugging: Proficient in debugging flaky tests and race conditions
- CI/CD: Experience integrating Playwright with various CI systems
- Performance: Understanding test optimization and parallelization
Integration with Other Skills
- Works with: Test Master (overall testing strategy), React Expert (testing React apps)
- Complements: Frontend framework skills for framework-specific testing approaches
Examples
Example 1: E2E Test for User Registration
import { test, expect } from '@playwright/test';
test.describe('User Registration', () => {
test('successful registration flow', async ({ page }) => {
await page.goto('/register');
// Fill form
await page.getByLabel('Email').fill('newuser@example.com');
await page.getByLabel('Password').fill('SecurePass123!');
await page.getByLabel('Confirm Password').fill('SecurePass123!');
await page.getByLabel(/I agree to the terms/).check();
// Submit and wait for navigation
await page.getByRole('button', { name: 'Sign Up' }).click();
// Verify success
await expect(page).toHaveURL(/dashboard/);
await expect(page.getByText('Welcome')).toBeVisible();
});
test('validates email format', async ({ page }) => {
await page.goto('/register');
await page.getByLabel('Email').fill('invalid-email');
await page.getByLabel('Password').fill('SecurePass123!');
await page.getByRole('button', { name: 'Sign Up' }).click();
await expect(page.getByText('Please enter a valid email')).toBeVisible();
await expect(page).toHaveURL(/register/);
});
});
Example 2: Testing with API Mocking
test('displays user list from API', async ({ page }) => {
// Mock the API response
await page.route('**/api/users', route => {
route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify({
users: [
{ id: 1, name: 'Alice', email: 'alice@example.com' },
{ id: 2, name: 'Bob', email: 'bob@example.com' }
]
})
});
});
await page.goto('/users');
// Verify mocked data is displayed
await expect(page.getByText('Alice')).toBeVisible();
await expect(page.getByText('Bob')).toBeVisible();
});
Best Practices Summary
- Selector Priority: Role > Label > Placeholder > Test ID > CSS/XPath
- Waiting: Use auto-waiting, avoid
waitForTimeout() - Independence: Each test should run in isolation
- Patterns: Use Page Object Model for maintainability
- Debugging: Enable traces, screenshots, and videos on failure
- Performance: Run tests in parallel, use sharding for large suites
- Stability: Fix flaky tests immediately, don't retry indefinitely
- Assertions: Use Playwright's expect with built-in retry logic
- Authentication: Save and reuse auth state
- CI/CD: Integrate early, run on every commit