461 lines
16 KiB
Markdown
461 lines
16 KiB
Markdown
> **使用场景**:衡量 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
|
||
```
|
||
|
||
## 模式
|
||
|
||
### 模式 1:V8 覆盖率 + 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"
|
||
}
|
||
}
|
||
```
|
||
|
||
### 模式 2:Istanbul(基于 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
|
||
```
|
||
|
||
### 模式 5:CSS 覆盖率
|
||
|
||
**适用场景**:识别未使用的 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 }
|
||
```
|
||
|
||
## 决策指南
|
||
|
||
| 覆盖率类型 | 工具 | 衡量内容 | 设置难度 | 准确性 |
|
||
| -------------------------- | ------------------------------------------------ | --------------------- | -------- | -------------------- |
|
||
| 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` 导入:
|
||
|
||
```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 与单元测试覆盖率策略
|