本节目标:把测试体系封口。读完后,你会知道为什么运行时测试测不到类型回归,能用
expectTypeOf与--typecheck写出会被编译期校验的测试,能区分四个覆盖率指标并配出一套不会被绕过、也不会逼人写废话的门禁阈值,还能说出覆盖率的三个盲区以及变异测试如何补位。
4.3 类型测试与覆盖率门禁
前两节我们建了两层防线:单元测试验证业务分支,集成测试验证真实依赖。但还有一个维度完全没被覆盖——类型本身。
看一个具体的例子。假设 OrderService.checkout 的返回类型从 Promise<string> 改成 Promise<string | null>:
class OrderService {
async checkout(id: string, total: number): Promise<string | null> {
const result = await this.gateway.charge(id, total);
if (!result.ok) return null; // 从抛错改成返回 null
await this.repo.save({ id, total, paid: true });
return result.txId;
}
}
如果测试写的是 await expect(service.checkout("o-1", 199)).resolves.toBe("tx-1"),它会照常通过——因为成功路径的返回值没变。只有调用方在编译时才会发现 txId 可能是 null。也就是说,一次「类型层面的破坏性变更」可以完全逃过运行时测试。上一节的集成测试再真实,也测不到这一层。
4.3.1 类型测试要断言什么
类型测试(type test)的思路和单元测试一致,只是把断言对象从「值」换成「类型」:
| 断言对象 | 单元测试 | 类型测试 |
|---|---|---|
| 具体值 | expect(x).toBe(3) | —— |
| 类型 | —— | expectTypeOf(x).toEqualTypeOf<number>() |
| 函数签名 | 调用后看行为 | expectTypeOf(fn).parameter(0).toBeString() |
| 「这里应当报错」 | expect(() => f()).toThrow() | // @ts-expect-error |
它解决的问题可以归纳成三类:公开 API 的类型契约不能被悄悄改坏、泛型工具的类型推导结果符合预期、非法用法必须被拒绝。这三类都是纯类型问题,运行时测试天然无能为力。
4.3.2 vitest 的 typecheck 模式
Vitest 内置了 expectTypeOf,但它默认不参与类型检查——如果只是 import 进来写几行,测试会「全绿但什么都没测」。必须显式开启 typecheck 模式:
// vitest.config.ts
import { defineConfig } from "vitest/config";
export default defineConfig({
test: {
include: ["src/**/*.test.ts"],
typecheck: {
enabled: true,
include: ["src/**/*.test-d.ts"], // 类型测试单独用 .test-d.ts 后缀
tsconfig: "./tsconfig.test.json",
},
},
});
{
"scripts": {
"test": "vitest run",
"test:types": "vitest run --typecheck.only"
}
}
--typecheck.only 表示只跑类型测试、跳过运行时用例,CI 里可以作为一个独立步骤,失败时输出的是编译错误而不是断言失败:
$ pnpm test:types
❯ src/money.test-d.ts (1 test | 1 failed) 812ms
× convert 的返回类型是 Money
→ expected type to be Money but got { amount: number; currency: string }
Test Files 1 failed (1)
Tests 1 failed (1)
注意报错信息里的 currency: string——这通常意味着某个地方用了 as string 把字面量联合拓宽了。类型测试的价值就在于把这种「偷偷拓宽」变成红灯。
4.3.3 第一个类型测试
expectTypeOf 的写法接近自然语言,读起来就是一句断言:
// src/money.test-d.ts
import { expectTypeOf } from "vitest";
import { convert } from "./money";
import type { Currency, Money } from "./money";
expectTypeOf(convert).parameter(0).toBeNumber();
expectTypeOf(convert).parameter(2).toEqualTypeOf<Currency>();
expectTypeOf(convert).returns.toEqualTypeOf<Money>();
// 断言「两个类型完全相同」,比 toMatchTypeOf 更严格
expectTypeOf<Money["currency"]>().toEqualTypeOf<Currency>();
// 断言「A 可以赋值给 B」,用于检查兼容性而非相等性
expectTypeOf<{ id: string; total: number }>().toMatchTypeOf<{ id: string }>();
toEqualTypeOf 与 toMatchTypeOf 的区别值得记牢:前者要求两个类型完全一致(多一个可选属性都会失败),后者只要求结构兼容(可以多出字段)。检查公开 API 的签名用前者,检查「能不能传进去」用后者。
4.3.4 三个典型场景
其一,工具类型与泛型推导。 这类代码纯类型运算,没有运行时表现,只能靠类型测试:
import { expectTypeOf } from "vitest";
type DeepPartial<T> = {
[K in keyof T]?: T[K] extends object ? DeepPartial<T[K]> : T[K];
};
interface Order {
id: string;
items: { sku: string; qty: number }[];
}
expectTypeOf<DeepPartial<Order>>().toEqualTypeOf<{
id?: string;
items?: { sku?: string; qty?: number }[];
}>();
其二,API 客户端的端到端类型契约。 这类测试守住的是「后端契约变更时前端会红」。如果项目用了 tRPC(见 16.1 tRPC 端到端类型安全 ),可以这样写:
import type { AppRouter } from "../server/router";
import type { inferRouterOutputs } from "@trpc/server";
type Outputs = inferRouterOutputs<AppRouter>;
expectTypeOf<Outputs["order"]["get"]>().toEqualTypeOf<{
id: string;
total: number;
paid: boolean;
} | null>();
其三,判别联合的收窄行为。 确保 switch 穷尽后类型真的被收窄,而不是残留 undefined:
type Event =
| { kind: "created"; id: string }
| { kind: "paid"; id: string; amount: number }
| { kind: "cancelled"; id: string; reason: string };
function describeEvent(event: Event): string {
switch (event.kind) {
case "created":
return `创建 ${event.id}`;
case "paid":
return `支付 ${event.amount}`;
case "cancelled":
return `取消 ${event.reason}`;
}
}
expectTypeOf(describeEvent).returns.toBeString();
这里真正的保障来自 noImplicitReturns 与穷尽检查:一旦给 Event 加了第四种 kind,describeEvent 会因为缺少 return 而编译失败。这正是 1.2 严格模式与 tsconfig 分层
里反复强调严格模式的收益。
4.3.5 @ts-expect-error:断言「这里必须报错」
前面三类都是断言「类型是什么」,还有一类是断言「非法用法必须被拒绝」。这时用 @ts-expect-error:
import { expectTypeOf } from "vitest";
import { convert } from "./money";
// @ts-expect-error 币种必须是 Currency,传 string 应当报错
convert(100, 7.2, "USD", "GBP");
// @ts-expect-error 金额必须是 number
convert("100", 7.2, "USD", "CNY");
expectTypeOf(convert).toBeFunction();
它和 @ts-ignore 的关键区别是:@ts-expect-error 在「没有报错」时会自己报错:
error TS2578: Unused '@ts-expect-error' directive.
这条规则让类型测试不会腐烂。假设某天有人把 convert 的签名放宽成 from: string,那么上面第一行不再报错,TS2578 立刻把测试打红,逼你确认「放宽签名」是不是有意的。用 @ts-ignore 就完全失去这层保护——它会安静地永久屏蔽错误。在 .test-d.ts 文件里,两者搭配是最实用的组合:前者守住「不该通过的用法」,后者守住「该有的类型」。
4.3.6 覆盖率四个指标
覆盖率门禁要配得有意义,先得分清四个指标统计的是什么:
| 指标 | 统计对象 | 典型偏低原因 |
|---|---|---|
statements | 语句是否被执行 | 错误分支、日志分支没测 |
lines | 行是否被执行 | 与 statements 接近,多行语句会拉开差距 |
functions | 函数是否被调用 | 导出但没人用的工具函数 |
branches | 每个条件分支是否都走到 | if、&&、??、默认参数、可选链 |
实践中最有价值的是 branches,因为它最接近「有没有测异常路径」。而 lines 最容易虚高:一个 90% 行覆盖的模块,可能所有错误分支都没测。
另一个常被忽略的点是未测试的文件算不算分母。Vitest 的 coverage.include 决定哪些文件进入统计:
const configExcerpt = {
coverage: {
provider: "v8",
reporter: ["text", "json-summary", "lcov"],
include: ["src/**/*.ts"],
exclude: ["src/**/*.d.ts", "src/**/types.ts", "src/**/index.ts"],
}
};
如果 include 只写了 src/core/**,那么 src/api 里一行没测的代码根本不会出现在报告里——覆盖率看起来很美,实际是分母作弊。所以 include 应当写成「所有源码」,再用 exclude 精确剔除类型文件与纯 re-export 的入口文件。
4.3.7 v8 与 istanbul 两种 provider
Vitest 支持两种覆盖率提供者,差异不在精度而在原理:
| 维度 | v8 | istanbul |
|---|---|---|
| 原理 | 用 V8 内置的精确覆盖率,无需插桩 | 源码插桩后再执行 |
| 速度 | 更快 | 稍慢 |
| 准确性 | 依赖 source map 映射回 TS 源码 | 直接统计插桩后的代码,映射更可靠 |
| 依赖 | 需要 @vitest/coverage-v8 | 需要 @vitest/coverage-istanbul |
| 忽略注释 | /* v8 ignore next */ | /* istanbul ignore next */ |
v8 的坑集中在source map:未执行的代码行在转译后会错位,报告里高亮的「未覆盖行」可能整体偏移几行。排查方式是打开 reporter: ["html"] 逐文件核对。如果项目里有大量装饰器、enum 或路径映射,istanbul 的报告通常更可信。忽略某些代码时两个 provider 的注释不能混用——写错会被静默忽略,覆盖率依旧不达标:
/* v8 ignore next 3 -- 仅在 CI 里执行的分支,无法在测试中覆盖 */
if (process.env.CI_ONLY_BRANCH) {
await reportToExternalService();
}
4.3.8 把门禁配出来
覆盖率本身只是报告,门禁是让它在不达标时让 CI 失败:
const configExcerpt = {
// vitest.config.ts(节选)
test: {
coverage: {
provider: "v8",
reporter: ["text", "json-summary", "lcov"],
include: ["src/**/*.ts"],
exclude: ["src/**/*.d.ts", "src/**/types.ts"],
thresholds: {
lines: 85,
functions: 85,
statements: 85,
branches: 75,
perFile: true, // 每个文件单独判定,防止「一个文件拉高全局」
autoUpdate: false,
},
},
}
};
三个配置要点:
branches阈值要低于其他三项。分支天然最难全覆盖,把它定得和lines一样高,只会逼团队写无意义的测试。perFile: true是双刃剑。它能防止「核心模块 20%、工具模块 100% 平均出 85%」这种掩盖,但也会让刚新建、尚未完善的模块直接把流水线打红。折中做法是:全局开perFile,同时对新增目录用exclude显式豁免,并在 PR 里说明豁免期限。autoUpdate: false必须显式写死。开启后 Vitest 会在覆盖率下降时自动把阈值调低——门禁会自己退化,等于没有。
想进一步看覆盖率与质量门禁的完整工程实践,可以延伸阅读 测试覆盖率与质量门禁 ,以及 GitHub Actions 测试覆盖率集成 里的流水线写法。
4.3.9 覆盖率的三个盲区
覆盖率有一个著名的陷阱:它能告诉你哪些代码没被执行,不能告诉你哪些代码没有被验证。三个具体盲区:
断言缺失。下面这个测试能让
add达到 100% 行覆盖,但它什么都没验证:it("调用 add", () => { add(1, 2); // 没有 expect,覆盖率满分 });边界值未覆盖。
if (amount > 0)两个分支都走过,不代表测了amount === 0和Number.MAX_SAFE_INTEGER。实现错误但结果巧合。把
a - b误写成b - a,只有当两个操作数不同且测试恰好断言了差值时才会暴露。
对付第 1 和第 3 类盲区的手段是变异测试(mutation testing):工具会主动把源码改成各种「错误版本」(+ 改 -、> 改 >=、删掉一行 return),然后重跑测试;如果测试依然全绿,说明这处变异没被任何断言发现,称为「存活变异体」。存活率越高,说明测试的「断言密度」越低。
{
"mutate": ["src/**/*.ts", "!src/**/*.test.ts"],
"testRunner": "vitest",
"thresholds": { "high": 80, "low": 60, "break": 60 },
"reporters": ["clear-text", "progress", "html"]
}
变异测试很慢(每个变异体都要重跑一次相关测试),不适合每次提交都跑。合理的位置是每日定时任务或发版前。更细的原理与阈值解读可延伸阅读 变异测试实践 ;想从另一个角度提升断言质量,属性测试(自动生成输入而非手写用例)也是好补充,见 属性测试 。若担心「测试写了但没测到真实行为」,测试覆盖率与 Mock 的陷阱 里有更多反例。
4.3.10 串进 CI
最后把三件事按顺序串成一个流水线(提交门禁的整体搭建见 1.3 代码规范与提交门禁(ESLint / Biome / husky) ):
- name: Typecheck
run: pnpm tsc --noEmit
- name: Unit tests with coverage
run: pnpm test --coverage
- name: Type tests
run: pnpm test:types
- name: Integration tests
run: pnpm test:integration
顺序有讲究:类型检查排第一,因为它最快、失败信息最明确,能在几秒内挡住大部分低级错误;类型测试紧跟单元测试,两者都只依赖本地代码;集成测试放最后,因为它要拉容器、最慢也最容易受环境影响。
如果用了 Vitest 的 json-summary reporter,还可以把覆盖率写进 PR 评论,让评审者直接看到本次改动对覆盖率的影响。这一层属于工程优化,不是必需,但能显著减少「为了过门禁而补无意义测试」的博弈。
4.3.11 常见坑
| 现象 | 原因 | 修复 |
|---|---|---|
| 类型测试全绿但什么都没测 | 没开 typecheck.enabled | 开启后单独跑 --typecheck.only |
error TS2578: Unused '@ts-expect-error' directive | 期望报错的地方其实没报错 | 确认签名是否被放宽,别改成 @ts-ignore |
| 覆盖率虚高 | include 只圈了部分目录 | include 写全部源码,用 exclude 精确剔除 |
| 报告里的未覆盖行位置偏移 | v8 的 source map 映射问题 | 换 istanbul provider,或核对 html 报告 |
| 忽略注释没生效 | 注释与 provider 不匹配 | v8 用 /* v8 ignore */,istanbul 用 /* istanbul ignore */ |
| 门禁阈值自己变低 | 开了 autoUpdate | 显式设置 autoUpdate: false |
小结
这一节把测试体系的最后两块补齐:
- 类型测试:
expectTypeOf断言类型相等或兼容,--typecheck.only让它真正参与编译检查,@ts-expect-error断言非法用法必须被拒绝,且能通过TS2578自动发现「保护失效」。 - 典型场景:泛型工具类型、API 客户端契约、判别联合收窄——这三类都只能靠类型测试守住。
- 覆盖率四指标:
branches最能反映异常路径覆盖,lines最容易虚高;include决定分母,写窄了就是作弊。 - 门禁配置:
branches阈值低于其他三项,perFile: true防止平均值掩盖,autoUpdate: false防止门禁自我退化。 - 覆盖率盲区:无断言的调用、未覆盖的边界值、实现错误但结果巧合——这三类要靠变异测试补位。
回顾第 4 章:4.1 用替身把业务逻辑测快,4.2 用容器把真实依赖测准,4.3 用类型测试与门禁把契约测稳。三者合起来才构成一条完整的防线——任何一层缺失,都会留下一类「测试全绿但线上炸」的漏洞。到这里,测试不再是负担,而是后面所有章节的底气:从下一章开始,我们要真正动手写服务端代码了,5.1 HTTP 服务与路由(Fastify / Hono) 会先从一个最小的 HTTP 服务讲起,把它接入本节配好的测试与门禁。
阅读导航:上一节:4.2 集成测试与 Testcontainers · 下一节:5.1 HTTP 服务与路由(Fastify / Hono) 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。