Vitest 自定义 Reporter 与覆盖率卡口:在 Monorepo 里搞增量覆盖率检测
上周 CR 的时候,有个同事提了一行改动,改了个工具函数的边界判断。测试没加。CI 绿了。合了。
然后线上炸了。
回头看,项目覆盖率 82%,达标。但那个被改的函数,覆盖率是 0。全局覆盖率这个指标,在这种场景下约等于摆设------你改了 10 行代码,只要剩下几万行覆盖率够高,这 10 行裸奔也能过。
所以问题很明确:怎么只卡「本次改动」的覆盖率?
再加上项目是 Monorepo,十几个包,每次 CI 全量跑测试要七八分钟。能不能只跑受影响的包?
这篇就聊这两件事:增量覆盖率检测,和 Monorepo 下的测试编排。都基于 Vitest。
先搞清楚 Vitest 覆盖率是怎么收集的
Vitest 支持两个覆盖率 provider:istanbul 和 v8。
istanbul 是老方案,靠代码插桩------在你源码里塞计数器,跑一遍之后统计哪些行被执行了。好处是准,坏处是慢,而且插桩后的代码跟源码对不上,调试体验差。
v8 走的是 V8 引擎内置的覆盖率收集能力,不需要插桩。快,而且对 sourcemap 支持更好。Vitest 默认推荐 v8。
配置很简单:
ts
// 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 这个配置,卡的是全局覆盖率。整个项目达标就过,不管你这次改了啥。聊胜于无。
增量覆盖率的思路
核心逻辑其实就三步:
- 拿到本次改动的文件列表和行号(
git diff) - 拿到覆盖率报告里每个文件的行覆盖数据
- 交叉比对:改动的行里,有多少被测试覆盖了?
听着不复杂。但细节全在实现里。
先解决第一步,拿 diff:
ts
// 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 钩子里能拿到。
ts
// 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)
}
注册也简单:
ts
// 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 全量跑测试,七八分钟。大部分时间浪费在跑那些根本没改动的包上。
思路:根据改动文件判断影响了哪些包,只跑那些包的测试。
ts
// 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 也应该跑测试。
得加上依赖分析:
ts
// 在 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]
}
跑测试的脚本大概长这样:
bash
#!/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.json、vitest.config.ts 这类文件,理论上可能影响所有包。我们的策略是:根目录文件变动 → 全量跑。简单粗暴但安全。
ts
// 在 affected 脚本里加一个判断
const rootChanges = changedFiles.some(f => !f.startsWith('packages/'))
if (rootChanges) {
console.log('根目录有改动,触发全量测试')
return allPackages // 返回所有包
}
串起来:CI 流水线的完整流程
大致是这样:
yaml
# .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 跑得快、卡得准。