639 lines
23 KiB
Markdown
639 lines
23 KiB
Markdown
# 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)——与时间相关的性能测试
|