23 KiB
23 KiB
Performance Testing
适用场景:测量并强制执行 Web Vitals、资源加载时机、打包体积和运行时性能。使用 Playwright 在 CI 中捕获性能回归,在用户察觉之前及时拦截。 前置条件:core/configuration.md、core/assertions-and-waiting.md
快速参考
// 测量最大内容绘制(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 })
})
})
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
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
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
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
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
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
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
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
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
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<number>((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
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
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
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——性能敏感型测试的超时与重试设置
- core/network-mocking.md——模拟慢速 API 以进行性能边界测试
- core/browser-apis.md——使用浏览器 API 进行测量
- ci/ci-github-actions.md——性能预算的 CI 配置
- core/clock-and-time-mocking.md——与时间相关的性能测试