9.8 KiB
E2E Testing Patterns
Deep-dive patterns for Playwright E2E tests. The main SKILL.md covers the essentials; this reference covers scaling patterns, data management, and anti-flake strategies.
Page Object Model (Scaling Pattern)
Use Page Objects when a suite grows beyond ~20 tests and multiple specs interact with the same pages. Keep them thin — locators and actions only, no assertions.
// e2e/pages/login.page.ts
import { type Page, type Locator } from '@playwright/test';
export class LoginPage {
readonly emailInput: Locator;
readonly passwordInput: Locator;
readonly submitButton: Locator;
readonly errorAlert: Locator;
constructor(private readonly 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();
}
}
// e2e/specs/auth.spec.ts
import { test, expect } from '@playwright/test';
import { LoginPage } from '../pages/login.page';
test('valid credentials redirect to dashboard', async ({ page }) => {
const loginPage = new LoginPage(page);
await loginPage.goto();
await loginPage.login('admin@example.com', 'test-password');
await expect(page).toHaveURL('/dashboard');
});
test('invalid credentials show error', async ({ page }) => {
const loginPage = new LoginPage(page);
await loginPage.goto();
await loginPage.login('admin@example.com', 'wrong');
await expect(loginPage.errorAlert).toContainText('Invalid credentials');
});
When to use Page Objects vs inline locators:
- < 20 tests: inline locators in each spec (simpler, less indirection)
- 20-50 tests: locator helper functions or fixtures
- 50+ tests: full Page Object Model with fixtures for injection
Test Data Management
API-based seeding (recommended)
Seed data via API calls in fixtures or beforeAll, not through the UI.
// e2e/helpers/api.ts
export async function createTestUser(request: APIRequestContext) {
const response = await request.post('/api/v1/users', {
data: {
email: `test-${Date.now()}@example.com`,
name: 'Test User',
role: 'member',
},
headers: { Authorization: `Bearer ${process.env.TEST_API_TOKEN}` },
});
return response.json();
}
export async function deleteTestUser(request: APIRequestContext, userId: string) {
await request.delete(`/api/v1/users/${userId}`, {
headers: { Authorization: `Bearer ${process.env.TEST_API_TOKEN}` },
});
}
// e2e/specs/user-management.spec.ts
import { test, expect } from '@playwright/test';
import { createTestUser, deleteTestUser } from '../helpers/api';
test.describe('User management', () => {
let testUser: { id: string; email: string };
test.beforeAll(async ({ request }) => {
testUser = await createTestUser(request);
});
test.afterAll(async ({ request }) => {
await deleteTestUser(request, testUser.id);
});
test('user appears in list', async ({ page }) => {
await page.goto('/admin/users');
await expect(page.getByText(testUser.email)).toBeVisible();
});
});
Database seeding (alternative)
For complex data, seed directly via a test database. Use globalSetup to reset the DB and beforeAll per suite for specific records.
// e2e/global-setup.ts (addition)
import { execSync } from 'child_process';
async function globalSetup() {
// Reset test database
execSync('pnpm db:reset --force', { env: { ...process.env, DATABASE_URL: process.env.TEST_DATABASE_URL } });
execSync('pnpm db:seed', { env: { ...process.env, DATABASE_URL: process.env.TEST_DATABASE_URL } });
// ... auth setup ...
}
Anti-Flake Strategies
Disable animations globally
// e2e/fixtures.ts
import { test as base } from '@playwright/test';
export const test = base.extend({
page: async ({ page }, use) => {
await page.addStyleTag({
content: `
*, *::before, *::after {
animation-duration: 0s !important;
animation-delay: 0s !important;
transition-duration: 0s !important;
transition-delay: 0s !important;
}
`,
});
await use(page);
},
});
Wait for network idle after navigation
test('dashboard loads fully', async ({ page }) => {
await page.goto('/dashboard');
// Wait for the specific content, not generic network idle
await expect(page.getByRole('heading', { name: 'Dashboard' })).toBeVisible();
await expect(page.getByRole('table')).toBeVisible();
});
Never use page.waitForLoadState('networkidle') for SPAs — it fires prematurely when the initial HTML loads but React hasn't rendered yet. Wait for the specific element you care about.
Retry flaky assertions with custom timeout
// For a known-slow operation
await expect(page.getByText('Report generated')).toBeVisible({ timeout: 30_000 });
Isolate test state with fresh contexts
test.describe('shopping cart', () => {
test.use({ storageState: undefined }); // Fresh guest for each test
test('add item to cart', async ({ page }) => {
// This test starts with an empty cart every time
});
});
Multi-Role Testing
Test different user roles in separate projects or fixtures.
// playwright.config.ts
projects: [
{ name: 'setup', testMatch: /.*\.setup\.ts/ },
{
name: 'admin',
use: { storageState: 'e2e/.auth/admin.json' },
dependencies: ['setup'],
testMatch: /.*\.admin\.spec\.ts/,
},
{
name: 'member',
use: { storageState: 'e2e/.auth/member.json' },
dependencies: ['setup'],
testMatch: /.*\.member\.spec\.ts/,
},
{
name: 'guest',
testMatch: /.*\.guest\.spec\.ts/,
},
],
Or use fixtures for per-test role selection:
// e2e/fixtures.ts
type Accounts = {
adminPage: Page;
memberPage: Page;
};
export const test = base.extend<Accounts>({
adminPage: async ({ browser }, use) => {
const ctx = await browser.newContext({ storageState: 'e2e/.auth/admin.json' });
await use(await ctx.newPage());
await ctx.close();
},
memberPage: async ({ browser }, use) => {
const ctx = await browser.newContext({ storageState: 'e2e/.auth/member.json' });
await use(await ctx.newPage());
await ctx.close();
},
});
Network Interception Patterns
Wait for a specific API response before asserting
test('submitting form shows success', async ({ page }) => {
await page.goto('/settings');
const responsePromise = page.waitForResponse(
(resp) => resp.url().includes('/api/v1/settings') && resp.status() === 200,
);
await page.getByRole('button', { name: 'Save' }).click();
await responsePromise;
await expect(page.getByText('Settings saved')).toBeVisible();
});
Mock a third-party service
test('shows map with mocked geocoding', async ({ page }) => {
await page.route('**/maps.googleapis.com/**', (route) =>
route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify({
results: [{ geometry: { location: { lat: 37.7749, lng: -122.4194 } } }],
}),
}),
);
await page.goto('/locations/new');
await page.getByLabel('Address').fill('123 Main St');
await page.getByRole('button', { name: 'Lookup' }).click();
await expect(page.getByTestId('map-marker')).toBeVisible();
});
Simulate slow network
test('shows loading state on slow network', async ({ page, context }) => {
await context.route('**/api/**', async (route) => {
await new Promise((resolve) => setTimeout(resolve, 3000));
await route.continue();
});
await page.goto('/dashboard');
await expect(page.getByRole('progressbar')).toBeVisible();
});
Accessibility Patterns
Scan all critical pages in a single test file
// e2e/specs/a11y.spec.ts
import { test, expect } from '@playwright/test';
import AxeBuilder from '@axe-core/playwright';
const pages = ['/', '/login', '/dashboard', '/settings', '/users'];
for (const path of pages) {
test(`${path} has no critical a11y violations`, async ({ page }) => {
await page.goto(path);
const results = await new AxeBuilder({ page })
.withTags(['wcag2a', 'wcag2aa'])
.exclude('.third-party-widget')
.analyze();
expect(results.violations.filter((v) => v.impact === 'critical')).toEqual([]);
});
}
Assert specific a11y rules
test('form has proper labels', async ({ page }) => {
await page.goto('/signup');
const results = await new AxeBuilder({ page })
.include('form')
.withRules(['label', 'input-button-name'])
.analyze();
expect(results.violations).toEqual([]);
});
Debugging Checklist
When a test fails in CI:
- Download the trace artifact from GitHub Actions
- Open with:
npx playwright show-trace trace.zip - Check the timeline: click through each action to see DOM snapshots
- Check the console tab: look for JS errors or failed requests
- Check the network tab: did an API call fail or return unexpected data?
- If flaky: run locally with
npx playwright test path/to/test --repeat-each=20 - If environment-specific: compare screenshots from CI vs local
- If timing-related: replace
waitForTimeoutwithexpect().toBeVisible()orwaitForResponse()