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

461 lines
16 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.
> **使用场景**:衡量 Playwright E2E 测试对应用程序代码的覆盖程度。适用于识别未经测试的代码路径,并在 CI 中强制执行覆盖率阈值。
## 快速参考
```bash
# 安装覆盖率依赖
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
```
## 模式
### 模式 1V8 覆盖率 + Playwright(推荐)
**适用场景**:在 E2E 测试期间测量 Web 应用程序的代码覆盖率。V8 覆盖率利用 Chrome DevTools 协议实现准确的 JavaScript 覆盖率。
**避免场景**:测试不受你控制的第三方站点。
**第 1 步:创建覆盖率 fixture**
```ts
// 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:**
```ts
// 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 步:测试后生成覆盖率报告:**
```json
// 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"
}
}
```
### 模式 2Istanbul(基于 Source Map)覆盖率
**适用场景**:你的应用程序使用 webpack、Vite 或其他打包工具,并且你需要与原始源文件关联的 source map 覆盖率。
**避免场景**:V8 覆盖率已经足够——它的设置更简单。
**第 1 步:对应用程序代码进行插桩:**
对于 **Vite**
```bash
npm install -D vite-plugin-istanbul
```
```ts
// 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):
```bash
npm install -D @istanbuljs/nyc-config-typescript babel-plugin-istanbul
```
```json
// babel.config.json(或 .babelrc
{
"env": {
"test": {
"plugins": ["istanbul"]
}
}
}
```
**第 2 步:从 `window.__coverage__` 收集覆盖率:**
```ts
// 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**
```json
// .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"
}
```
```bash
# 使用插桩启动应用
COVERAGE=true npm run dev
# 运行测试(在另一个终端中,或等待开发服务器启动后)
npx playwright test --project=chromium
# 生成报告
npx nyc report
```
### 模式 3:CI 中的覆盖率阈值
**适用场景**:将最低覆盖率作为质量门禁强制执行。
**避免场景**:你才刚刚开始接触覆盖率——请在建立基线之后再设置阈值。
```json
// .nycrc.json
{
"check-coverage": true,
"lines": 80,
"branches": 70,
"functions": 75,
"statements": 80,
"reporter": ["html", "text-summary", "lcov"]
}
```
**带覆盖率检查的 GitHub Actions**
```yaml
# .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 机器上分片运行,你需要聚合后的覆盖率。
**避免场景**:测试在单台机器上运行。
```yaml
# .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
```
### 模式 5CSS 覆盖率
**适用场景**:识别未使用的 CSS 以减小打包体积。
**避免场景**:CSS 对你的应用程序来说不是性能问题。
```ts
// 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 }
```
## 决策指南
| 覆盖率类型 | 工具 | 衡量内容 | 设置难度 | 准确性 |
| -------------------------- | ------------------------------------------------ | --------------------- | -------- | -------------------- |
| V8Chrome 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` 导入:
```ts
// 使用这个:
import { test, expect } from "../fixtures/coverage"
// 不要用这个:
import { test, expect } from "@playwright/test"
```
**原因(Istanbul**:应用程序未进行插桩,或者 `window.__coverage__` 为 undefined。
**修复**:确认应用程序已启用插桩运行:
```bash
# 检查覆盖率是否已暴露
COVERAGE=true npm run dev
# 在浏览器控制台中:window.__coverage__ -> 应该是一个对象,而不是 undefined
```
### 覆盖率文件无法正确合并
**原因**:来自不同分片的覆盖率 JSON 文件格式不兼容或键重叠。
**修复**:确保所有分片使用唯一的文件名写入 `.nyc_output/`
```ts
const coverageFile = path.join(coverageDir, `coverage-${crypto.randomUUID()}.json`)
```
然后使用 `npx nyc report` 合并(它会读取 `.nyc_output/` 中的所有文件)。
### V8 覆盖率无法映射回源文件
**原因**:V8 覆盖率报告的是打包/编译后的 URL,而不是原始源文件。
**修复**:改用 Istanbul 插桩以获得源码级别的准确性,或者使用 `v8-to-istanbul` 进行转换:
```bash
npm install -D v8-to-istanbul
```
### 并行运行时代码覆盖率下降
**原因**:如果文件名冲突,并行 worker 会互相覆盖覆盖率文件。
**修复**:在文件名中包含 `workerInfo.workerIndex` 或 UUID
```ts
const coverageFile = path.join(
coverageDir,
`coverage-worker${workerInfo.workerIndex}-${crypto.randomUUID()}.json`
)
```
## 相关文档
- [ci/parallel-and-sharding.md](parallel-and-sharding.md) —— 从分片运行中合并覆盖率
- [ci/ci-github-actions.md](ci-github-actions.md) —— CI 中的覆盖率阈值
- [ci/reporting-and-artifacts.md](reporting-and-artifacts.md) —— 上传覆盖率报告
- [core/configuration.md](../core/configuration.md) —— 覆盖率的项目配置
- [core/test-architecture.md](../core/test-architecture.md) —— E2E 与单元测试覆盖率策略