Vitest 自定义 Reporter 与覆盖率卡口:在 Monorepo 里搞增量覆盖率检测

13 阅读4分钟

Vitest 自定义 Reporter 与覆盖率卡口:在 Monorepo 里搞增量覆盖率检测

上周 CR 的时候,有个同事提了一行改动,改了个工具函数的边界判断。测试没加。CI 绿了。合了。

然后线上炸了。

回头看,项目覆盖率 82%,达标。但那个被改的函数,覆盖率是 0。全局覆盖率这个指标,在这种场景下约等于摆设——你改了 10 行代码,只要剩下几万行覆盖率够高,这 10 行裸奔也能过。

所以问题很明确:怎么只卡「本次改动」的覆盖率?

再加上项目是 Monorepo,十几个包,每次 CI 全量跑测试要七八分钟。能不能只跑受影响的包?

这篇就聊这两件事:增量覆盖率检测,和 Monorepo 下的测试编排。都基于 Vitest。

先搞清楚 Vitest 覆盖率是怎么收集的

Vitest 支持两个覆盖率 provider:istanbulv8

istanbul 是老方案,靠代码插桩——在你源码里塞计数器,跑一遍之后统计哪些行被执行了。好处是准,坏处是慢,而且插桩后的代码跟源码对不上,调试体验差。

v8 走的是 V8 引擎内置的覆盖率收集能力,不需要插桩。快,而且对 sourcemap 支持更好。Vitest 默认推荐 v8

配置很简单:

// vitest.config.ts
import { defineConfig } from 'vitest/config'

export default defineConfig({
  test: {
    coverage: {
      provider: 'v8',
      reporter: ['text', 'json', 'html'],
      // 全局阈值——但这不是今天的重点
      thresholds: {
        branches: 80,
        functions: 80,
        lines: 80,
        statements: 80,
      },
    },
  },
})

thresholds 这个配置,卡的是全局覆盖率。整个项目达标就过,不管你这次改了啥。聊胜于无。

增量覆盖率的思路

