Files
2026-07-13 21:36:47 +08:00

639 lines
23 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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<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**
```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<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**
```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)——与时间相关的性能测试