Playwright E2E Testing
Production-tested patterns from the TestDino Playwright Skill. Every pattern includes when (and when not) to use it.
Golden Rules
getByRole()over CSS/XPath — resilient to markup changes, mirrors how users see the page- Never
page.waitForTimeout()— useexpect(locator).toBeVisible()orpage.waitForURL() - Web-first assertions —
expect(locator)auto-retries;expect(await locator.textContent())does NOT - Isolate every test — no shared state, no execution-order dependencies
baseURLin config — zero hardcoded URLs in tests- Retries:
2in CI,0locally — surface flakiness where it matters - Traces:
'on-first-retry'— rich debugging artifacts without CI slowdown - Fixtures over globals — share state via
test.extend(), not module-level variables - One behavior per test — multiple related
expect()calls are fine - Mock external services only — never mock your own app; mock third-party APIs, payment gateways, email
Deep dives available in references/ directory — read them when working on the relevant topic.
Feature Tests vs Smoke Tests
Not all E2E tests are equal. Know what tier you're writing.
| Tier | What it tests | Example | Sufficient for feature coverage? |
|---|---|---|---|
| Smoke | Page loads, no 404, no crash | goto('/canvas'); expect(heading).toBeVisible() | NO — baseline only |
| Feature | User completes a real workflow | Drag entry to project → rule created → future entries auto-link | YES — this is the goal |
| Navigation | Links route correctly, active states work | Click "Canvas" in sidebar → URL is /canvas → heading visible | Required when nav changes |
The rule: Every feature shipped MUST have at least one tier-2 (feature) E2E test. Smoke tests are free but DO NOT count toward feature coverage.
Ask yourself: "If someone broke this feature tomorrow, would my E2E tests catch it?" If the answer is "only if they deleted the entire page" — you wrote smoke tests, not feature tests.
Navigation Tests — Required When Nav Changes
When you add or modify navigation (sidebar items, mobile tab bar, header links, route changes), you MUST write tests that verify:
- Nav item is visible at the correct viewport (desktop sidebar, mobile tab bar)
- Clicking it navigates to the correct URL
- Destination page renders its primary content (not just "no 404")
- Active/selected state highlights correctly
Desktop + Mobile navigation test template:
import { test, expect } from '@playwright/test';
test.describe('Navigation — Desktop', () => {
test.use({ viewport: { width: 1280, height: 800 } });
test('sidebar contains Canvas link and navigates correctly', async ({ page }) => {
await page.goto('/');
const sidebar = page.getByRole('navigation');
const canvasLink = sidebar.getByRole('link', { name: 'Canvas' });
await expect(canvasLink).toBeVisible();
await canvasLink.click();
await page.waitForURL('/canvas');
await expect(page.getByRole('heading', { name: 'Canvas' })).toBeVisible();
});
});
test.describe('Navigation — Mobile', () => {
test.use({ viewport: { width: 375, height: 812 } });
test('mobile tab bar contains Canvas and navigates correctly', async ({ page }) => {
await page.goto('/');
const tabBar = page.getByRole('navigation', { name: /mobile|tab/i });
const canvasTab = tabBar.getByRole('link', { name: 'Canvas' });
await expect(canvasTab).toBeVisible();
await canvasTab.click();
await page.waitForURL('/canvas');
await expect(page.getByRole('heading', { name: 'Canvas' })).toBeVisible();
});
});
Adapt names/selectors to the actual app. The structure is: find nav → find link → click → verify URL → verify content.
Next.js Config (App Router + Pages Router)
// playwright.config.ts
import { defineConfig, devices } from '@playwright/test';
export default defineConfig({
testDir: './tests/e2e',
fullyParallel: true,
forbidOnly: !!process.env.CI,
retries: process.env.CI ? 2 : 0,
workers: process.env.CI ? '50%' : undefined,
reporter: process.env.CI ? 'html' : 'list',
use: {
baseURL: 'http://localhost:3000',
trace: 'on-first-retry',
screenshot: 'on',
video: 'retain-on-failure',
},
expect: {
toHaveScreenshot: {
maxDiffPixelRatio: 0.01,
animations: 'disabled',
},
},
projects: [
{ name: 'chromium', use: { ...devices['Desktop Chrome'] } },
{ name: 'mobile', use: { ...devices['iPhone 14'] } },
],
webServer: {
command: process.env.CI
? 'npm run build && npm run start' // production build in CI
: 'npm run dev', // dev server locally
url: 'http://localhost:3000',
reuseExistingServer: !process.env.CI,
timeout: 120_000,
env: {
NODE_ENV: process.env.CI ? 'production' : 'test',
},
},
});
Environment variables: Next.js loads .env.test automatically when NODE_ENV=test. Use .env.test for non-secret test config (committed), .env.test.local for secrets (gitignored).
Gitignore additions:
.env*.local
playwright-report/
playwright/.auth/
test-results/
blob-report/
Do NOT gitignore screenshot baselines. The *.spec.ts-snapshots/ directories created by toHaveScreenshot() MUST be committed — they are the source of truth for visual regression tests. Only ephemeral artifacts (test-results/, playwright-report/) should be ignored.
Locators — Priority Order
Use the first one that works:
page.getByRole('button', { name: 'Submit' }) // 1. Role (ALWAYS preferred)
page.getByLabel('Email address') // 2. Label (form fields)
page.getByText('Welcome back') // 3. Text (non-interactive content)
page.getByPlaceholder('Search...') // 4. Placeholder
page.getByAltText('Company logo') // 5. Alt text (images)
page.getByTitle('Close dialog') // 6. Title attribute
page.getByTestId('checkout-summary') // 7. Test ID (last resort)
page.locator('css=.legacy-widget') // 8. CSS/XPath (absolute last resort)
Role locator cheat sheet:
// Buttons — matches <button>, <input type="submit">, role="button"
page.getByRole('button', { name: 'Save changes' })
// Links — matches <a href>
page.getByRole('link', { name: 'View profile' })
// Headings — use level to target h1-h6
page.getByRole('heading', { name: 'Dashboard', level: 1 })
// Text inputs — by accessible name (label)
page.getByRole('textbox', { name: 'Email' })
// Checkboxes and radios
page.getByRole('checkbox', { name: 'Remember me' })
page.getByRole('radio', { name: 'Monthly billing' })
// Dropdowns — <select> elements
page.getByRole('combobox', { name: 'Country' })
// Navigation landmarks
page.getByRole('navigation', { name: 'Main' })
// Dialogs
page.getByRole('dialog', { name: 'Confirm deletion' })
// Exact matching — prevents "Log" matching "Log out"
page.getByRole('button', { name: 'Log', exact: true })
For deeper locator strategy guidance, read references/locators-deep-dive.md
Assertions — Web-First vs Non-Retrying
Web-first (auto-retry) — ALWAYS prefer:
await expect(page.getByRole('heading')).toBeVisible();
await expect(page.getByRole('heading')).toHaveText('Dashboard');
await expect(page.getByRole('listitem')).toHaveCount(5);
await expect(page.getByRole('button')).toBeEnabled();
await expect(page.getByLabel('Name')).toHaveValue('Jane');
await expect(page.getByTestId('card')).toHaveClass(/active/);
await expect(page.getByRole('checkbox')).toBeChecked();
await expect(page.getByRole('dialog')).not.toBeVisible();
Non-retrying — only for already-resolved values:
const title = await page.