# Performance Testing > **适用场景**:测量并强制执行 Web Vitals、资源加载时机、打包体积和运行时性能。使用 Playwright 在 CI 中捕获性能回归,在用户察觉之前及时拦截。 > **前置条件**:[core/configuration.md](configuration.md)、[core/assertions-and-waiting.md](assertions-and-waiting.md) ## 快速参考 ```typescript // 测量最大内容绘制(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 }) }) }) expect(lcp).toBeLessThan(2500) // 良好的 LCP 阈值 // 将网络限速为 3G const client = await page.context().newCDPSession(page) await client.send("Network.emulateNetworkConditions", { offline: false, downloadThroughput: (1.6 * 1024 * 1024) / 8, uploadThroughput: (750 * 1024) / 8, latency: 150, }) ``` ## 模式 ### Web Vitals 测量(LCP、CLS、FID/INP) **适用场景**:在测试套件中强制执行 Core Web Vitals 阈值。 **避免场景**:你只需要聚合的字段数据——改用 Chrome UX Report 或 RUM 工具。 **TypeScript** ```typescript import { test, expect } from "@playwright/test" test("Core Web Vitals meet thresholds on homepage", async ({ page }) => { // 在导航前注入 Web Vitals 观察器 await page.addInitScript(() => { ;(window as any).__webVitals = { lcp: 0, cls: 0, fid: 0 } new PerformanceObserver((list) => { const entries = list.getEntries() ;(window as any).__webVitals.lcp = entries[entries.length - 1].startTime }).observe({ type: "largest-contentful-paint", buffered: true }) new PerformanceObserver((list) => { let clsValue = 0 for (const entry of list.getEntries()) { if (!(entry as any).hadRecentInput) { clsValue += (entry as any).value } } ;(window as any).__webVitals.cls = clsValue }).observe({ type: "layout-shift", buffered: true }) new PerformanceObserver((list) => { const entries = list.getEntries() ;(window as any).__webVitals.fid = entries[0]?.processingStart - entries[0]?.startTime }).observe({ type: "first-input", buffered: true }) }) await page.goto("/") // 触发一次用户交互以测量 FID await page.getByRole("button", { name: "Get started" }).click() // 等待 LCP 稳定 await page.waitForTimeout(1000) // 此处可接受:等待指标最终确定 const vitals = await page.evaluate(() => (window as any).__webVitals) expect(vitals.lcp).toBeLessThan(2500) // 良好:<2.5s expect(vitals.cls).toBeLessThan(0.1) // 良好:<0.1 // 自动化测试中 FID 可能为 0,因为没有真实的用户延迟 if (vitals.fid > 0) { expect(vitals.fid).toBeLessThan(100) // 良好:<100ms } }) ``` **JavaScript** ```javascript const { test, expect } = require("@playwright/test") test("LCP meets threshold on homepage", async ({ page }) => { await page.addInitScript(() => { window.__lcp = 0 new PerformanceObserver((list) => { const entries = list.getEntries() window.__lcp = entries[entries.length - 1].startTime }).observe({ type: "largest-contentful-paint", buffered: true }) }) await page.goto("/") await page.waitForTimeout(1000) const lcp = await page.evaluate(() => window.__lcp) expect(lcp).toBeLessThan(2500) }) ``` ### Performance API 访问 **适用场景**:测量导航计时、资源加载或自定义性能标记。 **避免场景**:仅 Web Vitals 已能满足需求。 **TypeScript** ```typescript import { test, expect } from "@playwright/test" test("page load timing is within budget", async ({ page }) => { await page.goto("/dashboard") const timing = await page.evaluate(() => { const nav = performance.getEntriesByType("navigation")[0] as PerformanceNavigationTiming return { dns: nav.domainLookupEnd - nav.domainLookupStart, tcp: nav.connectEnd - nav.connectStart, ttfb: nav.responseStart - nav.requestStart, domContentLoaded: nav.domContentLoadedEventEnd - nav.startTime, loadComplete: nav.loadEventEnd - nav.startTime, domInteractive: nav.domInteractive - nav.startTime, } }) expect(timing.ttfb).toBeLessThan(600) // TTFB 在 600ms 以内 expect(timing.domContentLoaded).toBeLessThan(2000) // DOM 就绪在 2s 以内 expect(timing.loadComplete).toBeLessThan(5000) // 完全加载在 5s 以内 }) test("critical API calls complete within budget", async ({ page }) => { await page.goto("/dashboard") const apiTimings = await page.evaluate(() => { return performance .getEntriesByType("resource") .filter((r) => r.name.includes("/api/")) .map((r) => ({ name: r.name.split("/api/")[1], duration: r.duration, size: (r as PerformanceResourceTiming).transferSize, })) }) for (const api of apiTimings) { expect(api.duration, `API ${api.name} too slow`).toBeLessThan(1000) } }) ``` **JavaScript** ```javascript const { test, expect } = require("@playwright/test") test("page load timing is within budget", async ({ page }) => { await page.goto("/dashboard") const timing = await page.evaluate(() => { const nav = performance.getEntriesByType("navigation")[0] return { ttfb: nav.responseStart - nav.requestStart, domContentLoaded: nav.domContentLoadedEventEnd - nav.startTime, loadComplete: nav.loadEventEnd - nav.startTime, } }) expect(timing.ttfb).toBeLessThan(600) expect(timing.domContentLoaded).toBeLessThan(2000) expect(timing.loadComplete).toBeLessThan(5000) }) ``` ### 资源加载与打包体积监控 **适用场景**:强制执行打包体积预算,发现意外的大资源。 **避免场景**:打包分析已由 webpack-bundle-analyzer 或类似的构建工具处理。 **TypeScript** ```typescript import { test, expect } from "@playwright/test" test("JavaScript bundle sizes are within budget", async ({ page }) => { const resourceSizes: { name: string; size: number }[] = [] page.on("response", async (response) => { const url = response.url() if (url.endsWith(".js") || url.includes(".js?")) { const headers = response.headers() const size = parseInt(headers["content-length"] || "0") resourceSizes.push({ name: url.split("/").pop()!.split("?")[0], size, }) } }) await page.goto("/") await page.waitForLoadState("networkidle") // 单个 JS 包压缩后不应超过 250KB for (const resource of resourceSizes) { expect( resource.size, `Bundle ${resource.name} is ${(resource.size / 1024).toFixed(1)}KB` ).toBeLessThan(250 * 1024) } // JS 总大小不应超过 500KB const totalSize = resourceSizes.reduce((sum, r) => sum + r.size, 0) expect(totalSize, `Total JS: ${(totalSize / 1024).toFixed(1)}KB`).toBeLessThan(500 * 1024) }) test("no unexpected large images", async ({ page }) => { const largeImages: { url: string; size: number }[] = [] page.on("response", async (response) => { const contentType = response.headers()["content-type"] || "" if (contentType.startsWith("image/")) { const size = parseInt(response.headers()["content-length"] || "0") if (size > 200 * 1024) { largeImages.push({ url: response.url(), size }) } } }) await page.goto("/") await page.waitForLoadState("networkidle") expect( largeImages, `Found ${largeImages.length} images over 200KB: ${largeImages.map((i) => i.url).join(", ")}` ).toHaveLength(0) }) ``` **JavaScript** ```javascript const { test, expect } = require("@playwright/test") test("JavaScript bundle sizes are within budget", async ({ page }) => { const resourceSizes = [] page.on("response", async (response) => { const url = response.url() if (url.endsWith(".js") || url.includes(".js?")) { const size = parseInt(response.headers()["content-length"] || "0") resourceSizes.push({ name: url.split("/").pop().split("?")[0], size }) } }) await page.goto("/") await page.waitForLoadState("networkidle") const totalSize = resourceSizes.reduce((sum, r) => sum + r.size, 0) expect(totalSize).toBeLessThan(500 * 1024) }) ``` ### 通过 CDP 模拟慢速网络 **适用场景**:在受限网络条件下测试应用的行为和性能。 **避免场景**:Playwright 内置的 `offline` 选项已满足测试需求。 **TypeScript** ```typescript import { test, expect, type Page } from "@playwright/test" // 网络预设 const NETWORK_PRESETS = { slow3G: { offline: false, downloadThroughput: (500 * 1024) / 8, // 500 Kbps uploadThroughput: (500 * 1024) / 8, latency: 400, }, fast3G: { offline: false, downloadThroughput: (1.6 * 1024 * 1024) / 8, // 1.6 Mbps uploadThroughput: (750 * 1024) / 8, latency: 150, }, regularLTE: { offline: false, downloadThroughput: (4 * 1024 * 1024) / 8, // 4 Mbps uploadThroughput: (3 * 1024 * 1024) / 8, latency: 20, }, } as const async function throttleNetwork(page: Page, preset: keyof typeof NETWORK_PRESETS) { const client = await page.context().newCDPSession(page) await client.send("Network.enable") await client.send("Network.emulateNetworkConditions", NETWORK_PRESETS[preset]) return client } test("app shows loading states on slow network", async ({ page }) => { await throttleNetwork(page, "slow3G") await page.goto("/dashboard") // 内容加载缓慢时应显示加载骨架屏 await expect(page.getByTestId("loading-skeleton")).toBeVisible() // 内容最终应加载完成 await expect(page.getByRole("heading", { name: "Dashboard" })).toBeVisible({ timeout: 30000 }) }) test("images lazy-load on slow connection", async ({ page }) => { await throttleNetwork(page, "fast3G") await page.goto("/gallery") // 初始只应加载首屏图片 const loadedImages = await page.evaluate( () => Array.from(document.querySelectorAll("img")).filter( (img) => img.complete && img.naturalWidth > 0 ).length ) // 滚动以触发懒加载 await page.evaluate(() => window.scrollTo(0, document.body.scrollHeight)) await page.waitForTimeout(2000) const allLoadedImages = await page.evaluate( () => Array.from(document.querySelectorAll("img")).filter( (img) => img.complete && img.naturalWidth > 0 ).length ) expect(allLoadedImages).toBeGreaterThan(loadedImages) }) ``` **JavaScript** ```javascript const { test, expect } = require("@playwright/test") async function throttleNetwork(page, preset) { const presets = { slow3G: { offline: false, downloadThroughput: (500 * 1024) / 8, uploadThroughput: (500 * 1024) / 8, latency: 400, }, fast3G: { offline: false, downloadThroughput: (1.6 * 1024 * 1024) / 8, uploadThroughput: (750 * 1024) / 8, latency: 150, }, } const client = await page.context().newCDPSession(page) await client.send("Network.enable") await client.send("Network.emulateNetworkConditions", presets[preset]) return client } test("app shows loading states on slow network", async ({ page }) => { await throttleNetwork(page, "slow3G") await page.goto("/dashboard") await expect(page.getByTestId("loading-skeleton")).toBeVisible() await expect(page.getByRole("heading", { name: "Dashboard" })).toBeVisible({ timeout: 30000 }) }) ``` ### 通过 CDP 进行 CPU 节流 **适用场景**:模拟低端设备,测试动画流畅度、交互响应性或重度计算场景。 **避免场景**:瓶颈在于网络性能而非 CPU。 **TypeScript** ```typescript import { test, expect } from "@playwright/test" test("animations remain smooth under CPU throttling", async ({ page }) => { const client = await page.context().newCDPSession(page) // 4 倍减速模拟中端移动设备 await client.send("Emulation.setCPUThrottlingRate", { rate: 4 }) await page.goto("/animations-demo") await page.getByRole("button", { name: "Start animation" }).click() // 测量动画期间的帧率 const fps = await page.evaluate(() => { return new Promise((resolve) => { let frames = 0 const start = performance.now() function count() { frames++ if (performance.now() - start < 1000) { requestAnimationFrame(count) } else { resolve(frames) } } requestAnimationFrame(count) }) }) // 即使在节流 CPU 上,也应保持至少 30fps expect(fps).toBeGreaterThan(30) // 重置节流 await client.send("Emulation.setCPUThrottlingRate", { rate: 1 }) }) test("search input responds quickly under CPU constraint", async ({ page }) => { const client = await page.context().newCDPSession(page) await client.send("Emulation.setCPUThrottlingRate", { rate: 4 }) await page.goto("/search") const start = Date.now() await page.getByRole("textbox", { name: "Search" }).fill("test query") await expect(page.getByRole("listbox")).toBeVisible() const elapsed = Date.now() - start // 即使在 4 倍 CPU 节流下,自动补全也应出现在 500ms 以内 expect(elapsed).toBeLessThan(500) await client.send("Emulation.setCPUThrottlingRate", { rate: 1 }) }) ``` **JavaScript** ```javascript const { test, expect } = require("@playwright/test") test("animations remain smooth under CPU throttling", async ({ page }) => { const client = await page.context().newCDPSession(page) await client.send("Emulation.setCPUThrottlingRate", { rate: 4 }) await page.goto("/animations-demo") await page.getByRole("button", { name: "Start animation" }).click() const fps = await page.evaluate(() => { return new Promise((resolve) => { let frames = 0 const start = performance.now() function count() { frames++ if (performance.now() - start < 1000) { requestAnimationFrame(count) } else { resolve(frames) } } requestAnimationFrame(count) }) }) expect(fps).toBeGreaterThan(30) await client.send("Emulation.setCPUThrottlingRate", { rate: 1 }) }) ``` ### CI 中的性能预算 **适用场景**:强制执行硬性性能限制,超出阈值时阻止合并。 **避免场景**:CI 环境中性能波动过大——改用基于趋势的监控。 **TypeScript** ```typescript import { test, expect } from "@playwright/test" // 在共享配置中定义预算 const PERFORMANCE_BUDGETS = { homepage: { lcp: 2500, cls: 0.1, ttfb: 600, totalJsSize: 500 * 1024, totalImageSize: 1000 * 1024, domContentLoaded: 2000, }, dashboard: { lcp: 3000, cls: 0.1, ttfb: 800, totalJsSize: 750 * 1024, totalImageSize: 500 * 1024, domContentLoaded: 3000, }, } as const test.describe("performance budgets", () => { test("homepage meets performance budget", async ({ page }) => { const budget = PERFORMANCE_BUDGETS.homepage let totalJsSize = 0 page.on("response", (response) => { if (response.url().endsWith(".js") || response.url().includes(".js?")) { totalJsSize += parseInt(response.headers()["content-length"] || "0") } }) // 注入 LCP 观察器 await page.addInitScript(() => { ;(window as any).__lcp = 0 new PerformanceObserver((list) => { const entries = list.getEntries() ;(window as any).__lcp = entries[entries.length - 1].startTime }).observe({ type: "largest-contentful-paint", buffered: true }) }) await page.goto("/") await page.waitForLoadState("networkidle") const metrics = await page.evaluate(() => { const nav = performance.getEntriesByType("navigation")[0] as PerformanceNavigationTiming return { lcp: (window as any).__lcp, ttfb: nav.responseStart - nav.requestStart, domContentLoaded: nav.domContentLoadedEventEnd - nav.startTime, } }) expect(metrics.lcp, "LCP budget exceeded").toBeLessThan(budget.lcp) expect(metrics.ttfb, "TTFB budget exceeded").toBeLessThan(budget.ttfb) expect(metrics.domContentLoaded, "DOMContentLoaded budget exceeded").toBeLessThan( budget.domContentLoaded ) expect(totalJsSize, "JS bundle budget exceeded").toBeLessThan(budget.totalJsSize) }) }) ``` **JavaScript** ```javascript const { test, expect } = require("@playwright/test") const PERFORMANCE_BUDGETS = { homepage: { lcp: 2500, ttfb: 600, totalJsSize: 500 * 1024, domContentLoaded: 2000 }, } test("homepage meets performance budget", async ({ page }) => { const budget = PERFORMANCE_BUDGETS.homepage let totalJsSize = 0 page.on("response", (response) => { if (response.url().endsWith(".js")) { totalJsSize += parseInt(response.headers()["content-length"] || "0") } }) await page.addInitScript(() => { window.__lcp = 0 new PerformanceObserver((list) => { const entries = list.getEntries() window.__lcp = entries[entries.length - 1].startTime }).observe({ type: "largest-contentful-paint", buffered: true }) }) await page.goto("/") await page.waitForLoadState("networkidle") const metrics = await page.evaluate(() => { const nav = performance.getEntriesByType("navigation")[0] return { lcp: window.__lcp, ttfb: nav.responseStart - nav.requestStart, domContentLoaded: nav.domContentLoadedEventEnd - nav.startTime, } }) expect(metrics.lcp).toBeLessThan(budget.lcp) expect(metrics.ttfb).toBeLessThan(budget.ttfb) expect(totalJsSize).toBeLessThan(budget.totalJsSize) }) ``` ## 决策指南 | 测量内容 | 技术 | 适用场景 | | ------------------------- | --------------------------------------------------- | ---------------------------------------- | | LCP、CLS、FID/INP | 通过 `addInitScript` 注入 `PerformanceObserver` | Core Web Vitals 回归测试 | | TTFB、DOM 加载时间 | `performance.getEntriesByType('navigation')` | 服务端响应与页面加载预算 | | API 调用耗时 | `performance.getEntriesByType('resource')` | 后端性能回归 | | JS/CSS 打包体积 | `page.on('response')` + `content-length` 响应头 | CI 中的打包体积预算 | | 慢速网络行为 | CDP `Network.emulateNetworkConditions` | 测试加载状态、懒加载、离线场景 | | 低端设备行为 | CDP `Emulation.setCPUThrottlingRate` | 动画流畅度、交互延迟 | | 完整 Lighthouse 审计 | `@playwright/test` + 通过 CDP 端口的 Lighthouse CLI | 全面性能评分 | | 运行时性能 | `page.evaluate` + `requestAnimationFrame` 帧率计数 | 动画与渲染性能 | ## 反模式 | 不要这样做 | 问题 | 应改为 | | -------------------------------------------------------- | ---------------------------------------------------------- | ------------------------------------------------------------------------ | | 基于本地开发机设定绝对阈值 | CI 机器更慢,阈值不稳定 | 在 CI 硬件上校准预算,或使用相对比较 | | 将 `networkidle` 作为性能测量点 | `networkidle` 包含分析、广告、非关键资源 | 通过 Performance API 直接测量具体指标(LCP、TTFB) | | 在 CI 中以 `--headed` 模式运行性能测试 | Headed 模式增加 GPU 开销并导致不一致性 | 使用无头模式以保证一致的测量 | | 在自动化测试中测量 FID | 自动化环境不存在真实的用户输入延迟 | 测量 INP 或使用 Lighthouse 获取 FID 估算 | | 将性能测试与其他 CI 作业并行运行 | CPU 争用会导致结果偏差 | 在隔离环境或专用 CI 运行器上执行性能测试 | | 忽略 `content-length` 为 `0` 的情况 | 压缩响应可能不报告大小 | 使用 `response.body().length` 获取实际传输大小 | | 只测试正常路径的性能 | 慢速错误路径同样降低用户体验 | 测试错误状态、空状态和大数据集下的性能 | | 微小回归就硬性让 CI 失败 | 对非性能变更造成合并摩擦 | 使用警告阈值并强制审查,仅在重大回归时失败 | ## 故障排查 | 现象 | 可能原因 | 修复方法 | | ---------------------------------------------- | ------------------------------------------------------ | -------------------------------------------------------------------------------- | | LCP 为 0 或低得不真实 | 观察器未触发;页面没有符合条件的 LCP 元素 | 确认页面包含图片或大文本块;在观察器中添加 `buffered: true` | | CLS 始终为 0 | 布局偏移发生在观察器注册之前 | 使用 `addInitScript` 在页面加载前注入观察器 | | CDP 会话在 Firefox/WebKit 上报错 | CDP 仅适用于 Chromium | 保护 CDP 代码:`test.skip(browserName !== 'chromium')` | | 性能数值在运行之间波动极大 | CI 机器负载波动 | 多次运行性能测试后取中位数;使用专用运行器 | | `content-length` 响应头缺失 | 服务端使用分块传输编码 | 使用 `response.body()` 后检查 `Buffer.byteLength()` | | 网络限速无效果 | CDP 会话创建在错误的页面上 | 从页面的上下文创建 CDP 会话,而非单独的浏览器 | | 打包体积测试通过,但应用感觉缓慢 | 测量的是压缩后大小,而非解析后大小 | 同时检查 `performance.getEntriesByType('resource')` 中的 `decodedBodySize` | ## 相关 - [core/configuration.md](configuration.md)——性能敏感型测试的超时与重试设置 - [core/network-mocking.md](network-mocking.md)——模拟慢速 API 以进行性能边界测试 - [core/browser-apis.md](browser-apis.md)——使用浏览器 API 进行测量 - [ci/ci-github-actions.md](../ci/ci-github-actions.md)——性能预算的 CI 配置 - [core/clock-and-time-mocking.md](clock-and-time-mocking.md)——与时间相关的性能测试