项目文件夹

文件
2026-07-13 21:36:47 +08:00

461 行
16 KiB
Markdown

此文件含有模棱两可的 Unicode 字符
此文件含有可能会与其他字符混淆的 Unicode 字符。 如果您是想特意这样的,可以安全地忽略该警告。 使用 Escape 按钮显示他们。
> **使用场景**:衡量 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 与单元测试覆盖率策略