.agents/skills/playwright-best-practices/infrastructure-ci-cd/performance.md
// playwright.config.ts
export default defineConfig({
// Run test files in parallel
fullyParallel: true,
// Number of worker processes
workers: process.env.CI ? 1 : undefined, // undefined = half CPU cores
// Or explicit count
// workers: 4,
// workers: '50%', // Percentage of CPU cores
})
// Entire file serial
test.describe.configure({mode: 'serial'})
test.describe('Sequential Tests', () => {
test('first', async ({page}) => {
// Runs first
})
test('second', async ({page}) => {
// Runs after first
})
})
// Single describe block serial
test.describe('Parallel Tests', () => {
test('a', async () => {}) // Parallel
test('b', async () => {}) // Parallel
})
test.describe.serial('Serial Tests', () => {
test('c', async () => {}) // Serial
test('d', async () => {}) // Serial
})
// playwright.config.ts
export default defineConfig({
projects: [
{name: 'chromium', use: {...devices['Desktop Chrome']}},
{name: 'firefox', use: {...devices['Desktop Firefox']}},
{name: 'webkit', use: {...devices['Desktop Safari']}},
],
})
# Run all projects in parallel
npx playwright test
# Run specific project
npx playwright test --project=chromium
# Split tests across 4 machines
# Machine 1:
npx playwright test --shard=1/4
# Machine 2:
npx playwright test --shard=2/4
# Machine 3:
npx playwright test --shard=3/4
# Machine 4:
npx playwright test --shard=4/4
Tests are distributed evenly by file. For optimal sharding:
fullyParallel: true for even distribution# GitHub Actions
jobs:
test:
strategy:
matrix:
shard: [1, 2, 3, 4]
steps:
- run: npx playwright test --shard=${{ matrix.shard }}/4
For comprehensive CI sharding (blob reports, merging sharded results, full workflows), see ci-cd.md.
Avoid logging in for every test. Use setup projects with storage state to authenticate once and reuse the session.
For authentication patterns (storage state, multiple auth states, setup projects), see fixtures-hooks.md.
Sharing a single page/context across tests with beforeAll/afterAll is not recommended for most suites: it breaks test isolation, causes state leak between tests, and makes failures harder to debug. Prefer a fresh page per test (Playwright default). Use shared page only when you explicitly need serial execution and accept no isolation.
// ⚠️ Serial only, no isolation: state from one test leaks into the next.
// Prefer test.describe.configure({ mode: 'serial' }) + fresh page per test, or beforeEach + page.goto().
test.describe.configure({mode: 'serial'})
test.describe('Dashboard', () => {
let page: Page
test.beforeAll(async ({browser}) => {
const context = await browser.newContext({
storageState: '.auth/user.json',
})
page = await context.newPage()
await page.goto('/dashboard')
})
test.afterAll(async () => {
await page?.close()
})
test('shows stats', async () => {
await expect(page.getByTestId('stats')).toBeVisible()
})
test('shows chart', async () => {
await expect(page.getByTestId('chart')).toBeVisible()
})
})
// Bad: Navigate in every test
test('check header', async ({page}) => {
await page.goto('/products')
await expect(page.getByRole('heading')).toBeVisible()
})
test('check footer', async ({page}) => {
await page.goto('/products')
await expect(page.getByRole('contentinfo')).toBeVisible()
})
// Good: Share navigation
test.describe('Products Page', () => {
test.beforeEach(async ({page}) => {
await page.goto('/products')
})
test('check header', async ({page}) => {
await expect(page.getByRole('heading')).toBeVisible()
})
test('check footer', async ({page}) => {
await expect(page.getByRole('contentinfo')).toBeVisible()
})
})
// Use test.skip for conditional execution
test('admin feature', async ({page}) => {
test.skip(!process.env.ADMIN_ENABLED, 'Admin features disabled')
// ...
})
// Use test.fixme for known broken tests
test.fixme('broken feature', async ({page}) => {
// Skipped but tracked
})
test.beforeEach(async ({page}) => {
// Mock slow/heavy endpoints
await page.route('**/api/analytics', (route) => route.fulfill({json: {views: 1000}}))
await page.route('**/api/recommendations', (route) => route.fulfill({json: []}))
})
test.beforeEach(async ({page}) => {
// Block analytics, ads, tracking
await page.route('**/*', (route) => {
const url = route.request().url()
if (url.includes('google-analytics') || url.includes('facebook') || url.includes('hotjar')) {
return route.abort()
}
return route.continue()
})
})
// Block images and fonts for faster tests
await page.route('**/*', (route) => {
const resourceType = route.request().resourceType()
if (['image', 'font', 'stylesheet'].includes(resourceType)) {
return route.abort()
}
return route.continue()
})
const apiCache = new Map<string, object>()
test.beforeEach(async ({page}) => {
await page.route('**/api/**', async (route) => {
const url = route.request().url()
if (apiCache.has(url)) {
return route.fulfill({json: apiCache.get(url)})
}
const response = await route.fetch()
const json = await response.json()
apiCache.set(url, json)
return route.fulfill({json})
})
})
Playwright gives each test its own browser context (and page). That gives isolation: no shared cookies, storage, or DOM between tests, so failures don’t carry over and you can run tests in any order or in parallel. Keep this default unless you have a clear reason to share state.
page or context in beforeAll) when tests can run in parallel. State from one test can leak into another and cause flaky, order-dependent failures.beforeEach for per-test navigation so each test gets a fresh page or a clean slate.testInfo.workerIndex (or process.env.TEST_WORKER_INDEX) to create unique data per worker (e.g. unique user or DB prefix). See fixtures-hooks.md for worker-scoped fixtures and debugging.md for debugging flaky parallel runs.If a test is flaky only with multiple workers:
--repeat-each=10 (or --repeat-each=100 --max-failures=1).--workers=1. If the failure disappears, the cause is likely shared state or non-isolated backend/DB data.beforeEach; isolate test data per worker with workerIndex in a worker-scoped fixture.Workers are restarted after a test failure so subsequent tests in that worker get a clean environment; fixing isolation still prevents the initial flakiness.
// Recommended: One context per test (default) — full isolation
test('isolated test', async ({page}) => {
// Fresh context automatically
})
// Manual context for specific needs
test('multiple tabs', async ({browser}) => {
const context = await browser.newContext()
const page1 = await context.newPage()
const page2 = await context.newPage()
// Clean up
await context.close()
})
// playwright.config.ts
export default defineConfig({
// Limit concurrent workers
workers: 2,
// Limit parallel tests per worker
use: {
// Lower memory usage
launchOptions: {
args: ['--disable-dev-shm-usage'],
},
},
})
// playwright.config.ts
export default defineConfig({
// Global test timeout
timeout: 30000,
// Assertion timeout
expect: {
timeout: 5000,
},
// Navigation timeout
use: {
navigationTimeout: 15000,
actionTimeout: 10000,
},
})
test('performance test', async ({page}, testInfo) => {
const startTime = Date.now()
await page.goto('/')
const loadTime = Date.now() - startTime
console.log(`Page load: ${loadTime}ms`)
// Add to test report
testInfo.annotations.push({
type: 'performance',
description: `Load time: ${loadTime}ms`,
})
})
test('collect metrics', async ({page}) => {
await page.goto('/')
const metrics = await page.evaluate(() => ({
// Navigation timing
loadTime: performance.timing.loadEventEnd - performance.timing.navigationStart,
domContentLoaded:
performance.timing.domContentLoadedEventEnd - performance.timing.navigationStart,
// Performance entries
resources: performance.getEntriesByType('resource').length,
// Memory (Chrome only)
// @ts-ignore
memory: performance.memory?.usedJSHeapSize,
}))
console.log('Metrics:', metrics)
expect(metrics.loadTime).toBeLessThan(3000)
})
import {playAudit} from 'playwright-lighthouse'
test('lighthouse audit', async ({page}) => {
await page.goto('/')
const audit = await playAudit({
page,
thresholds: {
'performance': 80,
'accessibility': 90,
'best-practices': 80,
'seo': 80,
},
port: 9222,
})
expect(audit.lhr.categories.performance.score * 100).toBeGreaterThanOrEqual(80)
})
| Optimization | Impact |
|---|---|
Enable fullyParallel | High |
| Reuse authentication | High |
| Mock heavy APIs | High |
| Block tracking scripts | Medium |
| Use sharding in CI | High |
| Reduce workers if memory-bound | Medium |
| Cache API responses | Medium |
| Skip unnecessary tests | Low-Medium |