用自动化检查减轻代码评审负担
使用 OpenAPI、dependency-cruiser 和 Stryker 变异测试自动化代码审查。配置 CI 检查,捕获机械性缺陷并减轻审查负担。
确定性形式的自动化代码评审,是指一组 CI 检查:只要代码违反某条规则,拉取请求(pull request)就会检查失败。这样,评审者就能把时间花在设计、意图和产品契合度上,而不是揪机械性错误。
如今,编码智能体(coding agent)提交拉取请求的速度,已经快到没人能逐行阅读。如果你是技术负责人,最终要么对 diff 草草盖章放行,要么自己成为瓶颈,而这两种状态都难以为继。
解决办法是让机器去捕获机械性问题,把人的注意力留给其他所有事情。无论代码出自谁手,这些检查都值得运行。本文介绍其中三项检查,外加两项低成本的补充检查,演示如何将它们全部接入 CI,最后说明哪些问题是这些检查都无法发现的。你应该已经在运行 lint、格式化和单元测试了;如果还没有,可以先从这篇 ESLint 与 Prettier 配置指南入手。
| 检查 | 能捕获的问题 | 评审者不再需要检查的内容 | 工具 |
|---|---|---|---|
| 契约代码生成 | API 从不返回的字段 | 每个响应字段是否真实存在 | Hey API openapi-ts |
| 分层边界 | 跨越架构分层的导入 | 每个导入的方向是否正确 | dependency-cruiser |
| 变异测试 | 执行了代码却不做断言的测试 | 每个新测试是否真的验证了行为 | Stryker |
| 死代码 | 未使用的文件、导出和依赖 | 是否有内容变成了孤儿代码 | Knip |
| 重复代码 | 复制粘贴的代码块 | 这段逻辑是否已经存在 | jscpd |
核心要点
- 如果 TypeScript 客户端是根据 OpenAPI 规范生成的,那么读取 API 从不返回的字段(例如
user.fullName)会直接导致编译错误,而不必等到评审时才被指出。 - 当 service 导入 component 时,严重级别为
error的 dependency-cruiserforbidden规则会让 CI 失败。使用err-longreporter 运行时,规则的comment会显示在每条违规信息旁边,告诉开发者这个导入应该放在哪里。 - 行覆盖率只能说明代码被执行过;变异测试则能说明当这段代码被故意破坏时,是否有测试会失败。
- 把变异得分当作探测器使用,不要设置 Stryker 的
thresholds.break,因为等价变异体会让硬性阈值产生误导。 - 新规则先以警告形式在变更文件上推行。把能捕获真实 bug 的规则升级为错误,并且只在某个 job 稳定之后才将其设为必需检查。
根据 OpenAPI 规范生成 API 客户端
如果 TypeScript 客户端是根据 OpenAPI 规范生成的,那么读取 API 从不返回的字段(例如 user.fullName)会直接导致编译错误,而不必等到评审时才被指出。手写的接口可能在无人察觉的情况下与后端渐行渐远,而生成的接口不会。
手写的类型即使是错的,也能通过编译:
// Hand-written: compiles, but the backend never sends fullName
interface User { id: string; fullName: string }
declare const user: User;
const label = user.fullName;
改为根据规范生成类型,同样的读取操作就会在编译期报错:
// Generated from the spec
type User = { id: string; firstName: string; lastName: string };
declare const user: User;
const label = user.fullName;
// error TS2339: Property 'fullName' does not exist on type 'User'.
const fixed = `${user.firstName} ${user.lastName}`;
Hey API openapi-ts 等工具会读取 OpenAPI 文档,并生成类型和客户端。客户端生成之后,评审的对象就变成了规范本身。把生成的代码当作构建产物对待:不要手动修改,也不要逐行评审。
类型在编译后会被擦除,因此对于不能轻信的响应,还需要加上 Zod 这类运行时校验器。所有这些的前提是,有一份由后端持续维护的权威规范。完整的端到端配置,请参阅使用 OpenAPI 和 TypeScript 构建类型安全的 API 客户端。
用 dependency-cruiser 强制分层边界
dependency-cruiser 会根据配置文件中的 forbidden 规则,检查 JavaScript 或 TypeScript 项目中的每一个导入。违规信息的输出风格与 ESLint 十分相似;只要违反了任何一条设为 error 的规则,命令就会以非零退出码结束,从而让 CI job 失败。这样,“这个 service 真的应该导入 component 吗?“就不再是评审者需要操心的问题了。
npm install --save-dev dependency-cruiser
npx dependency-cruiser --init
--init 会生成 .dependency-cruiser.js;如果 package.json 中设置了 "type": "module",则生成 .dependency-cruiser.cjs。生成的文件自带一组默认规则,它们会标记循环导入、孤立模块、导入了却未在 package.json 中声明的包,以及引入了 dev 依赖的生产代码。在这些规则旁边加上你自己的分层规则:
module.exports = {
forbidden: [
{
name: "services-not-to-components",
comment:
"Services must not import UI. Move shared types to src/types or next to the service.",
severity: "error",
from: { path: "^src/services" },
to: { path: "^src/components" },
},
],
};
把 comment 写成操作指引,而不是一个标签。默认的 err reporter 不会输出它,但 err-long reporter 会在每条违规信息中打印它。这样,触发失败的开发者或智能体看到的就是修复方法,而不仅仅是一条错误:
npx dependency-cruiser --output-type err-long src
如果你更希望把这项检查放在 linter 中,eslint-plugin-boundaries 可以在 ESLint 内完成同样的工作。oxlint 没有针对架构分层的内置规则,但它的 no-restricted-imports 规则可以拦截匹配特定模式的导入。
用变异测试检验测试能否发现破坏
变异测试检验的是测试质量:行覆盖率说明代码被执行过,而变异测试说明当这段代码被故意修改时,是否有测试会失败。一套测试可以达到 100% 的覆盖率,却几乎什么都没断言,变异测试正是发现这一点的手段。
Stryker 会对源代码做一些小改动,每个改动后的版本称为一个变异体(mutant),然后针对每个变异体运行你的测试。只要有任何测试针对某个变异体失败,该变异体就被杀死(killed);如果整套测试仍然全部通过,该变异体就存活(survives)了。
Stryker 官方的示例将 return user.age >= 18 变异为四种形式:> 18、< 18、return false 和 return true。
export function isUserOldEnough(user: { age: number }): boolean {
return user.age >= 18;
}
// Weak: 100% line coverage, two survivors
it("lets adults in", () => {
expect(isUserOldEnough({ age: 25 })).toBe(true);
});
// Added: boundary cases
it("lets in someone who is exactly 18", () => {
expect(isUserOldEnough({ age: 18 })).toBe(true);
});
it("keeps out someone who is 17", () => {
expect(isUserOldEnough({ age: 17 })).toBe(false);
});
一个只检查 25 岁用户的测试,在 > 18 变异体和 return true 变异体上都能通过,因此尽管行覆盖率达到 100%,这两个变异体仍会存活。这个测试只能杀死 < 18 和 return false。18 岁的用例能杀死 > 18,17 岁的用例能杀死 return true。三个测试齐备后,所有变异体都会被杀死。
存活的变异体通常源自几种可识别的弱测试模式:
- 只检查函数是否被调用,而不检查调用时传入了什么参数。
- 只检查结果是否存在或其长度,而不查看具体内容。
- 提交了一份没有人对照预期输出核对过的快照。
- 用与被测代码相同的逻辑来计算预期值。
- 检查 mock 返回了什么,而不是代码如何使用这个返回值。
存活的变异体指向的是没有任何测试锁定的行为。不过,有些变异体与原始代码是等价的,因此变异得分更适合作为探测器,而不是通过/失败的阈值。硬性阈值还会鼓励人们为了杀死变异体而写测试,而不是为了验证行为。Stryker 的 thresholds.break 会在得分低于设定值时让构建失败。它的默认值是 null,你应该保持不变,转而在 PR 中阅读存活的变异体。
为了缩短运行时间,可以通过 npx stryker run --mutate <comma-separated files> 只对 PR 修改过的文件进行变异。增量模式(--incremental)会将上次运行的结果保存在 reports/stryker-incremental.json 中,并尽可能复用;但在 CI 中,只有在多次运行之间缓存了该文件,才能真正节省时间。还要为你的测试框架安装对应的 test-runner 插件(@stryker-mutator/vitest-runner 或 @stryker-mutator/jest-runner)。如果没有安装,Stryker 会退回到 command runner,对每个变异体都运行整套测试。关于测试质量中需要判断力的部分,也就是决定哪些东西值得测试,请参阅我们的文章 AI 时代的测试。
将每项检查作为独立的必需 CI job 运行
可以跳过的检查就等于没有强制执行。为每项检查单独设置一个 job,让失败一目了然,然后在分支保护或规则集中将每个 job 标记为必需的状态检查(required status check)。如果你刚接触 GitHub Actions,可以先阅读这篇 GitHub Actions 实用指南。
on: pull_request
jobs:
lint:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v7
- uses: actions/setup-node@v7
with: { node-version: 22, cache: npm }
- run: npm ci
- run: npx eslint . && npx prettier --check .
- run: npx dependency-cruiser --output-type err-long src
typecheck:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v7
- uses: actions/setup-node@v7
with: { node-version: 22, cache: npm }
- run: npm ci
- run: npx tsc --noEmit
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v7
- uses: actions/setup-node@v7
with: { node-version: 22, cache: npm }
- run: npm ci
- run: npm test
mutation:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v7
with: { fetch-depth: 0 }
- uses: actions/setup-node@v7
with: { node-version: 22, cache: npm }
- run: npm ci
- name: Mutate changed files only
run: |
FILES=$(git diff --name-only --diff-filter=AMR "origin/${{ github.base_ref }}...HEAD" -- ':(glob)src/**/*.ts' ':(glob)src/**/*.tsx' \
| grep -vE '\.(test|spec)\.tsx?$' | paste -sd, -)
if [ -z "$FILES" ]; then echo "No source changes"; exit 0; fi
npx stryker run --mutate "$FILES"
build:
needs: [lint, typecheck, test, mutation]
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v7
- uses: actions/setup-node@v7
with: { node-version: 22, cache: npm }
- run: npm ci
- run: npm run build
mutation job 会拉取完整的提交历史(fetch-depth: 0),以便与基准分支进行 diff。然后它只把变更过的源文件传给 Stryker,并排除测试文件。
新规则应逐步推行:
- 每条新规则先以警告形式添加。在 dependency-cruiser 中,
severity: "warn"会报告违规但不会让 job 失败,只有error才会。 - 只对变更过的文件运行检查。
- 当某条警告捕获到真实 bug 时,将其升级为错误;只会制造噪音的规则则直接删除。
- 只有在某个 job 稳定之后,才将其设为必需检查。
还有两项低成本的自动化检查值得加上:用 Knip 检查死代码,用 jscpd 检查重复代码。Knip 会报告没有被任何地方导入的文件、没人使用的导出,以及不再需要的包。jscpd 会标记重复的代码块。
自动化代码评审检查无法告诉你什么?
确定性检查无法告诉你这个功能是否是正确的选择、某个抽象是否引入得过早,或者团队里是否有人真正理解这段代码。这些都需要判断,必须由人来完成。
一个通过了所有确定性检查的拉取请求,只能证明它不存在这些检查所针对的缺陷。关于评审中需要人来把关的部分,请参阅我们的文章 AI 时代的测试。
从契约开始
你交给机器的每一个机械性问题,都能为人工评审腾出时间,去处理只有人才能回答的问题。如果这个迭代只打算加一项检查,那就根据规范生成 API 客户端,因为它能在任何人写出代码之前就杜绝一整类缺陷。接下来,在 PR diff 上加入变异测试,作为只产生警告的 job,并认真阅读存活下来的变异体。
常见问题
如何在已有数百条违规的现有代码库中添加 dependency-cruiser 规则?
将现有违规记录为基线(baseline),只在出现新违规时失败。从 dependency-cruiser 18.3.0 起,推荐的做法是使用 --baseline 选项。默认情况下,它会在项目根目录写入 .dependency-cruiser-known-violations.json,使用 --ignore-known 运行时,其中列出的所有违规都会被降级为 ignore。任何违反同一规则的新导入仍会导致失败。由于匹配时不考虑严重级别,修改某条规则的严重级别并不会影响其已有的基线条目。
Stryker 变异测试支持 Vitest 吗?
支持。StrykerJS 从 7.0 版本开始通过 @stryker-mutator/vitest-runner 插件支持 Vitest,Jest 项目则使用 @stryker-mutator/jest-runner。Vitest runner 始终使用 perTest 覆盖率分析,并忽略你配置的任何 coverageAnalysis 设置。它不支持 Vitest Browser Mode。Stryker 在每个 worker 中以单线程运行测试,转而在自己的多个 worker 之间实现并行。
变异测试中的等价变异体是什么?
等价变异体修改了源代码,却没有改变程序的行为,因此没有任何测试能够杀死它。一个经典的例子是:在从零开始每次加一的循环中,把 i < length 替换为 i != length,两者都会在同一位置停止。由于等价变异体总会存活,100% 的变异得分往往无法达到,存活的变异体需要由人来阅读判断。
当 Stryker 在没有测试的变更文件上运行时会发生什么?
默认情况下,如果初始 dry run 没有执行任何测试,Stryker 会以 No tests found 错误终止,这会导致只对 PR diff 进行变异的 CI job 失败。从 StrykerJS 7.0 起,使用 --allowEmpty 标志(或在配置文件中设置 allowEmpty: true)可以避免将其视为错误。没有被任何测试执行到的代码中的变异体会被报告为无覆盖(no coverage),从而直接指出缺失的测试。