《TypeScript编程实战》4.3 类型测试与覆盖率门禁

本节补上测试体系的最后两个维度:类型测试用 vitest 的 typecheck 模式与 expectTypeOf 把类型契约纳入回归,用 @ts-expect-error 断言非法用法必须被拒绝;覆盖率门禁讲清四个指标的差异、v8 与 istanbul 的取舍、thresholds 与 perFile 配置,以及为什么覆盖率不等于正确性,最后把整套测试串进 CI 流水线。

本节目标:把测试体系封口。读完后,你会知道为什么运行时测试测不到类型回归,能用 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 支持两种覆盖率提供者,差异不在精度而在原理:

维度v8istanbul
原理用 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 覆盖率的三个盲区

覆盖率有一个著名的陷阱:它能告诉你哪些代码没被执行,不能告诉你哪些代码没有被验证。三个具体盲区:

  1. 断言缺失。下面这个测试能让 add 达到 100% 行覆盖,但它什么都没验证:

    it("调用 add", () => {
      add(1, 2); // 没有 expect,覆盖率满分
    });
    
  2. 边界值未覆盖。if (amount > 0) 两个分支都走过,不代表测了 amount === 0 和 Number.MAX_SAFE_INTEGER。

  3. 实现错误但结果巧合。把 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) 。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「typescript」更多文章

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