.agents/skills/playwright-best-practices/testing-patterns/performance-testing.md
test('core web vitals within thresholds', async ({page}) => {
// Inject web-vitals library
await page.addInitScript(() => {
;(window as any).__webVitals = {}
// Simplified web vitals collection
new PerformanceObserver((list) => {
for (const entry of list.getEntries()) {
if (entry.entryType === 'largest-contentful-paint') {
;(window as any).__webVitals.lcp = entry.startTime
}
}
}).observe({type: 'largest-contentful-paint', buffered: true})
new PerformanceObserver((list) => {
let cls = 0
for (const entry of list.getEntries() as any[]) {
if (!entry.hadRecentInput) {
cls += entry.value
}
}
;(window as any).__webVitals.cls = cls
}).observe({type: 'layout-shift', buffered: true})
})
await page.goto('/')
// Wait for page to stabilize
await page.waitForLoadState('networkidle')
// Get metrics
const vitals = await page.evaluate(() => (window as any).__webVitals)
// Assert thresholds (Google's "good" thresholds)
expect(vitals.lcp).toBeLessThan(2500) // LCP < 2.5s
expect(vitals.cls).toBeLessThan(0.1) // CLS < 0.1
})
test('web vitals with library', async ({page}) => {
await page.addInitScript(() => {
;(window as any).__vitals = {}
})
// Inject web-vitals after navigation
await page.goto('/')
await page.addScriptTag({
url: 'https://unpkg.com/web-vitals@3/dist/web-vitals.iife.js',
})
await page.evaluate(() => {
const {onLCP, onFID, onCLS, onFCP, onTTFB} = (window as any).webVitals
onLCP((metric: any) => ((window as any).__vitals.lcp = metric.value))
onFID((metric: any) => ((window as any).__vitals.fid = metric.value))
onCLS((metric: any) => ((window as any).__vitals.cls = metric.value))
onFCP((metric: any) => ((window as any).__vitals.fcp = metric.value))
onTTFB((metric: any) => ((window as any).__vitals.ttfb = metric.value))
})
// Trigger FID by clicking
await page.getByRole('button').first().click()
// Wait and collect
await page.waitForTimeout(1000)
const vitals = await page.evaluate(() => (window as any).__vitals)
console.log('Web Vitals:', vitals)
// Assertions
if (vitals.lcp) expect(vitals.lcp).toBeLessThan(2500)
if (vitals.fid) expect(vitals.fid).toBeLessThan(100)
if (vitals.cls) expect(vitals.cls).toBeLessThan(0.1)
})
test('page load performance', async ({page}) => {
await page.goto('/')
const timing = await page.evaluate(() => {
const nav = performance.getEntriesByType('navigation')[0] as PerformanceNavigationTiming
return {
// Time to First Byte
ttfb: nav.responseStart - nav.requestStart,
// DOM Content Loaded
domContentLoaded: nav.domContentLoadedEventEnd - nav.startTime,
// Full page load
loadComplete: nav.loadEventEnd - nav.startTime,
// DNS lookup
dns: nav.domainLookupEnd - nav.domainLookupStart,
// Connection time
connection: nav.connectEnd - nav.connectStart,
// Download time
download: nav.responseEnd - nav.responseStart,
// DOM processing
domProcessing: nav.domComplete - nav.domInteractive,
}
})
console.log('Performance timing:', timing)
// Assertions
expect(timing.ttfb).toBeLessThan(600) // TTFB < 600ms
expect(timing.domContentLoaded).toBeLessThan(2000) // DCL < 2s
expect(timing.loadComplete).toBeLessThan(4000) // Load < 4s
})
test('resource loading performance', async ({page}) => {
await page.goto('/')
const resources = await page.evaluate(() => {
return performance.getEntriesByType('resource').map((entry) => ({
name: entry.name.split('/').pop(),
type: (entry as PerformanceResourceTiming).initiatorType,
duration: entry.duration,
size: (entry as PerformanceResourceTiming).transferSize,
}))
})
// Find slow resources
const slowResources = resources.filter((r) => r.duration > 1000)
if (slowResources.length > 0) {
console.warn('Slow resources:', slowResources)
}
// Find large resources
const largeResources = resources.filter((r) => r.size > 500000) // > 500KB
expect(largeResources.length).toBe(0)
})
test('memory usage is reasonable', async ({page}) => {
await page.goto('/dashboard')
// Check memory (Chrome only)
const memory = await page.evaluate(() => {
if ((performance as any).memory) {
return {
usedJSHeapSize: (performance as any).memory.usedJSHeapSize,
totalJSHeapSize: (performance as any).memory.totalJSHeapSize,
}
}
return null
})
if (memory) {
const usedMB = memory.usedJSHeapSize / 1024 / 1024
console.log(`Memory usage: ${usedMB.toFixed(2)} MB`)
// Assert reasonable memory usage
expect(usedMB).toBeLessThan(100) // < 100MB
}
})
// performance-budgets.ts
export const budgets = {
homepage: {
lcp: 2500,
cls: 0.1,
fcp: 1800,
ttfb: 600,
totalSize: 1500000, // 1.5MB
jsSize: 500000, // 500KB
imageCount: 20,
},
dashboard: {
lcp: 3000,
cls: 0.1,
fcp: 2000,
ttfb: 800,
totalSize: 2000000,
jsSize: 800000,
},
}
import {budgets} from './performance-budgets'
test('homepage meets performance budget', async ({page}) => {
const budget = budgets.homepage
await page.goto('/')
await page.waitForLoadState('networkidle')
// Measure LCP
const lcp = await page.evaluate(() => {
return new Promise<number>((resolve) => {
new PerformanceObserver((list) => {
const entries = list.getEntries()
resolve(entries[entries.length - 1].startTime)
}).observe({type: 'largest-contentful-paint', buffered: true})
})
})
// Measure resources
const resources = await page.evaluate(() => {
const entries = performance.getEntriesByType('resource') as PerformanceResourceTiming[]
return {
totalSize: entries.reduce((sum, e) => sum + (e.transferSize || 0), 0),
jsSize: entries
.filter((e) => e.initiatorType === 'script')
.reduce((sum, e) => sum + (e.transferSize || 0), 0),
imageCount: entries.filter((e) => e.initiatorType === 'img').length,
}
})
// Assert budgets
expect(lcp, 'LCP exceeds budget').toBeLessThan(budget.lcp)
expect(resources.totalSize, 'Total size exceeds budget').toBeLessThan(budget.totalSize)
expect(resources.jsSize, 'JS size exceeds budget').toBeLessThan(budget.jsSize)
expect(resources.imageCount, 'Too many images').toBeLessThanOrEqual(budget.imageCount)
})
// fixtures/performance.fixture.ts
type PerformanceBudget = {
lcp?: number
cls?: number
ttfb?: number
totalSize?: number
}
type PerformanceFixtures = {
assertBudget: (budget: PerformanceBudget) => Promise<void>
}
export const test = base.extend<PerformanceFixtures>({
assertBudget: async ({page}, use) => {
await use(async (budget) => {
const metrics = await page.evaluate(() => {
const nav = performance.getEntriesByType('navigation')[0] as PerformanceNavigationTiming
const resources = performance.getEntriesByType('resource') as PerformanceResourceTiming[]
return {
ttfb: nav.responseStart - nav.requestStart,
totalSize: resources.reduce((sum, r) => sum + (r.transferSize || 0), 0),
}
})
if (budget.ttfb) {
expect(metrics.ttfb, `TTFB ${metrics.ttfb}ms exceeds budget ${budget.ttfb}ms`).toBeLessThan(
budget.ttfb,
)
}
if (budget.totalSize) {
expect(metrics.totalSize, `Total size exceeds budget`).toBeLessThan(budget.totalSize)
}
})
},
})
npm install -D playwright-lighthouse lighthouse
import {playAudit} from 'playwright-lighthouse'
test('lighthouse audit', async ({page}) => {
await page.goto('/')
// Run Lighthouse
const audit = await playAudit({
page,
port: 9222, // Chrome debugging port
thresholds: {
'performance': 80,
'accessibility': 90,
'best-practices': 80,
'seo': 80,
},
})
// Assertions
expect(audit.lhr.categories.performance.score * 100).toBeGreaterThanOrEqual(80)
expect(audit.lhr.categories.accessibility.score * 100).toBeGreaterThanOrEqual(90)
})
test('lighthouse with custom config', async ({page}, testInfo) => {
await page.goto('/')
const audit = await playAudit({
page,
port: 9222,
thresholds: {
performance: 70,
},
config: {
extends: 'lighthouse:default',
settings: {
onlyCategories: ['performance'],
throttling: {
rttMs: 40,
throughputKbps: 10240,
cpuSlowdownMultiplier: 1,
},
},
},
})
// Save report
const reportPath = testInfo.outputPath('lighthouse-report.html')
// Save audit.report to file
// Attach to test report
await testInfo.attach('lighthouse', {
body: JSON.stringify(audit.lhr),
contentType: 'application/json',
})
})
// reporters/perf-reporter.ts
import {Reporter, TestResult} from '@playwright/test/reporter'
class PerfReporter implements Reporter {
private metrics: any[] = []
onTestEnd(test: any, result: TestResult) {
const perfAnnotation = test.annotations.find((a: any) => a.type === 'performance')
if (perfAnnotation) {
this.metrics.push({
test: test.title,
...JSON.parse(perfAnnotation.description),
timestamp: new Date().toISOString(),
})
}
}
async onEnd() {
// Send to metrics service
if (process.env.METRICS_ENDPOINT) {
await fetch(process.env.METRICS_ENDPOINT, {
method: 'POST',
body: JSON.stringify({
commit: process.env.GITHUB_SHA,
branch: process.env.GITHUB_REF,
metrics: this.metrics,
}),
})
}
}
}
export default PerfReporter
test('no performance regression', async ({page}) => {
await page.goto('/')
const metrics = await page.evaluate(() => {
const nav = performance.getEntriesByType('navigation')[0] as PerformanceNavigationTiming
return {
loadTime: nav.loadEventEnd - nav.startTime,
}
})
// Compare against baseline (could be from file or API)
const baseline = 2000 // ms
const threshold = 1.1 // 10% regression allowed
expect(
metrics.loadTime,
`Load time ${metrics.loadTime}ms is ${((metrics.loadTime / baseline - 1) * 100).toFixed(1)}% slower than baseline`,
).toBeLessThan(baseline * threshold)
})
| Anti-Pattern | Problem | Solution |
|---|---|---|
| Testing only once | Results vary | Run multiple times, use averages |
| Ignoring network conditions | Unrealistic results | Test with throttling |
| No baseline comparison | Can't detect regressions | Track metrics over time |
| Testing in dev mode | Slow, not production-like | Test production builds |