
Playwright Testing
- 35 installs
- 2 repo stars
- Updated August 1, 2026
- mhagrelius/dotfiles
Helps with testing & qa tasks.
About
playwright-testing is a Claude Code skill for testing & qa. It helps solo builders move faster with AI-assisted coding.
- playwright-testing
- Testing & QA
- AI-coding skill
Playwright Testing by the numbers
- 35 all-time installs (skills.sh)
- Ranked #1,312 of 2,153 Testing & QA skills by installs in the Skillselion catalog
- Data as of Aug 4, 2026 (Skillselion catalog sync)
npx skills add https://github.com/mhagrelius/dotfiles --skill playwright-testingAdd your badge
Show developers this skill is listed on Skillselion. Paste this into your README.
| Installs | 35 |
|---|---|
| repo stars | ★ 2 |
| Last updated | August 1, 2026 |
| Repository | mhagrelius/dotfiles ↗ |
What it does
Helps with testing & qa tasks.
Files
Playwright Testing
Write reliable, fast, maintainable Playwright tests for SPAs.
Contents
- Core Concepts (Locators, Waiting, Assertions)
- Test Structure → See reference/structure.md
- Performance → See reference/performance.md
- Debugging → See reference/debugging.md
- CI Configuration → See reference/ci.md
Core principle: Test what users see and do. If a user can't find an element by its role or text, neither should your test.
Quality layers: Reliable (no flakes) → Fast (parallel, minimal waits) → Maintainable (survives refactors)
Philosophy: User-centric locators by default. Implementation details (test-ids, CSS selectors) are escape hatches, not first choices.
The Process
1. Identify: What user behavior are we testing? 2. Locate: Find elements the way users would (role, label, text) 3. Act: Perform user actions (click, fill, navigate) 4. Assert: Verify visible outcomes, not internal state 5. Stabilize: Handle async, add appropriate waits
Red Flags - STOP
page.locator('.btn-primary')- CSS class selectorspage.waitForTimeout(1000)- arbitrary sleepspage.locator('[data-testid="x"]')as first choice- Testing component internals instead of user outcomes
- Long test files with no page objects or fixtures
Locator Priority
digraph locator_priority {
rankdir=TB;
node [shape=box];
"Need to find element" [shape=diamond];
"Has accessible role?" [shape=diamond];
"Has label/placeholder?" [shape=diamond];
"Has visible text?" [shape=diamond];
"getByRole" [style=filled fillcolor=lightgreen];
"getByLabel/getByPlaceholder" [style=filled fillcolor=lightgreen];
"getByText" [style=filled fillcolor=lightgreen];
"getByTestId" [style=filled fillcolor=lightyellow];
"Need to find element" -> "Has accessible role?";
"Has accessible role?" -> "getByRole" [label="yes"];
"Has accessible role?" -> "Has label/placeholder?" [label="no"];
"Has label/placeholder?" -> "getByLabel/getByPlaceholder" [label="yes"];
"Has label/placeholder?" -> "Has visible text?" [label="no"];
"Has visible text?" -> "getByText" [label="yes"];
"Has visible text?" -> "getByTestId" [label="no (last resort)"];
}| Priority | Locator | When to Use | Example |
|---|---|---|---|
| 1st | getByRole | Buttons, links, inputs, headings, lists | getByRole('button', { name: 'Submit' }) |
| 2nd | getByLabel | Form inputs with labels | getByLabel('Email address') |
| 3rd | getByPlaceholder | Inputs with placeholder text | getByPlaceholder('Search...') |
| 4th | getByText | Static text content, paragraphs | getByText('Welcome back') |
| 5th | getByAltText | Images | getByAltText('Company logo') |
| Last | getByTestId | Dynamic content, no semantic meaning | getByTestId('total-price') |
Role Examples
// Buttons
page.getByRole('button', { name: 'Save changes' })
page.getByRole('button', { name: /submit/i }) // regex for flexibility
// Links
page.getByRole('link', { name: 'View profile' })
// Form inputs
page.getByRole('textbox', { name: 'Username' })
page.getByRole('checkbox', { name: 'Remember me' })
page.getByRole('combobox', { name: 'Country' })
// Structure
page.getByRole('heading', { name: 'Dashboard', level: 1 })
page.getByRole('list').getByRole('listitem')
page.getByRole('dialog', { name: 'Confirm deletion' })
// Tables
page.getByRole('table').getByRole('row', { name: /john/i })Disambiguating Multiple Matches
// Specify which match you want
await page.getByRole('button', { name: 'Delete' }).first().click();
await page.getByRole('listitem').last().click();
await page.getByRole('row').nth(2).click(); // 0-indexed
// Filter by content or child elements
await page.getByRole('listitem').filter({ hasText: 'John' }).click();
await page.getByRole('listitem').filter({
has: page.getByRole('button', { name: 'Edit' })
}).click();
// Chain locators to scope
await page.getByRole('dialog').getByRole('button', { name: 'Confirm' }).click();Locator Anti-patterns
| Bad | Why | Good |
|---|---|---|
.locator('.submit-btn') | Breaks on class rename | getByRole('button', { name: 'Submit' }) |
.locator('#email-input') | Coupled to implementation | getByLabel('Email') |
.locator('div > span:nth-child(2)') | Extremely brittle | getByText('...') or add test-id |
getByTestId everywhere | Misses accessibility bugs | Use semantic locators first |
| Unscoped locator with multiple matches | Flaky, might click wrong element | Use .first(), .filter(), or scope with parent |
Waiting & Async
Playwright auto-waits for most actions. Don't add manual waits unless you have a specific reason.
digraph waiting {
rankdir=TB;
node [shape=box];
"What are you waiting for?" [shape=diamond];
"Element to appear" [shape=diamond];
"Auto-wait (built-in)" [style=filled fillcolor=lightgreen];
"expect + toBeVisible" [style=filled fillcolor=lightgreen];
"waitForResponse" [style=filled fillcolor=lightyellow];
"waitForURL" [style=filled fillcolor=lightyellow];
"NEVER waitForTimeout" [style=filled fillcolor=lightpink];
"What are you waiting for?" -> "Auto-wait (built-in)" [label="clicking/filling"];
"What are you waiting for?" -> "Element to appear" [label="element"];
"What are you waiting for?" -> "waitForResponse" [label="API call"];
"What are you waiting for?" -> "waitForURL" [label="navigation"];
"Element to appear" -> "expect + toBeVisible" [label="use assertion"];
"Element to appear" -> "NEVER waitForTimeout" [label="don't guess"];
}Built-in Auto-Waiting
These actions auto-wait - no manual wait needed:
// All of these wait automatically for element to be actionable
await page.getByRole('button', { name: 'Submit' }).click();
await page.getByLabel('Email').fill('test@example.com');
await page.getByRole('checkbox').check();
await page.getByRole('combobox').selectOption('US');Explicit Waits (When Needed)
// Wait for element state (prefer assertions)
await expect(page.getByText('Success')).toBeVisible();
await expect(page.getByRole('button')).toBeEnabled();
await expect(page.getByRole('list')).not.toBeEmpty();
// Wait for navigation
await page.waitForURL('**/dashboard');
await page.waitForURL(url => url.searchParams.has('token'));
// Wait for API response (useful for loading states)
await page.getByRole('button', { name: 'Load data' }).click();
await page.waitForResponse(resp =>
resp.url().includes('/api/data') && resp.status() === 200
);
// Wait for network idle (use sparingly - can be slow)
await page.waitForLoadState('networkidle');
// Wait for specific request to complete before asserting
const responsePromise = page.waitForResponse('/api/users');
await page.getByRole('button', { name: 'Refresh' }).click();
await responsePromise;
await expect(page.getByRole('list')).toContainText('John');
// Promise.all pattern - click and wait simultaneously
await Promise.all([
page.waitForResponse(resp => resp.url().includes('/api/data')),
page.getByRole('button', { name: 'Submit' }).click()
]);Waiting Anti-patterns
| Bad | Why | Good |
|---|---|---|
waitForTimeout(2000) | Arbitrary, slow, still flaky | Wait for specific condition |
waitForTimeout(100) in loop | Polling manually | Use expect with auto-retry |
waitForLoadState('networkidle') everywhere | Slow, unreliable with polling | Wait for specific response |
| No wait + immediate assert | Race condition | expect auto-retries assertions |
Assertion Auto-Retry
Playwright assertions auto-retry until timeout. Use this instead of manual waits:
// BAD: manual wait then check
await page.waitForTimeout(1000);
const text = await page.getByTestId('status').textContent();
expect(text).toBe('Complete');
// GOOD: auto-retrying assertion
await expect(page.getByText('Complete')).toBeVisible();Soft Assertions
Use expect.soft() to continue testing after assertion failure (collect multiple failures):
test('validates all form fields', async ({ page }) => {
await page.goto('/profile');
// Soft assertions don't stop the test - useful for checking multiple things
await expect.soft(page.getByLabel('Name')).toHaveValue('John');
await expect.soft(page.getByLabel('Email')).toHaveValue('john@example.com');
await expect.soft(page.getByLabel('Phone')).toHaveValue('555-1234');
// Test continues even if some assertions fail
// All failures reported at end
});Handling Overlays and Popups
Use addLocatorHandler when overlays might interfere with test actions:
// Setup handler for cookie consent popup
await page.addLocatorHandler(
page.getByRole('dialog', { name: 'Cookie consent' }),
async () => {
await page.getByRole('button', { name: 'Accept' }).click();
}
);
// Now write test normally - handler auto-dismisses popup if it appears
await page.goto('/dashboard');
await page.getByRole('button', { name: 'Settings' }).click();Test Structure
For page objects, fixtures, and test organization, see reference/structure.md.
Quick tips:
- Page objects encapsulate interactions, not assertions
- Fixtures handle common setup/teardown
- One behavior per test
- Use
test.describefor grouping related tests
Performance
For parallel execution, auth optimization, and API shortcuts, see reference/performance.md.
Quick tips:
- Reuse auth state via
storageState - Create test data via API, not UI
- Use
fullyParallel: true - Avoid
waitForLoadState('networkidle')
Debugging
For debug mode, traces, and common scenarios, see reference/debugging.md.
Quick tips:
npx playwright test --debugfor local debugging- View traces for CI failures:
npx playwright show-trace trace.zip - Use
await page.pause()to stop and inspect
CI Configuration
For GitHub Actions, sharding, and CI-specific config, see reference/ci.md.
Quick tips:
forbidOnly: !!process.env.CIprevents test.only in CIretries: 2for CI to handle transient failures- Upload artifacts for debugging failures
Quality Checklist
Create TodoWrite items for each applicable check before finalizing tests.
Layer 1: Reliable (No Flakes)
- [ ] No
waitForTimeout()calls - using condition-based waits - [ ] Assertions use
expect()with auto-retry, not manual checks - [ ] Tests are independent - no shared state between tests
- [ ] Waiting for specific API responses, not
networkidle - [ ] Locators are specific enough (single element match)
- [ ] Tests pass consistently (run 5x locally before committing)
Layer 2: Fast
- [ ] Authentication reused via
storageState - [ ] Test data created via API, not UI
- [ ] Tests run in parallel (
fullyParallel: true) - [ ] No unnecessary
waitForLoadState('networkidle') - [ ] Heavy setup in fixtures, not repeated per test
- [ ] Sharding configured for large test suites
Layer 3: Maintainable
- [ ] Locators use roles/labels, not CSS selectors
- [ ] Page objects encapsulate interactions
- [ ] Test names describe user action + expected outcome
- [ ] One behavior per test - not testing multiple things
- [ ] Fixtures handle common setup/teardown
- [ ] No magic strings - constants for repeated values
Common Patterns Reference
| Need | Pattern |
|---|---|
| Authenticated user | storageState fixture |
| Test data setup | API calls in beforeEach or fixture |
| Wait for data load | waitForResponse('/api/...') |
| Click + wait for response | Promise.all([waitForResponse(...), click()]) |
| Multiple similar tests | test.describe + parameterized data |
| Slow operation | Increase timeout for specific test |
| Modal/dialog | getByRole('dialog') then scope within |
| Dropdown selection | getByRole('combobox').selectOption() |
| File upload | setInputFiles() on file input |
| Hover menu | locator.hover() then click revealed item |
| Drag and drop | locator.dragTo(target) |
| iframes | frameLocator() then scope within |
| New tab/window | page.waitForEvent('popup') |
| Multiple matches | .first(), .last(), .nth(n), .filter() |
| Check multiple things | expect.soft() for non-blocking assertions |
| Dismiss popups/overlays | addLocatorHandler() |
When to Add data-testid
Use data-testid as escape hatch when:
- Element has no semantic role (decorative container)
- Dynamic content with no stable text (generated IDs, prices)
- Multiple identical elements where position matters
- Third-party components without accessible markup
// Acceptable: price that changes dynamically
<span data-testid="cart-total">{formatCurrency(total)}</span>
page.getByTestId('cart-total')
// Still prefer scoping with semantic locators
page.getByRole('region', { name: 'Cart' }).getByTestId('total')Quick Reference Commands
# Run all tests
npx playwright test
# Run specific file
npx playwright test login.spec.ts
# Run tests matching name
npx playwright test -g "login"
# Run in headed mode
npx playwright test --headed
# Debug mode with inspector
npx playwright test --debug
# Update snapshots
npx playwright test --update-snapshots
# Generate test from recording
npx playwright codegen localhost:3000
# Show last HTML report
npx playwright show-reportCI Configuration Reference
Contents
- GitHub Actions
- CI-Specific Config
- Handling Flaky Tests
- Sharding for Large Suites
- CI Anti-patterns
GitHub Actions
# .github/workflows/playwright.yml
name: Playwright Tests
on:
push:
branches: [main]
pull_request:
branches: [main]
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 20
cache: 'npm'
- name: Install dependencies
run: npm ci
- name: Install Playwright browsers
run: npx playwright install --with-deps
- name: Run Playwright tests
run: npx playwright test
- name: Upload test results
if: always()
uses: actions/upload-artifact@v4
with:
name: playwright-report
path: playwright-report/
retention-days: 7CI-Specific Config
// playwright.config.ts
import { defineConfig, devices } from '@playwright/test';
export default defineConfig({
testDir: './tests',
fullyParallel: true,
forbidOnly: !!process.env.CI, // Fail if test.only is committed
retries: process.env.CI ? 2 : 0, // Retry flaky tests in CI
workers: process.env.CI ? 4 : undefined,
reporter: process.env.CI
? [['html'], ['github']] // GitHub annotations
: [['html'], ['list']],
use: {
baseURL: process.env.BASE_URL || 'http://localhost:3000',
trace: 'on-first-retry',
screenshot: 'only-on-failure',
video: 'retain-on-failure',
},
// Start dev server before tests
webServer: {
command: 'npm run dev',
url: 'http://localhost:3000',
reuseExistingServer: !process.env.CI,
timeout: 120_000,
},
projects: [
{ name: 'chromium', use: { ...devices['Desktop Chrome'] } },
{ name: 'firefox', use: { ...devices['Desktop Firefox'] } },
{ name: 'webkit', use: { ...devices['Desktop Safari'] } },
],
});Handling Flaky Tests
// Retry configuration (prefer fixing root cause)
export default defineConfig({
retries: process.env.CI ? 2 : 0,
});
// Mark known flaky test (temporary - fix the root cause!)
test('occasionally flaky test', {
annotation: { type: 'issue', description: 'https://github.com/org/repo/issues/123' },
}, async ({ page }) => {
// ...
});
// Skip in CI while investigating
test.skip(!!process.env.CI, 'Flaky in CI - investigating');
// Increase timeout for slow test
test('slow integration test', async ({ page }) => {
test.setTimeout(60_000);
// ...
});Sharding for Large Suites
# Run tests in parallel across multiple jobs
jobs:
test:
strategy:
matrix:
shard: [1, 2, 3, 4]
steps:
- name: Run tests
run: npx playwright test --shard=${{ matrix.shard }}/4CI Anti-patterns
| Bad | Why | Good |
|---|---|---|
| No retries in CI | Single flake fails PR | retries: 2 for CI |
test.only committed | Other tests don't run | Use forbidOnly: !!process.env.CI |
| No artifact upload | Can't debug CI failures | Upload report on failure |
| Hardcoded URLs | Breaks in different envs | Use baseURL from env |
| No test sharding (huge suite) | Slow CI times | Shard across workers |
| Ignoring flaky tests | Tech debt accumulates | Fix root cause, track issues |
Debugging Reference
Contents
- Debug Mode
- Trace Viewer
- Screenshots & Videos
- Console & Network Logging
- Pausing & Stepping
- Common Debugging Scenarios
- Locator Debugging
Debug Mode
# Run with headed browser and slow motion
npx playwright test --headed --debug
# Run specific test in debug mode
npx playwright test login.spec.ts:15 --debug
# Open Playwright Inspector
PWDEBUG=1 npx playwright testTrace Viewer
// playwright.config.ts
export default defineConfig({
use: {
// Capture trace on first retry (good for CI)
trace: 'on-first-retry',
// Or always capture (larger files)
// trace: 'on',
},
});# View trace after test failure
npx playwright show-trace trace.zip
# Traces contain: screenshots, DOM snapshots, network, console logsScreenshots & Videos
// playwright.config.ts
export default defineConfig({
use: {
// Screenshot on failure
screenshot: 'only-on-failure',
// Record video on failure
video: 'retain-on-failure',
},
});
// Manual screenshot in test
await page.screenshot({ path: 'debug.png', fullPage: true });Console & Network Logging
// Log all console messages
page.on('console', msg => console.log('BROWSER:', msg.text()));
// Log all network requests
page.on('request', req => console.log('→', req.method(), req.url()));
page.on('response', res => console.log('←', res.status(), res.url()));
// Log failed requests
page.on('requestfailed', req => {
console.log('FAILED:', req.url(), req.failure()?.errorText);
});Pausing & Stepping
test('debug this test', async ({ page }) => {
await page.goto('/dashboard');
// Pause here and open inspector
await page.pause();
// Continue with test...
await page.getByRole('button', { name: 'Submit' }).click();
});Common Debugging Scenarios
digraph debugging {
rankdir=TB;
node [shape=box];
"Test failing" [shape=diamond];
"Element not found" [shape=diamond];
"Run with --debug" [style=filled fillcolor=lightgreen];
"Check locator in inspector" [style=filled fillcolor=lightgreen];
"View trace" [style=filled fillcolor=lightgreen];
"Add page.pause()" [style=filled fillcolor=lightyellow];
"Check network tab" [style=filled fillcolor=lightyellow];
"Test failing" -> "Run with --debug" [label="locally"];
"Test failing" -> "View trace" [label="in CI"];
"Run with --debug" -> "Element not found";
"Element not found" -> "Check locator in inspector" [label="wrong locator"];
"Element not found" -> "Add page.pause()" [label="timing issue"];
"Element not found" -> "Check network tab" [label="data not loaded"];
}| Symptom | Likely Cause | Debug Step |
|---|---|---|
| Element not found | Wrong locator | Use Inspector to find element |
| Element not found (intermittent) | Race condition | Check if waiting for data load |
| Timeout on click | Element obscured | Screenshot before action |
| Different result locally vs CI | Viewport/timing | Check viewport size in config |
| Test passes alone, fails in suite | Shared state | Ensure test isolation |
| Network request fails | API issue | Log requests, check response |
Locator Debugging
// Test your locator interactively
const locator = page.getByRole('button', { name: 'Submit' });
// How many elements match?
console.log('Count:', await locator.count());
// What's the actual text?
console.log('Text:', await locator.textContent());
// Is it visible?
console.log('Visible:', await locator.isVisible());
// Highlight it (in headed mode)
await locator.highlight();Performance Reference
Contents
- Parallel Execution
- Efficient Authentication
- API Shortcuts for Data Setup
- Performance Anti-patterns
- Test Isolation with Contexts
Parallel Execution
// playwright.config.ts
import { defineConfig } from '@playwright/test';
export default defineConfig({
// Run tests in parallel (default: true)
fullyParallel: true,
// Number of parallel workers
workers: process.env.CI ? 4 : undefined, // undefined = use all CPUs
// Fail fast in CI
maxFailures: process.env.CI ? 10 : undefined,
});Tests must be independent for parallel execution. Each test should:
- Create its own data
- Not depend on other tests' side effects
- Clean up if necessary (or use isolated contexts)
Efficient Authentication
// SLOW: Login through UI for every test
test.beforeEach(async ({ page }) => {
await page.goto('/login');
await page.getByLabel('Email').fill('user@example.com');
await page.getByLabel('Password').fill('password');
await page.getByRole('button', { name: 'Sign in' }).click();
await page.waitForURL('/dashboard');
});
// FAST: Reuse authentication state
// global-setup.ts
import { chromium } from '@playwright/test';
async function globalSetup() {
const browser = await chromium.launch();
const page = await browser.newPage();
await page.goto('/login');
await page.getByLabel('Email').fill('user@example.com');
await page.getByLabel('Password').fill('password');
await page.getByRole('button', { name: 'Sign in' }).click();
await page.waitForURL('/dashboard');
// Save auth state
await page.context().storageState({ path: '.auth/user.json' });
await browser.close();
}
export default globalSetup;
// playwright.config.ts
export default defineConfig({
globalSetup: require.resolve('./global-setup'),
projects: [
{ name: 'setup', testMatch: /global-setup\.ts/ },
{
name: 'tests',
use: { storageState: '.auth/user.json' },
dependencies: ['setup'],
},
],
});API Shortcuts for Data Setup
// SLOW: Create test data through UI
test('deletes a project', async ({ page }) => {
// Create project through UI - slow!
await page.goto('/projects/new');
await page.getByLabel('Name').fill('Test Project');
await page.getByRole('button', { name: 'Create' }).click();
await page.waitForURL(/\/projects\/\d+/);
// Now test deletion...
});
// FAST: Create test data via API
test('deletes a project', async ({ page, request }) => {
// Create via API - fast!
const response = await request.post('/api/projects', {
data: { name: 'Test Project' }
});
const { id } = await response.json();
// Test the actual UI behavior
await page.goto(`/projects/${id}`);
await page.getByRole('button', { name: 'Delete' }).click();
await page.getByRole('button', { name: 'Confirm' }).click();
await expect(page.getByText('Project deleted')).toBeVisible();
});Performance Anti-patterns
| Slow | Fast |
|---|---|
waitForLoadState('networkidle') | waitForResponse for specific API |
| UI-based auth every test | Reuse storageState |
| Create all data through UI | API calls for setup |
waitForTimeout(ms) | Condition-based waits |
| Running all browsers locally | Run browsers in parallel |
| Full E2E for every edge case | Unit tests + selective E2E |
Test Isolation with Contexts
// Each test gets isolated browser context (cookies, storage)
test('test one', async ({ context, page }) => {
// Fresh context - no shared state with other tests
});
// Share context within a describe block if needed
test.describe(() => {
let sharedPage: Page;
test.beforeAll(async ({ browser }) => {
sharedPage = await browser.newPage();
// Expensive setup once
});
test.afterAll(async () => {
await sharedPage.close();
});
});Test Structure Reference
Contents
- File Organization
- Test Anatomy
- Page Objects
- Fixtures for Reusable Setup
- Structure Anti-patterns
File Organization
tests/
├── e2e/
│ ├── auth/
│ │ ├── login.spec.ts
│ │ └── signup.spec.ts
│ ├── dashboard/
│ │ └── dashboard.spec.ts
│ └── checkout/
│ └── checkout.spec.ts
├── fixtures/
│ └── index.ts
├── pages/
│ ├── login.page.ts
│ └── dashboard.page.ts
└── playwright.config.tsTest Anatomy
import { test, expect } from '@playwright/test';
test.describe('Feature: User Login', () => {
test.beforeEach(async ({ page }) => {
await page.goto('/login');
});
test('logs in with valid credentials', async ({ page }) => {
// Arrange - setup is in beforeEach
// Act
await page.getByLabel('Email').fill('user@example.com');
await page.getByLabel('Password').fill('password123');
await page.getByRole('button', { name: 'Sign in' }).click();
// Assert
await expect(page).toHaveURL('/dashboard');
await expect(page.getByRole('heading', { name: 'Welcome' })).toBeVisible();
});
test('shows error for invalid credentials', async ({ page }) => {
await page.getByLabel('Email').fill('user@example.com');
await page.getByLabel('Password').fill('wrong');
await page.getByRole('button', { name: 'Sign in' }).click();
await expect(page.getByRole('alert')).toContainText('Invalid credentials');
});
});Page Objects
Use page objects to encapsulate page interactions, not assertions:
// pages/login.page.ts
import { Page, Locator } from '@playwright/test';
export class LoginPage {
readonly page: Page;
readonly emailInput: Locator;
readonly passwordInput: Locator;
readonly submitButton: Locator;
readonly errorAlert: 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.errorAlert = 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();
}
}
// In test file
test('logs in successfully', async ({ page }) => {
const loginPage = new LoginPage(page);
await loginPage.goto();
await loginPage.login('user@example.com', 'password123');
await expect(page).toHaveURL('/dashboard');
});Fixtures for Reusable Setup
// fixtures/index.ts
import { test as base, Page } from '@playwright/test';
import { LoginPage } from '../pages/login.page';
import { DashboardPage } from '../pages/dashboard.page';
type Fixtures = {
loginPage: LoginPage;
dashboardPage: DashboardPage;
authenticatedPage: Page;
};
export const test = base.extend<Fixtures>({
loginPage: async ({ page }, use) => {
await use(new LoginPage(page));
},
dashboardPage: async ({ page }, use) => {
await use(new DashboardPage(page));
},
// Fixture that provides an already-authenticated page
authenticatedPage: async ({ page }, use) => {
await page.goto('/login');
await page.getByLabel('Email').fill('test@example.com');
await page.getByLabel('Password').fill('password');
await page.getByRole('button', { name: 'Sign in' }).click();
await page.waitForURL('/dashboard');
await use(page);
},
});
export { expect } from '@playwright/test';
// Usage in tests
import { test, expect } from '../fixtures';
test('views dashboard data', async ({ authenticatedPage }) => {
await expect(authenticatedPage.getByRole('heading', { name: 'Dashboard' })).toBeVisible();
});Structure Anti-patterns
| Bad | Why | Good |
|---|---|---|
| Assertions inside page objects | Hides test intent | Page objects do actions, tests assert |
| Huge test files (500+ lines) | Hard to maintain | Split by feature/user flow |
| Copy-pasting setup code | DRY violation | Use fixtures or beforeEach |
| Testing multiple unrelated things | Hard to debug failures | One behavior per test |
| Vague test names | Unclear what's tested | Describe user action + expected outcome |