12k
All articles

用自动化检查减轻代码评审负担

使用 OpenAPI、dependency-cruiser 和 Stryker 变异测试自动化代码审查。配置 CI 检查,捕获机械性缺陷并减轻审查负担。

OpenReplay Team
OpenReplay Team
用自动化检查减轻代码评审负担

确定性形式的自动化代码评审,是指一组 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-cruiser forbidden 规则会让 CI 失败。使用 err-long reporter 运行时,规则的 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,并排除测试文件。

新规则应逐步推行:

  1. 每条新规则先以警告形式添加。在 dependency-cruiser 中,severity: "warn" 会报告违规但不会让 job 失败,只有 error 才会。
  2. 只对变更过的文件运行检查。
  3. 当某条警告捕获到真实 bug 时,将其升级为错误;只会制造噪音的规则则直接删除。
  4. 只有在某个 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),从而直接指出缺失的测试。

Understand every bug

Uncover frustrations, understand bugs and fix slowdowns like never before with OpenReplay — self-hosted, with full data ownership.

Star on GitHub

We use cookies to improve your experience. By using our site, you accept cookies.