Co-Pilot
Updated a month ago

playwright-best-practices

00xBigBoss
0.0k
0xBigBoss/claude-code/.claude/skills/playwright-best-practices
82
Agent Score

💡 Summary

This project provides best practices for writing resilient Playwright tests with effective locator strategies and test organization.

🎯 Target Audience

QA EngineersSoftware DevelopersTest Automation EngineersDevOps ProfessionalsTechnical Leads

🤖 AI Roast:Powerful, but the setup might scare off the impatient.

Security AnalysisMedium Risk

Risk: Medium. Review: shell/CLI command execution; outbound network access (SSRF, data egress); API keys/tokens handling and storage; filesystem read/write scope and path traversal. Run with least privilege and audit before enabling in production.


name: playwright-best-practices description: 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).

Playwright Best Practices

CLI Context: Prevent Context Overflow

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 test

Configure 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',

Locator Priority (Most to Least Resilient)

Always prefer user-facing attributes:

  1. page.getByRole('button', { name: 'Submit' }) - accessibility roles
  2. page.getByLabel('Email') - form control labels
  3. page.getByPlaceholder('Search...') - input placeholders
  4. page.getByText('Welcome') - visible text (non-interactive)
  5. page.getByAltText('Logo') - image alt text
  6. page.getByTitle('Settings') - title attributes
  7. page.getByTestId('submit-btn') - explicit test contracts
  8. CSS/XPath - last resort, avoid
// 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')

Chaining and Filtering

// 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' }));

Strictness

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();

Web-First Assertions

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 end

Page Object Model

Encapsulate 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); } }

Fixtures

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 };

Worker-Scoped Fixtures

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' }], });

Automatic Fixtures

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 }], });

Authentication

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'], }, ], });

API Authentication (Faster)

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 }); });

Network Mocking

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('/'); });

Test Isolation

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
5-Dim Analysis
Clarity9/10
Novelty7/10
Utility9/10
Completeness8/10
Maintainability8/10
Pros & Cons

Pros

  • Clear guidelines for resilient locators.
  • Encourages best practices in test organization.
  • Supports effective test automation strategies.

Cons

  • May require prior knowledge of Playwright.
  • Not exhaustive for all testing scenarios.
  • Assumes familiarity with TypeScript.

Related Skills

systematic-debugging

S
toolCo-Pilot
90/ 100

“This skill is essentially a stern rubber duck that yells 'Did you read the error message?' before you can even ask for help.”

ccmp

A
toolCo-Pilot
86/ 100

“Powerful, but the setup might scare off the impatient.”

claude-files

A
toolAuto-Pilot
84/ 100

“Powerful, but the setup might scare off the impatient.”

Disclaimer: This content is sourced from GitHub open source projects for display and rating purposes only.

Copyright belongs to the original author 0xBigBoss.