《TypeScript编程入门》15.3 覆盖率、lint 与 CI 门禁

本节是第 15 章收官,把零散的测试变成系统性防线:先厘清语句、分支、函数、行四种覆盖率口径与阈值配置的陷阱,再给出 ESLint 搭配 typescript-eslint 类型感知规则的扁平配置,说明 Prettier 与提交前钩子的职责分工,最后用一份 GitHub Actions 流水线演示类型检查、lint、测试、覆盖率如何逐层卡门,并给出渐进收紧的棘轮策略与常见坑。

本节目标:读完这一节,你能解释语句、分支、函数、行四种覆盖率口径的差别,配出「只升不降」的覆盖率阈值;能用扁平配置写出带类型感知规则的 ESLint;能说清格式化工具与提交前钩子各自该管什么;并能独立写出一份 GitHub Actions 流水线,让类型检查、lint、测试、覆盖率四道门依次卡住不合格的提交。

15.3 覆盖率、lint 与 CI 门禁

前两节我们分别搞定了「测行为」和「测类型」。但零散的测试文件不构成质量保障——真正的保障来自一套自动运行、无法绕过的门禁:本地写完代码,push 上去,机器替你检查,不合格就红。

这一节是第 15 章的收官,我们把散落的工具串成一条流水线。

三层质量防线

在动手配置之前,先建立一条分工原则:能交给编译器的,不要交给 lint;能交给 lint 的,不要交给测试。

防线工具能抓住的问题运行成本
类型检查tsc --noEmit类型错误、签名不符、漏处理分支中
静态分析ESLint(typescript-eslint)危险写法、未处理 Promise、无用代码中高(类型感知)
单元测试Vitest行为错误、边界条件、回归高
覆盖率Vitest + v8/istanbul测试盲区随测试增长

顺序也是由快到慢的:类型检查最快,应该最先卡。一个被 tsc 就能挡下的错误,不该浪费 CI 时间去跑测试才发现。

覆盖率:四种口径与阈值

覆盖率不是单一数字,而是四个口径:

口径含义典型问题
Statements语句被执行的比例最常用,但容易虚高
Branches每个 if/三元/?? 的两个方向最能反映逻辑漏洞
Functions函数被调用的比例抓「写了没测」的函数
Lines被执行的行比例与 statements 接近

其中分支覆盖率最有价值。看一个例子:

function discount(price: number, vip: boolean): number {
  return vip ? price * 0.8 : price;   // 两个分支
}
it("VIP 打折", () => {
  expect(discount(100, true)).toBe(80);
});

这个测试的语句覆盖率是 100%(那一行确实执行了),但分支覆盖率只有 50%——vip === false 那条路从没走过。只看语句覆盖率,你会以为这个函数测透了。

配置阈值(Vitest):

// vitest.config.ts
import { defineConfig } from "vitest/config";

export default defineConfig({
  test: {
    coverage: {
      provider: "v8",              // 或 "istanbul"
      reporter: ["text", "lcov"],
      include: ["src/**/*.ts"],
      exclude: ["src/**/*.test.ts", "src/**/*.d.ts"],
      thresholds: {
        statements: 80,
        branches: 70,
        functions: 80,
        lines: 80,
      },
    },
  },
});

低于阈值时,Vitest 会以非零退出码结束,CI 随之变红:

ERROR: Coverage for branches (68.42%) does not meet global threshold (70%)

关于 v8 与 istanbul 的取舍:v8 复用 V8 引擎自带的行覆盖信息,几乎零开销,但分支口径不如 istanbul 精确(某些三元、逻辑短路会算不准)。如果团队对分支覆盖率有硬要求,用 istanbul 更稳,代价是插桩带来的额外耗时。

延伸阅读:既有专题里有一篇专门讲覆盖率与质量门禁的文章 /test-coverage-quality-gates/ ,可与本节对照。

覆盖率不是目标

这是本节最想说的一句话。覆盖率是下限工具,不是上限指标。一旦把它当成 KPI,团队会写出这种测试:

it("调用一下", () => {
  discount(100, true); // 覆盖率 +1,什么都没断言
});

它把覆盖率刷上去了,却没抓住任何 bug。古德哈特定律在这里体现得淋漓尽致:当一个度量变成目标,它就不再是好度量。

正确的用法是三条:

  1. 用覆盖率找盲区(哪些文件、哪些分支没人碰),而不是评判人;
  2. 只对核心模块(金额、权限、状态机)设高阈值,工具类可放宽;
  3. 阈值只升不降(棘轮策略,后文详述)。

