16 KiB
使用场景:衡量 Playwright E2E 测试对应用程序代码的覆盖程度。适用于识别未经测试的代码路径,并在 CI 中强制执行覆盖率阈值。
快速参考
# 安装覆盖率依赖
npm install -D nyc istanbul-lib-coverage istanbul-reports v8-to-istanbul
# 运行并收集覆盖率
npx playwright test # 使用 fixture 收集覆盖率
# 从收集的数据生成报告
npx nyc report --reporter=html --reporter=text-summary
npx nyc report --reporter=lcov # 用于 CI 集成(Codecov、SonarQube)
# 检查覆盖率阈值
npx nyc check-coverage --lines 80 --branches 70 --functions 75
模式
模式 1:V8 覆盖率 + Playwright(推荐)
适用场景:在 E2E 测试期间测量 Web 应用程序的代码覆盖率。V8 覆盖率利用 Chrome DevTools 协议实现准确的 JavaScript 覆盖率。 避免场景:测试不受你控制的第三方站点。
第 1 步:创建覆盖率 fixture:
// fixtures/coverage.ts
import { test as base, expect } from "@playwright/test"
import * as fs from "fs"
import * as path from "path"
import * as crypto from "crypto"
const coverageDir = path.resolve(process.cwd(), ".nyc_output")
export const test = base.extend({
page: async ({ page, browserName }, use, testInfo) => {
// V8 覆盖率仅在 Chromium 中有效
if (browserName === "chromium") {
await page.coverage.startJSCoverage({ resetOnNavigation: false })
}
await use(page)
if (browserName === "chromium") {
const coverage = await page.coverage.stopJSCoverage()
// 过滤出仅属于应用程序的代码
const appCoverage = coverage.filter(
(entry) => entry.url.includes("localhost") && !entry.url.includes("node_modules")
)
if (appCoverage.length > 0) {
// 确保输出目录存在
fs.mkdirSync(coverageDir, { recursive: true })
// 写入 V8 覆盖率数据
const coverageFile = path.join(coverageDir, `coverage-${crypto.randomUUID()}.json`)
fs.writeFileSync(
coverageFile,
JSON.stringify({
result: appCoverage.map((entry) => ({
scriptId: "0",
url: entry.url,
functions: entry.functions || [],
})),
})
)
}
}
},
})
export { expect }
第 2 步:在测试中使用覆盖率 fixture:
// tests/dashboard.spec.ts
import { test, expect } from "../fixtures/coverage"
test("仪表盘加载小部件", async ({ page }) => {
await page.goto("/dashboard")
await expect(page.getByRole("heading", { name: "Dashboard" })).toBeVisible()
// 覆盖率会自动收集并保存
})
第 3 步:测试后生成覆盖率报告:
// package.json
{
"scripts": {
"test:e2e": "npx playwright test",
"test:coverage": "npx playwright test --project=chromium && npx nyc report --reporter=html --reporter=text-summary",
"coverage:check": "npx nyc check-coverage --lines 80 --branches 70 --functions 75"
}
}
模式 2:Istanbul(基于 Source Map)覆盖率
适用场景:你的应用程序使用 webpack、Vite 或其他打包工具,并且你需要与原始源文件关联的 source map 覆盖率。 避免场景:V8 覆盖率已经足够——它的设置更简单。
第 1 步:对应用程序代码进行插桩:
对于 Vite:
npm install -D vite-plugin-istanbul
// vite.config.ts
import { defineConfig } from "vite"
import istanbul from "vite-plugin-istanbul"
export default defineConfig({
plugins: [
...(process.env.COVERAGE === "true"
? [
istanbul({
include: "src/*",
exclude: ["node_modules", "test/"],
extension: [".js", ".ts", ".tsx", ".jsx"],
requireEnv: true,
}),
]
: []),
],
})
对于 webpack(Create React App):
npm install -D @istanbuljs/nyc-config-typescript babel-plugin-istanbul
// babel.config.json(或 .babelrc)
{
"env": {
"test": {
"plugins": ["istanbul"]
}
}
}
第 2 步:从 window.__coverage__ 收集覆盖率:
// fixtures/istanbul-coverage.ts
import { test as base, expect } from "@playwright/test"
import * as fs from "fs"
import * as path from "path"
import * as crypto from "crypto"
const coverageDir = path.resolve(process.cwd(), ".nyc_output")
export const test = base.extend({
page: async ({ page }, use) => {
await use(page)
// Istanbul 对代码进行插桩,并将覆盖率暴露在 window.__coverage__ 上
const coverage = await page.evaluate(() => (window as any).__coverage__)
if (coverage) {
fs.mkdirSync(coverageDir, { recursive: true })
const coverageFile = path.join(coverageDir, `coverage-${crypto.randomUUID()}.json`)
fs.writeFileSync(coverageFile, JSON.stringify(coverage))
}
},
})
export { expect }
第 3 步:配置 nyc:
// .nycrc.json
{
"extends": "@istanbuljs/nyc-config-typescript",
"all": true,
"include": ["src/**/*.{ts,tsx,js,jsx}"],
"exclude": ["src/**/*.test.*", "src/**/*.spec.*", "src/test/**", "src/**/*.d.ts"],
"reporter": ["html", "text-summary", "lcov"],
"report-dir": "coverage"
}
# 使用插桩启动应用
COVERAGE=true npm run dev
# 运行测试(在另一个终端中,或等待开发服务器启动后)
npx playwright test --project=chromium
# 生成报告
npx nyc report
模式 3:CI 中的覆盖率阈值
适用场景:将最低覆盖率作为质量门禁强制执行。 避免场景:你才刚刚开始接触覆盖率——请在建立基线之后再设置阈值。
// .nycrc.json
{
"check-coverage": true,
"lines": 80,
"branches": 70,
"functions": 75,
"statements": 80,
"reporter": ["html", "text-summary", "lcov"]
}
带覆盖率检查的 GitHub Actions:
# .github/workflows/playwright.yml(添加到测试任务步骤中)
- name: 运行带覆盖率的 Playwright 测试
run: COVERAGE=true npx playwright test --project=chromium
- name: 生成覆盖率报告
run: npx nyc report --reporter=html --reporter=text-summary --reporter=lcov
if: ${{ !cancelled() }}
- name: 检查覆盖率阈值
run: npx nyc check-coverage --lines 80 --branches 70 --functions 75
- name: 上传覆盖率报告
uses: actions/upload-artifact@v4
if: ${{ !cancelled() }}
with:
name: coverage-report
path: coverage/
retention-days: 14
模式 4:合并来自分片运行的覆盖率
适用场景:测试在多台 CI 机器上分片运行,你需要聚合后的覆盖率。 避免场景:测试在单台机器上运行。
# .github/workflows/playwright.yml
jobs:
test:
strategy:
fail-fast: false
matrix:
shard: [1/4, 2/4, 3/4, 4/4]
steps:
# ... 检出、安装等
- name: 运行带覆盖率的分片测试
run: COVERAGE=true npx playwright test --shard=${{ matrix.shard }} --project=chromium
- name: 上传覆盖率数据
uses: actions/upload-artifact@v4
if: ${{ !cancelled() }}
with:
name: coverage-${{ strategy.job-index }}
path: .nyc_output/
retention-days: 1
merge-coverage:
needs: test
if: ${{ !cancelled() }}
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 20
cache: "npm"
- run: npm ci
- name: 下载所有覆盖率数据
uses: actions/download-artifact@v4
with:
path: all-coverage
pattern: coverage-*
merge-multiple: true
- name: 合并覆盖率
run: |
mkdir -p .nyc_output
cp all-coverage/*.json .nyc_output/
npx nyc report --reporter=html --reporter=text-summary --reporter=lcov
npx nyc check-coverage --lines 80 --branches 70 --functions 75
- name: 上传合并后的覆盖率报告
uses: actions/upload-artifact@v4
with:
name: coverage-report
path: coverage/
retention-days: 14
模式 5:CSS 覆盖率
适用场景:识别未使用的 CSS 以减小打包体积。 避免场景:CSS 对你的应用程序来说不是性能问题。
// fixtures/css-coverage.ts
import { test as base, expect } from "@playwright/test"
import * as fs from "fs"
import * as path from "path"
export const test = base.extend({
page: async ({ page, browserName }, use, testInfo) => {
if (browserName === "chromium") {
await page.coverage.startCSSCoverage()
}
await use(page)
if (browserName === "chromium") {
const coverage = await page.coverage.stopCSSCoverage()
// 计算每个文件中未使用的 CSS 百分比
const report = coverage.map((entry) => {
const totalBytes = entry.text.length
const usedBytes = entry.ranges.reduce((acc, range) => acc + (range.end - range.start), 0)
return {
url: entry.url,
totalBytes,
usedBytes,
unusedPercent: ((1 - usedBytes / totalBytes) * 100).toFixed(1),
}
})
// 保存以供分析
const outputDir = path.resolve(process.cwd(), "css-coverage")
fs.mkdirSync(outputDir, { recursive: true })
fs.writeFileSync(
path.join(outputDir, `${testInfo.testId}.json`),
JSON.stringify(report, null, 2)
)
}
},
})
export { expect }
决策指南
| 覆盖率类型 | 工具 | 衡量内容 | 设置难度 | 准确性 |
|---|---|---|---|---|
| V8(Chrome DevTools) | page.coverage API |
浏览器中的 JS 执行 | 低 | 高(运行时) |
| Istanbul(插桩) | babel-plugin-istanbul / vite-plugin-istanbul |
带 source map 的 JS | 中 | 高(源码级别) |
| CSS | page.coverage.startCSSCoverage() |
CSS 规则使用情况 | 低 | 高 |
| 指标 | 衡量内容 | 推荐阈值 | 原因 |
|---|---|---|---|
| Lines | 已执行的代码行数 | 80%+ | 最直观;可发现死代码 |
| Branches | 已覆盖的 if/else 路径 | 70%+ | 可发现未测试的条件分支 |
| Functions | 已调用的函数 | 75%+ | 可发现未使用的函数 |
| Statements | 已执行的单个语句 | 80%+ | 与 lines 类似,但统计多语句行的每条语句 |
| 场景 | 方案 | 原因 |
|---|---|---|
| 刚刚开始接触覆盖率 | V8 覆盖率,不设阈值 | 先建立基线 |
| 成熟的代码库 | Istanbul + CI 中的阈值 | 基于 source map,强制执行 |
| 分片 CI | 合并所有分片后的 .nyc_output |
聚合后的覆盖率视图 |
| 只关心 Chromium | V8 覆盖率 fixture | 设置最简单,无需修改构建 |
| 需要源码级别的准确性 | Istanbul + 打包工具插件 | 映射回原始源文件 |
| 上传到 Codecov / SonarQube | 生成 lcov 格式 |
覆盖率服务的标准格式 |
反模式
| 反模式 | 问题 | 推荐做法 |
|---|---|---|
| 追求 100% E2E 覆盖率 | E2E 测试成本高,超过 80% 后收益递减 | 设定合理的阈值;用单元测试覆盖边界情况 |
| 在所有浏览器中收集覆盖率 | V8 覆盖率仅在 Chromium 中有效,其他浏览器浪费资源 | 仅在 --project=chromium 下运行覆盖率收集 |
| 未过滤源文件 | 覆盖率包含 node_modules 和第三方代码 |
过滤:entry.url.includes('localhost') && !entry.url.includes('node_modules') |
| 覆盖率拖慢 CI 却未产生价值 | 在每个 PR 上都收集覆盖率但没有设置阈值 | 要么强制执行阈值,要么跳过覆盖率收集 |
| 在生产构建中启用 Istanbul 插桩 | 性能开销;暴露内部实现 | 仅在 COVERAGE=true 时进行插桩 |
| 仅靠 E2E 覆盖率衡量 | E2E 测试覆盖的是 happy path,单元测试覆盖的是分支 | 将 E2E 覆盖率与单元测试覆盖率结合使用 |
故障排除
覆盖率报告显示 0% 或为空
原因(V8):测试未使用覆盖率 fixture,或者未调用 startJSCoverage()。
修复:确保测试从覆盖率 fixture 文件导入,而不是直接从 @playwright/test 导入:
// 使用这个:
import { test, expect } from "../fixtures/coverage"
// 不要用这个:
import { test, expect } from "@playwright/test"
原因(Istanbul):应用程序未进行插桩,或者 window.__coverage__ 为 undefined。
修复:确认应用程序已启用插桩运行:
# 检查覆盖率是否已暴露
COVERAGE=true npm run dev
# 在浏览器控制台中:window.__coverage__ -> 应该是一个对象,而不是 undefined
覆盖率文件无法正确合并
原因:来自不同分片的覆盖率 JSON 文件格式不兼容或键重叠。
修复:确保所有分片使用唯一的文件名写入 .nyc_output/:
const coverageFile = path.join(coverageDir, `coverage-${crypto.randomUUID()}.json`)
然后使用 npx nyc report 合并(它会读取 .nyc_output/ 中的所有文件)。
V8 覆盖率无法映射回源文件
原因:V8 覆盖率报告的是打包/编译后的 URL,而不是原始源文件。
修复:改用 Istanbul 插桩以获得源码级别的准确性,或者使用 v8-to-istanbul 进行转换:
npm install -D v8-to-istanbul
并行运行时代码覆盖率下降
原因:如果文件名冲突,并行 worker 会互相覆盖覆盖率文件。
修复:在文件名中包含 workerInfo.workerIndex 或 UUID:
const coverageFile = path.join(
coverageDir,
`coverage-worker${workerInfo.workerIndex}-${crypto.randomUUID()}.json`
)
相关文档
- ci/parallel-and-sharding.md —— 从分片运行中合并覆盖率
- ci/ci-github-actions.md —— CI 中的覆盖率阈值
- ci/reporting-and-artifacts.md —— 上传覆盖率报告
- core/configuration.md —— 覆盖率的项目配置
- core/test-architecture.md —— E2E 与单元测试覆盖率策略