Loading...
Loading...
Production-grade Playwright in TypeScript: Page Object Model, fixtures, auto-waiting, user-facing locators, parallel execution, CI integration, visual testing, accessibility. Includes explicit "do not" list for AI agents and 2025-2026 feature awareness. Use when: "Playwright," "browser testing," "E2E test," "end-to-end," "page object." Related: visual-testing, ci-cd-integration, api-testing, test-reliability, accessibility-testing.
npx skill4agent add petrkindlmann/qa-skills playwright-automationreferences/multi-site-architecture.mdgetByRolegetByLabelgetByTestIdreferences/selector-strategies.mdwaitForTimeoutBrowserContextfullyParallel: truetest.describe.serialbeforeEachafterEachreferences/fixtures-and-projects.mdCalibrate to your team maturity (setinteam_maturity):.agents/qa-project-context.md
- startup — Chromium only, 5–10 critical path tests, basic CI run on PR. Skip sharding and visual testing until the suite is stable.
- growing — Multi-browser (Chromium + Firefox), POM structure, parallel execution, CI with sharding, HTML report artifacts.
- established — Full browser matrix, auth fixtures, API mocking layer, visual regression baseline, trace-on-failure, flakiness tracking.
waitForTimeout()// BAD
await page.waitForTimeout(2000);
await page.click('#submit');
// GOOD
await page.getByRole('button', { name: 'Submit' }).click(); // auto-waitsgetByRolegetByLabelgetByTestId// BAD
await page.locator('.btn-primary.submit-form').click();
// GOOD
await page.getByRole('button', { name: 'Submit' }).click();references/selector-strategies.mdpage.*page.click()page.fill()page.type()// BAD
await page.click('#email');
await page.fill('#email', 'user@example.com');
// GOOD
await page.getByLabel('Email').fill('user@example.com');force: trueforce: true// BAD
await page.getByRole('button', { name: 'Save' }).click({ force: true });
// GOOD -- dismiss any overlay first
await page.getByRole('button', { name: 'Dismiss' }).click();
await page.getByRole('button', { name: 'Save' }).click();references/anti-patterns.mdstorageStatestorageStatereferences/auth-patterns.mdlocator.all()locator.all()// BAD
const items = await page.getByRole('listitem').all();
expect(items.length).toBe(5); // may be 0 if DOM is still rendering
// GOOD
await expect(page.getByRole('listitem')).toHaveCount(5);
const items = await page.getByRole('listitem').all(); // then iterate if neededallTextContents()toHaveText()allTextContents()toHaveText()// BAD
const texts = await page.getByRole('listitem').allTextContents();
expect(texts).toEqual(['Apple', 'Banana', 'Cherry']);
// GOOD
await expect(page.getByRole('listitem')).toHaveText(['Apple', 'Banana', 'Cherry']);// BAD -- hitting real Stripe checkout
await page.goto('https://checkout.stripe.com/...');
// GOOD -- mock the external integration
await page.route('**/api/create-checkout-session', async (route) => {
await route.fulfill({ json: { sessionId: 'mock_session', url: '/success' } });
});test.onlytest.onlyexport default defineConfig({ forbidOnly: !!process.env.CI });project-root/
├── playwright.config.ts
├── e2e/
│ ├── fixtures/ # base.fixture.ts, auth.fixture.ts, data.fixture.ts
│ ├── pages/ # Page objects organized by feature
│ │ ├── base.page.ts
│ │ ├── dashboard.page.ts
│ │ └── components/ # Reusable component objects
│ │ ├── data-table.component.ts
│ │ └── modal.component.ts
│ ├── tests/ # Test files organized by feature
│ │ ├── auth/
│ │ ├── dashboard/
│ │ └── settings/
│ ├── helpers/ # test-data.ts, api-client.ts
│ └── global-setup.ts
├── .auth/ # Git-ignored storageState files
└── test-results/ # Git-ignored artifactsimport { defineConfig, devices } from '@playwright/test';
const isCI = !!process.env.CI;
const baseURL = process.env.BASE_URL ?? 'http://localhost:3000';
export default defineConfig({
testDir: './e2e/tests',
fullyParallel: true,
forbidOnly: isCI,
retries: isCI ? 2 : 0,
workers: isCI ? '50%' : undefined,
reporter: isCI
? [['html', { open: 'never' }], ['github'], ['json', { outputFile: 'test-results/results.json' }]]
: [['html', { open: 'on-failure' }]],
use: {
baseURL,
trace: isCI ? 'on-first-retry' : 'retain-on-failure',
screenshot: 'only-on-failure',
video: isCI ? 'on-first-retry' : 'off',
actionTimeout: 15_000,
navigationTimeout: 30_000,
},
projects: [
{ name: 'setup', testMatch: /global-setup\.ts/, teardown: 'teardown' },
{ name: 'teardown', testMatch: /global-teardown\.ts/ },
{ name: 'chromium', use: { ...devices['Desktop Chrome'], storageState: '.auth/user.json' }, dependencies: ['setup'] },
{ name: 'firefox', use: { ...devices['Desktop Firefox'], storageState: '.auth/user.json' }, dependencies: ['setup'] },
{ name: 'webkit', use: { ...devices['Desktop Safari'], storageState: '.auth/user.json' }, dependencies: ['setup'] },
],
webServer: isCI ? undefined : {
command: 'npm run dev', url: baseURL, reuseExistingServer: true, timeout: 120_000,
},
});import { test as setup, expect } from '@playwright/test';
setup('authenticate as default user', 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 expect(page).toHaveURL(/.*dashboard/);
await page.context().storageState({ path: '.auth/user.json' });
});import { type Page, type Locator, expect } from '@playwright/test';
export abstract class BasePage {
constructor(protected readonly page: Page) {}
abstract readonly path: string;
async goto(): Promise<void> {
await this.page.goto(this.path);
await this.waitForReady();
}
async waitForReady(): Promise<void> {
await this.page.waitForLoadState('domcontentloaded');
}
}LocatorPageexport class DataTable {
readonly rows: Locator;
constructor(private readonly root: Locator) {
this.rows = root.getByRole('row');
}
getRowByText(text: string | RegExp): Locator {
return this.rows.filter({ hasText: text });
}
}export const test = base.extend<{ dashboardPage: DashboardPage }>({
dashboardPage: async ({ page }, use) => { await use(new DashboardPage(page)); },
});
export { expect } from '@playwright/test';export class UsersPage extends BasePage {
readonly path = '/admin/users';
readonly table: DataTable;
constructor(page: Page) {
super(page);
this.table = new DataTable(page.getByRole('table', { name: 'Users' }));
}
}storageStatereferences/auth-patterns.mdtest.step()test('submits a multi-step form', async ({ page }) => {
await page.goto('/onboarding');
await test.step('fill personal info', async () => {
await page.getByLabel('First name').fill('Jane');
await page.getByRole('button', { name: 'Next' }).click();
});
await test.step('submit', async () => {
await page.getByRole('button', { name: 'Complete setup' }).click();
});
await expect(page).toHaveURL('/dashboard');
});// Mock a response
await page.route('**/api/products*', async (route) => {
await route.fulfill({ json: { items: [{ id: '1', name: 'Widget', price: 29.99 }] } });
});
// Modify a real response
await page.route('**/api/feature-flags', async (route) => {
const response = await route.fetch();
const body = await response.json();
body.flags['new-checkout'] = true;
await route.fulfill({ response, json: body });
});
// Simulate errors
await page.route('**/api/products*', (route) => route.fulfill({ status: 500 }));
// WebSocket (v1.49+)
await page.routeWebSocket('**/ws/notifications', (ws) => {
ws.onMessage((msg) => { ws.send(JSON.stringify({ type: 'alert', title: 'Deployed' })); });
});references/network-and-mocking.mdtest('checkout @smoke', async ({ page }) => { /* npx playwright test --grep @smoke */ });
test.slow(); // Triples timeout
test.skip(({ browserName }) => browserName === 'webkit', 'WebKit bug');
test.fixme('known issue tracked in JIRA-1234', async ({ page }) => { /* ... */ });await expect(page.getByRole('alert')).toBeVisible();
await expect(page.getByRole('heading')).toHaveText('Dashboard');
await expect(page).toHaveURL('/dashboard');
await expect(page.getByRole('button', { name: 'Save' })).toBeEnabled();
await expect(page.getByRole('listitem')).toHaveCount(5);
await expect(page.getByRole('listitem')).toHaveText(['Apple', 'Banana', 'Cherry']);await expect.soft(page.getByLabel('Name')).toHaveValue('Jane Doe');
await expect.soft(page.getByLabel('Email')).toHaveValue('jane@example.com');await expect(page.getByRole('navigation', { name: 'Main' })).toMatchAriaSnapshot(`
- navigation "Main":
- link "Home"
- link "Products"
`);| Version | Feature | What it does |
|---|---|---|
| v1.45 | Clock API | |
| v1.45 | | CI flag that fails the run if any test required a retry to pass |
| v1.46 | | Run only tests affected by changed files (git-diff-aware) |
| v1.46 | Component testing | Mock Next.js/SvelteKit/etc. router in component tests |
| v1.46 | ARIA snapshots | |
| v1.49 | | First-class WebSocket interception (replaces CDP hacks) |
| v1.51 | | Per-block timeout/soft configuration |
| v1.57 | Speedboard in HTML reporter | Performance timeline visualization in the built-in report |
| v1.57 | | Wait for a specific stdout pattern instead of just a URL |
export default defineConfig({
fullyParallel: true,
workers: process.env.CI ? '50%' : undefined,
});strategy:
fail-fast: false
matrix:
shard: [1, 2, 3, 4]
steps:
- run: npx playwright test --shard=${{ matrix.shard }}/4reporter: [
['html', { open: 'never' }],
['json', { outputFile: 'results.json' }],
['github'],
['junit', { outputFile: 'junit.xml' }],
],references/ci-recipes.mdnpx playwright show-trace test-results/my-test/trace.zipnpx playwright test --uinpx playwright test my-test.spec.ts --debugms-playwright.playwrightreferences/debugging-and-triage.mdplaywright.config.tsprojectse2e/pages/LocatorgetByRolegetByLabelgetByTestId--shardwaitForTimeoutgrepforbidOnlyreferences/| File | Purpose |
|---|---|
| BAD vs GOOD code pairs for every common mistake |
| Auth fixtures, data fixtures, multi-env projects, composition |
| Locator decision tree, getByRole examples, stability scoring |
| storageState, multi-role, token seeding, session expiry |
| Shared fixtures, per-site config, monorepo patterns |
| page.route, route.fetch, HAR, WebSocket, conditional routing |
| Trace viewer, flaky test triage, retries, artifacts |
| Reporters, sharding, --only-changed, browser caching, artifacts |