如果想让「测试是否真的有效」也被度量,可以了解变异测试(mutation testing)——它故意改坏源码,看测试会不会失败,是比覆盖率更严格的指标。既有专题有相关文章 /mutation-testing/ 。

ESLint + typescript-eslint

ESLint 是 JS 生态的静态分析标准,typescript-eslint 是让 ESLint 理解 TypeScript 的官方插件集合。当前推荐用扁平配置(eslint.config.js):

npm i -D eslint typescript-eslint
// eslint.config.js
import tseslint from "typescript-eslint";

export default tseslint.config(
  ...tseslint.configs.recommended,
  {
    files: ["src/**/*.ts"],
    languageOptions: {
      parserOptions: {
        projectService: true,   // 让规则能访问类型信息
        tsconfigRootDir: import.meta.dirname,
      },
    },
    rules: {
      "@typescript-eslint/no-floating-promises": "error",
      "@typescript-eslint/no-misused-promises": "error",
      "@typescript-eslint/await-thenable": "error",
      "@typescript-eslint/no-unnecessary-condition": "warn",
    },
  },
);

类型感知规则(type-aware rules)是 typescript-eslint 的核心价值。它们能访问类型信息,抓出纯语法规则看不到的问题。最典型的就是 no-floating-promises:

// ❌ 没有 await 也没有 catch,Promise 被丢弃
async function save() {
  db.write(data);   // error: Promises must be awaited or returned
}

// ✅
async function save() {
  await db.write(data);
}

这条规则几乎能消灭一整类线上事故(异步写入静默失败)。no-misused-promises 则专抓「把 async 函数当同步回调传给 forEach / if 条件」这类错误。

代价要提前知道:类型感知规则需要 ESLint 加载整个 TS 程序,速度会明显变慢。在 monorepo 里可以只在 CI 跑全量类型感知规则,本地开发只跑快速规则。相关取舍见 16.3 Monorepo 与 Project References 。

格式化与提交前钩子

格式化(formatting)与静态分析(linting)是两件事,不要混在一套规则里:

职责工具例子
格式化(可自动修)Prettier / Biome缩进、引号、换行
静态分析(需人判断)ESLint未处理 Promise、无用变量

如果两套都装了,务必加上 eslint-config-prettier,关掉所有与 Prettier 冲突的格式类规则,否则会出现「ESLint 让你加分号、Prettier 给你删掉」的循环。近两年也有团队直接用 Biome 一个工具同时承担格式化和 lint,配置更少。

提交前钩子用 husky + lint-staged,只对暂存文件跑:

{
  "scripts": {
    "prepare": "husky",
    "lint:staged": "lint-staged"
  },
  "lint-staged": {
    "*.{ts,tsx}": ["eslint --fix", "prettier --write"]
  }
}
npx husky init
echo 'npx lint-staged' > .husky/pre-commit

不要把 tsc --noEmit 放进 pre-commit。它要检查整个项目,动辄十几秒,钩子一慢,开发者就会用 --no-verify 绕过,防线形同虚设。类型检查属于 CI 的活。

GitHub Actions 流水线

把四道门写进一条工作流。关键是步骤顺序由快到慢,让失败尽早暴露:

name: ci

on:
  push:
    branches: [main]
  pull_request:

concurrency:
  group: ci-${{ github.ref }}
  cancel-in-progress: true

jobs:
  quality:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with:
          node-version: 22
          cache: npm
      - run: npm ci
      - run: npx tsc --noEmit
      - run: npx eslint .
      - run: npx vitest run --coverage

几点说明:

  • npm ci 而不是 npm install。前者严格按 package-lock.json 安装,保证 CI 与本地、CI 各次运行之间完全一致;后者可能悄悄升级依赖,制造「本地过了 CI 不过」。
  • cancel-in-progress: true。同一分支连续 push 时,自动取消上一次未跑完的任务,省机器也省等待。
  • cache: npm。缓存 ~/.npm,安装步骤能从几十秒降到几秒。
  • tsc --noEmit 放在 lint 之前。类型错误最便宜,先卡。

要跑多个 Node 版本时用矩阵:

    strategy:
      matrix:
        node-version: [20, 22]

然后在 setup-node 里用 ${{ matrix.node-version }}。矩阵适合库作者(要保证下游兼容),应用项目通常固定一个 LTS 版本即可。

最后在仓库设置里把 quality 设为必需检查(required status check),并开启分支保护,这样不合格的 PR 无法合并——门禁才算真正闭环。

