# Performance Testing & Web Vitals ## Table of Contents 1. [Core Web Vitals](#core-web-vitals) 2. [Performance Metrics](#performance-metrics) 3. [Performance Budgets](#performance-budgets) 4. [Lighthouse Integration](#lighthouse-integration) 5. [Performance Fixtures](#performance-fixtures) 6. [CI Performance Monitoring](#ci-performance-monitoring) ## Core Web Vitals ### Measure LCP, FID, CLS ```typescript 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 }); ``` ### Using web-vitals Library ```typescript 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); }); ``` ## Performance Metrics ### Navigation Timing ```typescript 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 }); ``` ### Resource Timing ```typescript 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); }); ``` ### Memory Usage ```typescript 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 ### Define Budgets ```typescript // 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, }, }; ``` ### Test Against Budgets ```typescript 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((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, ); }); ``` ### Budget Fixture ```typescript // fixtures/performance.fixture.ts type PerformanceBudget = { lcp?: number; cls?: number; ttfb?: number; totalSize?: number; }; type PerformanceFixtures = { assertBudget: (budget: PerformanceBudget) => Promise; }; export const test = base.extend({ 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, ); } }); }, }); ``` ## Lighthouse Integration ### Using playwright-lighthouse ```bash npm install -D playwright-lighthouse lighthouse ``` ```typescript 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, ); }); ``` ### Lighthouse with Config ```typescript 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", }); }); ``` ## CI Performance Monitoring ### Track Performance Over Time ```typescript // 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; ``` ### Performance Regression Detection ```typescript 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-Patterns to Avoid | 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 | ## Related References - **Performance Optimization**: See [performance.md](../infrastructure-ci-cd/performance.md) for test execution performance - **CI/CD**: See [ci-cd.md](../infrastructure-ci-cd/ci-cd.md) for CI integration