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

16 KiB
Raw Permalink Blame History

使用场景:衡量 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

模式

模式 1V8 覆盖率 + 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"
  }
}

模式 2Istanbul(基于 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,
          }),
        ]
      : []),
  ],
})

对于 webpackCreate 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

模式 3CI 中的覆盖率阈值

适用场景:将最低覆盖率作为质量门禁强制执行。 避免场景:你才刚刚开始接触覆盖率——请在建立基线之后再设置阈值。

// .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

模式 5CSS 覆盖率

适用场景:识别未使用的 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 }

决策指南

覆盖率类型 工具 衡量内容 设置难度 准确性
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 导入:

// 使用这个:
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`
)

相关文档