Provides Playwright test patterns for resilient locators, Page Object Models, fixtures, web-first assertions, and network mocking. Must use when writing or modifying Playwright tests (.spec.ts, .test.ts files with @playwright/test imports).
97
Quality
96%
Does it follow best practices?
Impact
100%
1.36xAverage score across 3 eval scenarios
Passed
No known issues
When running Playwright tests from Claude Code or any CLI agent, always use minimal reporters to prevent verbose output from consuming the context window.
Use --reporter=line or --reporter=dot for CLI test runs:
# REQUIRED: Use minimal reporter to prevent context overflow
npx playwright test --reporter=line
npx playwright test --reporter=dot
# BAD: Default reporter generates thousands of lines, floods context
npx playwright testConfigure playwright.config.ts to use minimal reporters by default when CI or CLAUDE env vars are set:
reporter: process.env.CI || process.env.CLAUDE
? [['line'], ['html', { open: 'never' }]]
: 'list',Always prefer user-facing attributes:
page.getByRole('button', { name: 'Submit' }) - accessibility rolespage.getByLabel('Email') - form control labelspage.getByPlaceholder('Search...') - input placeholderspage.getByText('Welcome') - visible text (non-interactive)page.getByAltText('Logo') - image alt textpage.getByTitle('Settings') - title attributespage.getByTestId('submit-btn') - explicit test contracts// BAD: Brittle selectors tied to implementation
page.locator('button.btn-primary.submit-form')
page.locator('//div[@class="container"]/form/button')
page.locator('#app > div:nth-child(2) > button')
// GOOD: User-facing, resilient locators
page.getByRole('button', { name: 'Submit' })
page.getByLabel('Password')// Scope within a region
const card = page.getByRole('listitem').filter({ hasText: 'Product A' });
await card.getByRole('button', { name: 'Add to cart' }).click();
// Filter by child locator
const row = page.getByRole('row').filter({
has: page.getByRole('cell', { name: 'John' })
});
// Combine conditions
const visibleSubmit = page.getByRole('button', { name: 'Submit' }).and(page.locator(':visible'));
const primaryOrSecondary = page.getByRole('button', { name: 'Save' }).or(page.getByRole('button', { name: 'Update' }));Locators throw if multiple elements match. Use first(), last(), nth() only when intentional:
// Throws if multiple buttons match
await page.getByRole('button', { name: 'Delete' }).click();
// Explicit selection when needed
await page.getByRole('listitem').first().click();
await page.getByRole('row').nth(2).getByRole('button').click();Use async assertions that auto-wait and retry:
// BAD: No auto-wait, flaky
expect(await page.getByText('Success').isVisible()).toBe(true);
// GOOD: Auto-waits up to timeout
await expect(page.getByText('Success')).toBeVisible();
await expect(page.getByRole('button')).toBeEnabled();
await expect(page.getByTestId('status')).toHaveText('Submitted');
await expect(page).toHaveURL(/dashboard/);
await expect(page).toHaveTitle('Dashboard');
// Collections
await expect(page.getByRole('listitem')).toHaveCount(5);
await expect(page.getByRole('listitem')).toHaveText(['Item 1', 'Item 2', 'Item 3']);
// Soft assertions (continue on failure, report all)
await expect.soft(locator).toBeVisible();
await expect.soft(locator).toHaveText('Expected');
// Test continues, failures compiled at endEncapsulate page interactions. Define locators as readonly properties in constructor.
// pages/base.page.ts
import { type Page, type Locator, expect } from '@playwright/test';
import debug from 'debug';
export abstract class BasePage {
protected readonly log: debug.Debugger;
constructor(
protected readonly page: Page,
protected readonly timeout = 30_000
) {
this.log = debug(`test:page:${this.constructor.name}`);
}
protected async safeClick(locator: Locator, description?: string) {
this.log('clicking: %s', description ?? locator);
await expect(locator).toBeVisible({ timeout: this.timeout });
await expect(locator).toBeEnabled({ timeout: this.timeout });
await locator.click();
}
protected async safeFill(locator: Locator, value: string) {
await expect(locator).toBeVisible({ timeout: this.timeout });
await locator.fill(value);
}
abstract isLoaded(): Promise<void>;
}// pages/login.page.ts
import { type Locator, type Page, expect } from '@playwright/test';
import { BasePage } from './base.page';
export class LoginPage extends BasePage {
readonly emailInput: Locator;
readonly passwordInput: Locator;
readonly submitButton: Locator;
readonly errorMessage: Locator;
constructor(page: Page) {
super(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');
await this.isLoaded();
}
async isLoaded() {
await expect(this.emailInput).toBeVisible();
}
async login(email: string, password: string) {
await this.safeFill(this.emailInput, email);
await this.safeFill(this.passwordInput, password);
await this.safeClick(this.submitButton, 'Sign in button');
}
async expectError(message: string) {
await expect(this.errorMessage).toHaveText(message);
}
}Prefer fixtures over beforeEach/afterEach. Fixtures encapsulate setup + teardown, run on-demand, and compose with dependencies.
// fixtures/index.ts
import { test as base, expect } from '@playwright/test';
import { LoginPage } from '../pages/login.page';
import { DashboardPage } from '../pages/dashboard.page';
type TestFixtures = {
loginPage: LoginPage;
dashboardPage: DashboardPage;
};
export const test = base.extend<TestFixtures>({
loginPage: async ({ page }, use) => {
const loginPage = new LoginPage(page);
await loginPage.goto();
await use(loginPage);
},
dashboardPage: async ({ page }, use) => {
await use(new DashboardPage(page));
},
});
export { expect };Use for expensive setup shared across tests (database connections, authenticated users):
// fixtures/auth.fixture.ts
import { test as base } from '@playwright/test';
type WorkerFixtures = {
authenticatedUser: { token: string; userId: string };
};
export const test = base.extend<{}, WorkerFixtures>({
authenticatedUser: [async ({}, use) => {
// Expensive setup - runs once per worker
const user = await createTestUser();
const token = await authenticateUser(user);
await use({ token, userId: user.id });
// Cleanup after all tests in worker
await deleteTestUser(user.id);
}, { scope: 'worker' }],
});Run for every test without explicit declaration:
export const test = base.extend<{ autoLog: void }>({
autoLog: [async ({ page }, use) => {
page.on('console', msg => console.log(`[browser] ${msg.text()}`));
await use();
}, { auto: true }],
});Save authenticated state to reuse. Never log in via UI in every test.
// 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(process.env.TEST_USER_EMAIL!);
await page.getByLabel('Password').fill(process.env.TEST_USER_PASSWORD!);
await page.getByRole('button', { name: 'Sign in' }).click();
await page.waitForURL('/dashboard');
await page.context().storageState({ path: authFile });
});// playwright.config.ts
export default defineConfig({
projects: [
{ name: 'setup', testMatch: /.*\.setup\.ts/ },
{
name: 'chromium',
use: {
...devices['Desktop Chrome'],
storageState: 'playwright/.auth/user.json',
},
dependencies: ['setup'],
},
],
});setup('authenticate via API', async ({ request }) => {
const response = await request.post('/api/auth/login', {
data: { email: process.env.TEST_USER_EMAIL, password: process.env.TEST_USER_PASSWORD },
});
expect(response.ok()).toBeTruthy();
await request.storageState({ path: authFile });
});Set up routes before navigation.
test('displays mocked data', async ({ page }) => {
await page.route('**/api/users', route => route.fulfill({
json: [{ id: 1, name: 'Test User' }],
}));
await page.goto('/users');
await expect(page.getByText('Test User')).toBeVisible();
});
// Modify real response
test('injects item into response', async ({ page }) => {
await page.route('**/api/items', async route => {
const response = await route.fetch();
const json = await response.json();
json.push({ id: 999, name: 'Injected' });
await route.fulfill({ response, json });
});
await page.goto('/items');
});
// HAR recording
test('uses recorded responses', async ({ page }) => {
await page.routeFromHAR('./fixtures/api.har', {
url: '**/api/**',
update: false, // true to record
});
await page.goto('/');
});Each test gets fresh browser context. Never share state between tests.
// BAD: Tests depend on each other
let userId: string;
test('create user', async ({ request }) => {
userId = (await (await request.post('/api/users', { data: { name: 'Test' } })).json()).id;
});
test('delete user', async ({ request }) => {
await request.delete(`/api/users/${userId}`); // Depends on previous!
});
// GOOD: Each test creates its own data
test('can delete created user', async ({ request }) => {
const { id } = await (await request.post('/api/users', { data: { name: 'Test' } })).json();
const deleteResponse = await request.delete(`/api/users/${id}`);
expect(deleteResponse.ok()).toBeTruthy();
});// 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,
// Use minimal reporter in CI/agent contexts to prevent context overflow
reporter: process.env.CI || process.env.CLAUDE
? [['line'], ['html', { open: 'never' }]]
: 'list',
use: {
baseURL: process.env.BASE_URL ?? 'http://localhost:3000',
trace: 'on-first-retry',
screenshot: 'only-on-failure',
video: 'on-first-retry',
},
projects: [
{ name: 'setup', testMatch: /.*\.setup\.ts/ },
{
name: 'chromium',
use: { ...devices['Desktop Chrome'] },
dependencies: ['setup'],
},
{
name: 'firefox',
use: { ...devices['Desktop Firefox'] },
dependencies: ['setup'],
},
{
name: 'webkit',
use: { ...devices['Desktop Safari'] },
dependencies: ['setup'],
},
],
webServer: {
command: 'npm run start',
url: 'http://localhost:3000',
reuseExistingServer: !process.env.CI,
},
});tests/
fixtures/ # Custom fixtures (extend base test)
pages/ # Page Object Models
helpers/ # Utility functions (API clients, data factories)
auth.setup.ts # Authentication setup project
*.spec.ts # Test files
playwright/
.auth/ # Auth state storage (gitignored)
playwright.config.tsOrganize tests by feature or user journey. Colocate page objects with tests when possible.
// helpers/user.helper.ts
import type { Page } from '@playwright/test';
import debug from 'debug';
const log = debug('test:helper:user');
export class UserHelper {
constructor(private page: Page) {}
async createUser(data: { name: string; email: string }) {
log('creating user: %s', data.email);
const response = await this.page.request.post('/api/users', { data });
return response.json();
}
async deleteUser(id: string) {
log('deleting user: %s', id);
await this.page.request.delete(`/api/users/${id}`);
}
}
// helpers/data.factory.ts
export function createTestUser(overrides: Partial<User> = {}): User {
return {
id: crypto.randomUUID(),
email: `test-${Date.now()}@example.com`,
name: 'Test User',
...overrides,
};
}npx playwright test --debug # Step through with inspector
npx playwright test --trace on # Record trace for all tests
npx playwright test --ui # Interactive UI mode
npx playwright codegen localhost:3000 # Generate locators interactively
npx playwright show-report # View HTML reportEnable debug logs: DEBUG=test:* npx playwright test
page.waitForTimeout(ms) - use auto-waiting locators insteadpage.locator('.class') - use role/label/testid--reporter=line or --reporter=dot to prevent context overflowawait expect() web-first matcherspage.waitForTimeout() - use auto-waitingline/dot) used in CI/agent contexts5342bca
If you maintain this skill, you can claim it as your own. Once claimed, you can manage eval scenarios, bundle related skills, attach documentation or rules, and ensure cross-agent compatibility.