| name | playwright |
| description | Use this skill when writing, reviewing, or debugging Playwright tests. Covers test structure, selectors (getByRole, getByLabel, getByTestId), assertions, page object model, parallel execution, waiting strategies, API testing, accessibility testing with axe-core, visual regression, and authentication patterns. Trigger when the user mentions Playwright, e2e tests, browser automation, or references .spec.ts test files. |
Playwright Testing Skill
Test Structure
File Organization
- Place tests in a
tests/ or e2e/ directory
- Group by feature or user flow:
tests/auth/, tests/checkout/, tests/dashboard/
- Name files descriptively:
login.spec.ts, cart-checkout.spec.ts
- Co-locate page objects in
tests/pages/ or tests/fixtures/
Test Anatomy
import { test, expect } from '@playwright/test';
test.describe('Feature Name', () => {
test('should do something specific when condition is met', async ({ page }) => {
await page.goto('/path');
await page.getByRole('button', { name: 'Submit' }).click();
await expect(page.getByText('Success')).toBeVisible();
});
});
Naming Conventions
- Use
test.describe to group related tests by feature
- Test names should read as sentences:
should display error when email is invalid
- Avoid vague names like
test 1, it works, check stuff
Selectors
Preferred (most stable → least stable)
-
getByRole — best for accessibility and stability
page.getByRole('button', { name: 'Submit' })
page.getByRole('heading', { name: 'Dashboard' })
page.getByRole('link', { name: 'Sign out' })
-
getByLabel — for form inputs
page.getByLabel('Email address')
page.getByLabel('Password')
-
getByPlaceholder — when labels aren't visible
page.getByPlaceholder('Search...')
-
getByText — for visible text content
page.getByText('Welcome back')
page.getByText(/total: \$\d+/i)
-
getByTestId — when semantic selectors aren't practical
page.getByTestId('user-avatar')
Avoid
- CSS selectors tied to styling:
.btn-primary, .card-header
- XPath expressions
:nth-child or index-based selectors
- Auto-generated class names (CSS modules, Tailwind JIT)
Assertions
Common Patterns
await expect(page.getByText('Welcome')).toBeVisible();
await expect(page.getByTestId('modal')).toBeHidden();
await expect(page.getByRole('heading')).toHaveText('Dashboard');
await expect(page.getByRole('alert')).toContainText('saved');
await expect(page.getByLabel('Email')).toHaveValue('user@example.com');
await expect(page.getByRole('button')).toBeDisabled();
await expect(page.getByRole('button')).toBeEnabled();
await expect(page.getByRole('listitem')).toHaveCount(5);
await expect(page).toHaveURL('/dashboard');
await expect(page).toHaveURL(/\/dashboard/);
await expect(page).toHaveTitle('My App - Dashboard');
Always Use Web-First Assertions
Prefer expect(locator) over expect(await locator.textContent()):
await expect(page.getByText('Loaded')).toBeVisible();
const text = await page.textContent('.status');
expect(text).toBe('Loaded');
Page Object Model
Structure
import { type Page, type Locator } 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: 'Sign 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();
}
}
Usage in Tests
import { test, expect } from '@playwright/test';
import { LoginPage } from './pages/login-page';
test('should show error for invalid credentials', async ({ page }) => {
const loginPage = new LoginPage(page);
await loginPage.goto();
await loginPage.login('bad@email.com', 'wrongpassword');
await expect(loginPage.errorMessage).toContainText('Invalid credentials');
});
Guidelines
- Page objects expose actions (methods) and elements (locators), not assertions
- Keep assertions in the test file, not in the page object
- One page object per page or major component
- Reuse page objects across test files
Parallel Execution
Rules
- Tests must be independently runnable — no shared state between files
- Use unique test data per test (unique emails, unique names, timestamps)
- Never depend on execution order
- Clean up created data in
afterEach or use isolated contexts
Configuration
export default defineConfig({
workers: 5,
fullyParallel: true,
reporter: [['html', { open: 'never' }]],
});
Isolation Patterns
test('should create user', async ({ page }) => {
const uniqueEmail = `test-${Date.now()}@example.com`;
});
test.beforeEach(async ({ page }) => {
await page.goto('/');
});
Waiting & Timing
Do
await page.waitForURL('/dashboard');
await page.getByRole('button', { name: 'Save' }).click();
await page.waitForResponse(resp => resp.url().includes('/api/save') && resp.status() === 200);
await expect(page.getByText('Saved')).toBeVisible({ timeout: 10000 });
Don't
await page.waitForTimeout(3000);
while (!(await page.isVisible('.loaded'))) { }
API Testing with Playwright
Using request Context
import { test, expect } from '@playwright/test';
test('GET /api/users returns user list', async ({ request }) => {
const response = await request.get('/api/users');
expect(response.ok()).toBeTruthy();
expect(response.status()).toBe(200);
const body = await response.json();
expect(body).toHaveLength(10);
expect(body[0]).toHaveProperty('email');
});
test('POST /api/users creates a user', async ({ request }) => {
const response = await request.post('/api/users', {
data: { name: 'Test User', email: `test-${Date.now()}@example.com` },
});
expect(response.status()).toBe(201);
});
Accessibility Testing
With axe-core
import { test, expect } from '@playwright/test';
import AxeBuilder from '@axe-core/playwright';
test('should have no accessibility violations', async ({ page }) => {
await page.goto('/');
const results = await new AxeBuilder({ page }).analyze();
expect(results.violations).toEqual([]);
});
test('navigation should be accessible', async ({ page }) => {
await page.goto('/');
const results = await new AxeBuilder({ page })
.include('nav')
.withTags(['wcag2a', 'wcag2aa'])
.analyze();
expect(results.violations).toEqual([]);
});
Visual Regression Testing
test('homepage matches baseline', async ({ page }) => {
await page.goto('/');
await expect(page).toHaveScreenshot('homepage.png');
});
test('mobile layout matches baseline', async ({ page }) => {
await page.setViewportSize({ width: 375, height: 812 });
await page.goto('/');
await expect(page).toHaveScreenshot('homepage-mobile.png', {
maxDiffPixelRatio: 0.002,
});
});
test('header matches baseline', async ({ page }) => {
await page.goto('/');
const header = page.getByRole('banner');
await expect(header).toHaveScreenshot('header.png');
});
Authentication Patterns
Reuse Auth State
import { test as setup } from '@playwright/test';
setup('authenticate', 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: 'Sign in' }).click();
await page.waitForURL('/dashboard');
await page.context().storageState({ path: '.auth/user.json' });
});
export default defineConfig({
projects: [
{ name: 'setup', testMatch: /.*\.setup\.ts/ },
{
name: 'tests',
dependencies: ['setup'],
use: { storageState: '.auth/user.json' },
},
],
});
Common Anti-Patterns
| Anti-Pattern | Problem | Better Approach |
|---|
page.waitForTimeout(3000) | Arbitrary wait, flaky | Use web-first assertions or waitForResponse |
page.$('.class-name') | Brittle CSS selector | Use getByRole, getByLabel, getByTestId |
| Shared database state between tests | Tests fail when run in parallel | Use unique data per test, clean up in afterEach |
| Assertions inside page objects | Couples page logic with test logic | Keep assertions in test files only |
test.describe.serial everywhere | Prevents parallelization | Design tests to be independent |
| Hardcoded URLs | Breaks across environments | Use baseURL in config |
Checking innerText directly | No auto-retry | Use expect(locator).toHaveText() |