核心逻辑其实就三步:

  1. 拿到本次改动的文件列表和行号(git diff
  2. 拿到覆盖率报告里每个文件的行覆盖数据
  3. 交叉比对:改动的行里,有多少被测试覆盖了?

听着不复杂。但细节全在实现里。

先解决第一步,拿 diff:

// scripts/get-changed-lines.ts
import { execSync } from 'child_process'

interface ChangedLines {
  [filePath: string]: number[] // 文件路径 → 改动的行号数组
}

export function getChangedLines(baseBranch = 'main'): ChangedLines {
  // -U0:不要上下文行,只要真正改动的行
  const diff = execSync(`git diff ${baseBranch} --unified=0 --diff-filter=ACMR`)
    .toString()

  const result: ChangedLines = {}
  let currentFile = ''

  for (const line of diff.split('\n')) {
    // 匹配文件路径
    if (line.startsWith('+++ b/')) {
      currentFile = line.slice(6)
      result[currentFile] = []
    }
    // 匹配行号范围,格式:@@ -old,count +new,count @@
    if (line.startsWith('@@')) {
      const match = line.match(/\+(\d+)(?:,(\d+))?/)
      if (match) {
        const start = parseInt(match[1])
        const count = parseInt(match[2] ?? '1')
        for (let i = start; i < start + count; i++) {
          result[currentFile]?.push(i)
        }
      }
    }
  }

  return result
}

--diff-filter=ACMR 过滤掉删除的文件,只看新增和修改的。删掉的代码不需要覆盖率。

自定义 Reporter:把增量检测嵌进 Vitest

Vitest 的 Reporter 接口很灵活,可以监听测试生命周期的各个阶段。覆盖率数据在 onFinished 钩子里能拿到。

// reporters/incremental-coverage-reporter.ts
import type { Reporter } from 'vitest/reporters'
import type { Vitest } from 'vitest/node'
import { getChangedLines } from '../scripts/get-changed-lines'
import fs from 'fs'

const THRESHOLD = 80 // 增量覆盖率阈值

export default class IncrementalCoverageReporter implements Reporter {
  ctx!: Vitest

  onInit(ctx: Vitest) {
    this.ctx = ctx
  }

  async onFinished() {
    // 覆盖率 JSON 报告的路径
    const coveragePath = './coverage/coverage-final.json'
    if (!fs.existsSync(coveragePath)) {
      console.log('⚠️  没找到覆盖率数据,跳过增量检测')
      return
    }

    const coverage = JSON.parse(fs.readFileSync(coveragePath, 'utf-8'))
    const changedLines = getChangedLines()
    const failures: string[] = []

    for (const [file, lines] of Object.entries(changedLines)) {
      if (!lines.length) continue

      // 只看 .ts/.tsx/.js/.jsx,忽略配置文件之类的
      if (!/\.[jt]sx?$/.test(file)) continue

      const fileCoverage = coverage[file] || coverage[`./${file}`]
      if (!fileCoverage) {
        // 改了但完全没被任何测试 import → 覆盖率 0
        failures.push(`${file}: 改动未被任何测试覆盖 (0%)`)
        continue
      }

      // statementMap + s 对象:每条语句是否被执行
      const { statementMap, s } = fileCoverage
      let coveredCount = 0
      let totalCount = 0

      for (const [id, stmt] of Object.entries(statementMap) as any) {
        const stmtLines = range(stmt.start.line, stmt.end.line)
        // 这条语句涉及的行,是否跟改动行有交集
        const isChanged = stmtLines.some((l: number) => (lines as number[]).includes(l))
        if (isChanged) {
          totalCount++
          if (s[id] > 0) coveredCount++
        }
      }

      if (totalCount > 0) {
        const pct = Math.round((coveredCount / totalCount) * 100)
        if (pct < THRESHOLD) {
          failures.push(`${file}: 增量覆盖率 ${pct}%,低于阈值 ${THRESHOLD}%`)
        }
      }
    }

    if (failures.length) {
      console.log('\n❌ 增量覆盖率检测未通过:')
      failures.forEach(f => console.log(`   ${f}`))
      process.exitCode = 1 // 让 CI 挂掉
    } else {
      console.log('\n✅ 增量覆盖率检测通过')
    }
  }
}

function range(start: number, end: number): number[] {
  return Array.from({ length: end - start + 1 }, (_, i) => start + i)
}

注册也简单:

// vitest.config.ts
export default defineConfig({
  test: {
    coverage: {
      provider: 'v8',
      reporter: ['json'], // 必须包含 json,自定义 reporter 要读这个
    },
    reporters: [
      'default',
      './reporters/incremental-coverage-reporter.ts',
    ],
  },
})

这里有个坑说一下。coverage-final.json 的文件路径 key,有时候带 ./ 前缀,有时候不带,取决于 Vitest 版本和配置。上面代码里 coverage[file] || coverage['./' + file] 就是在处理这个。我之前被这个坑了半小时,以为是 diff 解析有问题,结果是路径没对上。

Monorepo 下只跑受影响的包

项目用 pnpm workspace,十几个包。每次 PR 全量跑测试,七八分钟。大部分时间浪费在跑那些根本没改动的包上。

思路:根据改动文件判断影响了哪些包,只跑那些包的测试。

// scripts/affected-packages.ts
import { execSync } from 'child_process'
import path from 'path'
import fs from 'fs'

export function getAffectedPackages(baseBranch = 'main'): string[] {
  const changedFiles = execSync(`git diff ${baseBranch} --name-only --diff-filter=ACMR`)
    .toString()
    .trim()
    .split('\n')
    .filter(Boolean)

  // 扫描 packages 目录下的所有包
  const packagesDir = path.resolve('packages')
  const allPackages = fs.readdirSync(packagesDir).filter(name =>
    fs.existsSync(path.join(packagesDir, name, 'package.json'))
  )

  const affected = new Set<string>()

  for (const file of changedFiles) {
    // packages/foo/src/bar.ts → foo
    const match = file.match(/^packages\/([^/]+)\//)
    if (match && allPackages.includes(match[1])) {
      affected.add(match[1])
    }
  }

  return [...affected]
}

但这只处理了「直接改动」。如果 packages/utils 改了,依赖它的 packages/ui 也应该跑测试。

得加上依赖分析:

// 在 getAffectedPackages 里追加依赖链分析
function getDependentsMap(packagesDir: string): Record<string, string[]> {
  const packages = fs.readdirSync(packagesDir).filter(name =>
    fs.existsSync(path.join(packagesDir, name, 'package.json'))
  )

  // 构建反向依赖图:被依赖方 → 依赖方列表
  const dependents: Record<string, string[]> = {}

  for (const pkg of packages) {
    const pkgJson = JSON.parse(
      fs.readFileSync(path.join(packagesDir, pkg, 'package.json'), 'utf-8')
    )
    const allDeps = {
      ...pkgJson.dependencies,
      ...pkgJson.devDependencies,
    }

    for (const dep of Object.keys(allDeps)) {
      // 只关心 workspace 内的依赖,约定 scope 是 @myorg/
      const match = dep.match(/^@myorg\/(.+)/)
      if (match && packages.includes(match[1])) {
        dependents[match[1]] ??= []
        dependents[match[1]].push(pkg)
      }
    }
  }

  return dependents
}

// 递归找到所有受影响的包
function expandAffected(
  directlyAffected: string[],
  dependentsMap: Record<string, string[]>
): string[] {
  const all = new Set(directlyAffected)
  const queue = [...directlyAffected]

  while (queue.length) {
    const pkg = queue.shift()!
    for (const dep of dependentsMap[pkg] ?? []) {
      if (!all.has(dep)) {
        all.add(dep)
        queue.push(dep) // 继续往上找
      }
    }
  }

  return [...all]
}

跑测试的脚本大概长这样:

#!/bin/bash
AFFECTED=$(node scripts/get-affected.mjs)

if [ -z "$AFFECTED" ]; then
  echo "没有受影响的包,跳过测试"
  exit 0
fi

# --filter 是 pnpm 的包过滤语法
for pkg in $AFFECTED; do
  pnpm --filter "@myorg/$pkg" run test -- --coverage
done

实际效果:CI 时间从 7 分多缩到平均 2 分钟出头。改个组件库的样式,不会触发业务逻辑包的测试。

几个值得权衡的点

增量覆盖率阈值设多少合适?

我们设的 80%。一开始想设 100%,但发现有些场景确实不好覆盖——比如某些 catch 分支、某些兼容性判断。强制 100% 会逼着人写无意义的测试,纯粹为了过 CI。80% 是个平衡点,具体数字各团队自己定,关键是要有这个卡口。

statementMap 还是 branchMap?

上面的实现用的是 statementMap,按语句维度统计。也可以用 branchMap 按分支维度统计,更严格一点。我个人倾向先用 statementMap,因为 branchMap 在 v8 provider 下偶尔有些行号对不上的问题,特别是处理 optional chaining 和 nullish coalescing 的时候。等 Vitest 后续版本稳定了再切。

受影响包的判定要不要用 Turborepo / Nx?

如果你已经在用了,直接用它们的 affected 能力就行,比自己写靠谱。Turborepo 的 turbo run test --filter=...[origin/main] 开箱即用。但如果项目没引入这些工具,为了这一个功能引入一整个构建编排系统,有点杀鸡用牛刀。上面那几十行脚本够用了。

根目录改动怎么办?

改了根目录的 tsconfig.jsonvitest.config.ts 这类文件,理论上可能影响所有包。我们的策略是:根目录文件变动 → 全量跑。简单粗暴但安全。

// 在 affected 脚本里加一个判断
const rootChanges = changedFiles.some(f => !f.startsWith('packages/'))
if (rootChanges) {
  console.log('根目录有改动,触发全量测试')
  return allPackages // 返回所有包
}

串起来:CI 流水线的完整流程

大致是这样:

# .github/workflows/test.yml
jobs:
  test:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
        with:
          fetch-depth: 0  # 必须拉全量历史,不然 git diff 跑不了

      - uses: pnpm/action-setup@v2
      - uses: actions/setup-node@v4

      - run: pnpm install

      # 1. 算出受影响的包
      - id: affected
        run: echo "packages=$(node scripts/get-affected.mjs)" >> $GITHUB_OUTPUT

      # 2. 对每个受影响的包跑测试 + 覆盖率
      - run: |
          for pkg in ${{ steps.affected.outputs.packages }}; do
            pnpm --filter "@myorg/$pkg" run test -- --coverage
          done

      # 3. 增量覆盖率在每个包的 reporter 里自动检测
      #    失败会 process.exitCode = 1,CI 自然挂掉

fetch-depth: 0 这个别忘了。GitHub Actions 默认 shallow clone,只拉最后一个 commit,git diff main 会报错说找不到 main。之前踩过这个坑,排查了好一会儿才想起来。

聊到这

增量覆盖率不是什么新概念,Java 那边的 JaCoCo 很早就有类似能力。但在前端工具链里,这块一直比较糙,大部分团队还停在全局覆盖率的阶段。

Vitest 的 Reporter 接口给了足够的扩展空间,自己写一个增量检测的 reporter 也就百来行代码。配合 Monorepo 的按需测试,CI 跑得快、卡得准。