Writing Tests
Comprehensive patterns for authoring Playwright tests. Use the table of contents to jump to what you need.
Contents
- File layout
- Locator strategies
- Assertions
- Test hooks and lifecycle
- Parametrized tests
- Tags and filtering
- Skips, conditionals, expected failures
- Timeouts
- Multi-page and multi-context
- Anti-patterns to avoid
File layout
tests/
├── shared/ # cross-feature helpers, types, users.ts
│ └── users.ts
├── auth/
│ ├── login.spec.ts
│ └── logout.spec.ts
├── checkout/
│ ├── cart.spec.ts
│ └── payment.spec.ts
└── inventory/
└── filtering.spec.ts
Rules:
- One
*.spec.tsfile per user-facing feature, not per page object - Shared helpers (page objects, fixtures, data) live in
shared/or alongside the spec — never reach into another spec file - Cookbook/example files use
.cookbook.tssuffix so they can be excluded from CI viatestIgnore: '**/*.cookbook.ts'
Locator strategies
Priority order — pick the highest one that works:
// 1. By role — accessible, resilient, recommended
await page.getByRole('button', { name: 'Login' }).click();
await page.getByRole('textbox', { name: 'Username' }).fill('user');
// 2. By label (form fields)
await page.getByLabel('Email address').fill('a@b.com');
// 3. By placeholder
await page.getByPlaceholder('Username').fill('user');
// 4. By test ID — uses `testIdAttribute` from config
// This project: data-test → page.getByTestId('username')
await page.getByTestId('login-button').click();
// 5. By text (read-only content)
await page.getByText('Welcome back').isVisible();
// 6. By alt text (images)
await page.getByAltText('Company logo');
// 7. By title attribute
await page.getByTitle('Tooltip text');
// LAST RESORT — CSS / XPath
await page.locator('.login_logo').click();
await page.locator('//button[@type="submit"]').click();
Chaining locators
// Filter to narrow down
const item = page
.getByTestId('inventory-item')
.filter({ hasText: 'Sauce Labs Backpack' });
await item.getByRole('button', { name: /add to cart/i }).click();
// Find by index
await page.getByTestId('inventory-item').first().click();
await page.getByTestId('inventory-item').nth(2).click();
await page.getByTestId('inventory-item').last().click();
// `has` and `hasNot` — filter by descendant
page.locator('article').filter({ has: page.getByText('Featured') });
page.locator('article').filter({ hasNot: page.getByText('Sold out') });
Scoping a locator
// Confine searches to a region
const cart = page.getByTestId('cart-contents');
await cart.getByRole('button', { name: 'Remove' }).click();
Assertions
Always await web-first assertions — they auto-retry until the assertion
timeout. Without await they return a Promise that may resolve too early.
// Visibility
await expect(locator).toBeVisible();
await expect(locator).toBeHidden();
await expect(locator).toBeInViewport();
// State
await expect(locator).toBeEnabled();
await expect(locator).toBeDisabled();
await expect(locator).toBeEditable();
await expect(locator).toBeFocused();
await expect(locator).toBeChecked();
// Content
await expect(locator).toHaveText('exact');
await expect(locator).toHaveText(/regex/);
await expect(locator).toContainText('substring');
await expect(locator).toHaveValue('input value');
await expect(locator).toHaveAttribute('href', '/path');
await expect(locator).toHaveClass(/active/);
await expect(locator).toHaveCount(3);
// Page
await expect(page).toHaveURL(/dashboard/);
await expect(page).toHaveTitle('Page Title');
// Soft — collect all failures, don't bail on first
await expect.soft(loc1).toBeVisible();
await expect.soft(loc2).toHaveText('x');
// Poll arbitrary expressions
await expect.poll(async () => fetchSomething()).toBe('expected');
await expect.poll(() => page.evaluate(() => location.pathname)).toBe('/x');
// Negation
await expect(locator).not.toBeVisible();
Custom timeout on a single assertion
await expect(locator).toBeVisible({ timeout: 10_000 });
Test hooks and lifecycle
test.beforeAll(async () => { /* once per worker, before any test in file */ });
test.afterAll(async () => { /* once per worker, after all tests in file */ });
test.beforeEach(async ({ page }) => { /* every test */ });
test.afterEach(async ({ page }) => { /* every test */ });
// Inside a describe — scoped to that suite
test.describe('Feature', () => {
test.beforeEach(async ({ page }) => { /* only for this describe */ });
test('does the thing', async ({ page }) => { /* ... */ });
});
Prefer fixtures over beforeEach when the setup produces something the
test uses. beforeEach is fine for side-effects (clear state, log to console).
Parametrized tests
Generate one test per data row at module load time:
import { test, expect } from '@playwright/test';
const cases = [
{ username: 'standard_user', shouldSucceed: true },
{ username: 'locked_out_user', shouldSucceed: false },
];
for (const c of cases) {
test(`login — ${c.username}`, async ({ page }) => {
await page.goto('/');
await page.getByTestId('username').fill(c.username);
await page.getByTestId('password').fill('secret_sauce');
await page.getByTestId('login-button').click();
if (c.shouldSucceed) {
await expect(page).toHaveURL('/inventory.html');
} else {
await expect(page.getByTestId('error')).toBeVisible();
}
});
}
Key insight: the for loop runs at collection time, not runtime — each
iteration registers a separate test(), so each gets its own browser context.
Tags and filtering
// Tag on a single test
test('flow A', { tag: '@smoke' }, async () => { /* ... */ });
// Multiple tags
test('flow B', { tag: ['@smoke', '@critical'] }, async () => { /* ... */ });
// Suite-level tag (all tests inherit)
test.describe('Checkout', { tag: '@checkout' }, () => { /* ... */ });
Run by tag via CLI:
npx playwright test --grep "@smoke"
npx playwright test --grep "@critical"
npx playwright test --grep-invert "@slow"
Or via project grep in config:
projects: [
{ name: 'smoke', grep: /@smoke/ },
{ name: 'full', grep: /@full/ },
]
Then: npm run test:smoke runs the smoke project.
Skips, conditionals, expected failures
test.skip('not implemented yet', async () => { /* never runs */ });
test('feature flag', async ({ page }) => {
test.skip(process.env.FEATURE_X !== 'on', 'requires FEATURE_X');
// ... actual test
});
test.fixme('broken — tracked in JIRA-123', async () => { /* skipped */ });
test('expected to fail until fixed', async () => {
test.fail(); // PASSES if the body throws, FAILS if it doesn't
expect(1).toBe(2);
});
test('slow test', async () => {
test.slow(); // triples the timeout
// ... long operation
});
Timeouts
Three layers, from coarsest to most precise:
// 1. Global default (playwright.config.ts)
timeout: 60_000,
// 2. Suite-level
test.describe('Slow suite', () => {
test.describe.configure({ timeout: 120_000 });
// ...
});
// 3. Per-test
test('one slow test', async () => {
test.setTimeout(180_000);
// ...
});
// 4. Per-assertion
await expect(loc).toBeVisible({ timeout: 30_000 });
// 5. Per-action (use sparingly — usually fix the test instead)
await page.click('button', { timeout: 5_000 });
Multi-page and multi-context
// Two pages in the SAME context (shared cookies, storage)
test('two tabs', async ({ context }) => {
const page1 = await context.newPage();
const page2 = await context.newPage();
// ...
});
// Two ISOLATED contexts (different users, no shared state)
test('two users', async ({ browser }) => {
const ctxA = await browser.newContext();
const ctxB = await browser.newContext();
const pageA = await ctxA.newPage();
const pageB = await ctxB.newPage();
// ...
await ctxA.close();
await ctxB.close();
});
// Wait for a popup/new tab
const [popup] = await Promise.all([
context.waitForEvent('page'),
page.click('a[target=_blank]'),
]);
await popup.waitForLoadState();
Anti-patterns to avoid
| Don't | Do |
|---|---|
await page.waitForTimeout(1000) | await expect(loc).toBeVisible() |
page.locator('.btn-primary') | page.getByRole('button', { name: 'Save' }) |
| Sharing state via module-level variables | Use fixtures with proper scope |
if (await loc.isVisible()) ... | await expect(loc).toBeVisible() |
expect(await loc.textContent()).toBe('x') | await expect(loc).toHaveText('x') |
try/catch to detect element absence | await expect(loc).not.toBeVisible() |
page.click('button') then immediate assertion | The click already waits; trust web-first assertions |
beforeEach for object construction | Fixture that returns the object |
import { Page } from 'playwright' | import { Page } from '@playwright/test' |
| Hard-coding URLs | Use baseURL in config + relative paths |