延伸阅读:既有专题里有一篇 GitHub Actions 与测试覆盖率集成实战 /github-actions-test-coverage-integration/ ,以及一篇讲「质量左移」的静态分析文章 /testing-static-analysis-quality-left/ 。

渐进式收紧:棘轮策略

最忌讳的是一上来就把阈值拉满:一个没有测试的存量项目,直接设 branches: 80 只会让 CI 从第一天起就永久红,然后所有人学会无视它。

正确做法是棘轮(ratchet):只升不降。

  1. 先测出当前真实覆盖率,把阈值设在略低于它的位置(比如 41% 就设 40%);
  2. 每次有人提升了覆盖率,顺手把阈值调高一点点;
  3. lint 规则同理:新规则先设 "warn",全团队清理完再改成 "error"。
const configExcerpt = {
thresholds: {
  // 棘轮:这个数字只能往上改,永远不许往下
  branches: 40,
  statements: 55,
}
};

这条策略的心理学基础是:门禁必须永远是绿的,红只能意味着「你这次写坏了」。一个常年红的门禁等于没有门禁。

常见坑与报错

坑一:本地过、CI 不过。 九成是依赖或 Node 版本不一致。解法:CI 用 npm ci,本地锁定同一 Node 版本(.nvmrc + engines 字段)。

坑二:You have used a rule which requires type information。 用了类型感知规则,却没配 parserOptions.projectService(或旧版的 project)。这条报错的意思是「规则拿不到类型,没法工作」。

坑三:覆盖率数字在本地和 CI 之间跳动。 换了 provider(v8 ↔ istanbul)或 Node 版本都会导致口径变化。provider 一旦选定就不要随意换,否则历史趋势线会断。

坑四:偶发失败(flaky test)。 依赖真实时间、网络、随机数或测试间共享状态的用例,在 CI 上会随机红。既有专题有一篇专门讲并行与 flaky 测试的文章 /parallel-flaky-tests/ ,可作参考。

坑五:把 node_modules 或构建产物纳入了 lint/覆盖率统计。 记得在 ignore 与 coverage.exclude 里排除 dist、build、*.d.ts。

坑六:ESLint 与 Prettier 规则打架。 加上 eslint-config-prettier,或干脆改用 Biome。

一个真实工程的 CI 门禁清单

门禁命令卡住什么是否必需
类型检查tsc --noEmit任何类型错误是
静态分析eslint .危险写法、未处理 Promise是
格式检查prettier --check .格式不统一是(可自动修)
单元测试vitest run行为回归是
覆盖率阈值vitest run --coverage新增代码无测试核心模块必需
类型测试vitest --typecheck 或 tsd类型精度退化库项目必需
构建tsup / vite build打包失败是

这张表可以当作你下一个项目的起点。严格程度随项目阶段调整:个人项目留类型检查加测试即可,团队协作的核心服务则值得把七行都点亮。

本书附录里还有一份工具与资源的汇总,可作选型参考:附录 C 常用工具、库与资源 。

小结

  • 三层防线各有分工:类型检查抓类型,ESLint 抓危险写法,测试抓行为;顺序由快到慢,能交给编译器的别交给测试。
  • 覆盖率有语句、分支、函数、行四种口径,分支覆盖率最有价值;只测 vip === true 一条路,语句 100% 而分支只有 50%。
  • 覆盖率是下限工具不是 KPI,用它找盲区,并对核心模块设阈值;要更严格的指标可以看变异测试。
  • ESLint 的类型感知规则(no-floating-promises 等)能消灭一整类静默失败,代价是变慢;格式交给 Prettier 或 Biome,别让两套规则打架。
  • tsc --noEmit 不要放进 pre-commit,钩子只对暂存文件跑 lint-staged。
  • CI 流水线用 npm ci + 缓存 + cancel-in-progress,并把 job 设为必需检查;阈值用棘轮策略只升不降,保证门禁永远有信号。

第 15 章到此结束:你现在有了「测行为、测类型、卡门禁」的完整闭环。下一章我们把视角转向工程化的另一半——编译目标怎么定、打包器怎么选、monorepo 怎么组织,从 16.1 编译目标与严格模式配置 开始。

阅读导航:上一节:15.2 类型测试(tsd/expect-type) · 下一节:16.1 编译目标与严格模式配置 。

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「typescript」更多文章

  1. 《TypeScript高级编程》11.3 类型驱动架构与团队规范
  2. 《TypeScript高级编程》11.2 渐进式迁移与严格化路径
  3. 《TypeScript高级编程》11.1 TS 版本演进与 breaking changes