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

23 KiB
Raw Permalink Blame History

Performance Testing

适用场景:测量并强制执行 Web Vitals、资源加载时机、打包体积和运行时性能。使用 Playwright 在 CI 中捕获性能回归,在用户察觉之前及时拦截。 前置条件core/configuration.mdcore/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-length0 的情况 压缩响应可能不报告大小 使用 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

相关