本节目标:读完这一节,你能解释语句、分支、函数、行四种覆盖率口径的差别,配出「只升不降」的覆盖率阈值;能用扁平配置写出带类型感知规则的 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。古德哈特定律在这里体现得淋漓尽致:当一个度量变成目标,它就不再是好度量。
正确的用法是三条:
- 用覆盖率找盲区(哪些文件、哪些分支没人碰),而不是评判人;
- 只对核心模块(金额、权限、状态机)设高阈值,工具类可放宽;
- 阈值只升不降(棘轮策略,后文详述)。
如果想让「测试是否真的有效」也被度量,可以了解变异测试(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):只升不降。
- 先测出当前真实覆盖率,把阈值设在略低于它的位置(比如 41% 就设 40%);
- 每次有人提升了覆盖率,顺手把阈值调高一点点;
- 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 编译目标与严格模式配置 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。