引言
TypeScript 项目有两类「错误」要防:运行时的逻辑错误(用单元/集成测试)和编译期的类型错误(用类型检查)。但 TS 的类型系统只保证「类型一致」,不保证「业务正确」;反过来,测试只验证「运行行为」,不一定验证「类型契约」——两者要一起上。更妙的是,TS 能测「类型本身」:断言某个类型恰好等于另一个类型、某个泛型的结果符合预期。
本文系统讲 TS 测试策略:先对比测试框架选型(Vitest/Jest),再重点讲类型测试(expect-type、tsd)——用代码断言类型系统;接着讲单元测试最佳实践、测试替身与测试数据工厂,最后覆盖覆盖率、CI 集成与类型驱动的测试设计。
前置:/typescript/(TS 基础)、/typescript-advanced-types/(类型运算)、/typescript-strict-config/(严格配置)、/typescript-project-architecture-tsconfig/(工程架构)。
目录
- 1. 测试框架选型:Vitest vs Jest
- 2. 类型测试:断言类型系统本身
- 3. tsd:面向库的类型测试
- 4. 单元测试最佳实践
- 5. 测试替身:Mock、Stub 与 Spy
- 6. 测试数据与工厂模式
- 7. 异步与并发测试
- 8. 覆盖率与 CI 集成
- 9. 类型驱动的测试设计
- 10. 速查表
- 延伸阅读
1. 测试框架选型:Vitest vs Jest
1.1 两者对比
| 维度 | Vitest | Jest |
|---|---|---|
| 速度 | 快(原生 ESM + worker) | 中 |
| ESM 支持 | 原生 | 需配置 |
| 配置 | 零配置(Vite 生态) | 较繁琐 |
| TS 支持 | 开箱即用 | ts-jest/babel |
| 快照/模拟 | 完整 | 完整 |
| 社区 | 新兴、活跃 | 成熟 |
1.2 Vitest 快速上手
pnpm add -D vitest
# 无需额外配置,vitest 直接识别 TS
// sum.ts
export function sum(a: number, b: number): number {
return a + b;
}
// sum.test.ts
import { describe, it, expect } from 'vitest'
import { sum } from './sum'
describe('sum', () => {
it('adds two numbers', () => {
expect(sum(1, 2)).toBe(3)
})
})
// package.json
{
"scripts": {
"test": "vitest",
"test:run": "vitest run" // CI 一次性运行
}
}
1.3 测试文件组织
co-located(推荐):测试与被测文件同目录
src/sum.ts
src/sum.test.ts
集中式:__tests__/ 目录
Vitest 默认匹配 *.test.ts / *.spec.ts
一句话总结:Vitest 与 Vite/TS 原生协同、零配置、速度快,是 TS 项目当前最优选;Jest 适合历史项目或特殊生态。
2. 类型测试:断言类型系统本身
2.1 为什么需要类型测试
类型检查(tsc)只保证「代码和类型一致」,不保证「类型符合业务预期」
例:一个泛型函数本该返回「输入对象去掉 id 字段」,但实现可能返回了多余字段——
tsc 不报错,因为实现自己定义了自己的返回类型
类型测试 = 断言「这个类型恰好等于那个类型」,把类型错误也变成测试
2.2 expect-type:精确类型断言
import { expectTypeOf, expect } from 'vitest'
// 断言函数返回类型
const result = processUser({ id: 1, name: 'Alice' })
expectTypeOf(result).toEqualTypeOf<{ name: string }>() // 恰好相等
// 断言泛型推导
expectTypeOf(pick<{ a: number; b: string }, 'a'>({ a: 1, b: 'x' }))
.toEqualTypeOf<{ a: number }>()
// 断言可赋值性(宽松)
expectTypeOf(value).toMatchTypeOf<number>()
2.3 内置工具类型的类型测试
import { expectTypeOf } from 'vitest'
// 验证自定义工具类型行为
type MyReturnType<T extends (...args: any[]) => any> =
T extends (...args: any[]) => infer R ? R : never
expectTypeOf<MyReturnType<() => string>>().toEqualTypeOf<string>()
expectTypeOf<MyReturnType<(x: number) => boolean>>().toEqualTypeOf<boolean>()
2.4 什么时候写类型测试
1. 自定义工具类型 / 类型体操(泛型、条件类型)
2. 公共 API 的类型契约(库作者)
3. 复杂的泛型推导(如类型安全 API)
4. 重构类型定义时防止回归
一句话总结:类型测试用 expect-type/tsd 断言「类型恰好相等」,把类型系统的回归也变成可运行的测试——是库作者与类型体操的必备。
3. tsd:面向库的类型测试
3.1 tsd 是什么
tsd 是专为「库类型声明」设计的类型测试工具:它编译 .test-d.ts 文件里的断言,验证 .d.ts 是否如预期。
// index.test-d.ts
import { expectType, expectError, expectAssignable } from 'tsd'
import { processUser } from '.'
// 断言类型恰好
expectType<string>(processUser({ id: 1 }).name)
// 断言「应报错」的用法
expectError(processUser({ id: 'not-number' }))
// 断言可赋值
expectAssignable<{ name: string }>(processUser({ id: 1 }))
3.2 tsd 的关键 API
| API | 用途 |
|---|---|
expectType | 断言表达式类型与给定类型一致 |
expectError | 断言该用法编译不通过(反例) |
expectAssignable | 断言可赋值(宽松) |
expectNotType | 断言类型不一致 |
expectNever | 断言类型为 never |
3.3 tsd 实战示例
// 测试一个「只读类型」工具
import { expectType, expectError } from 'tsd'
type ReadonlyDeep<T> = {
readonly [K in keyof T]: T[K] extends object ? ReadonlyDeep<T[K]> : T[K]
}
type Source = { a: number; nested: { b: string } }
type Readonly = ReadonlyDeep<Source>
expectType<Readonly<Readonly>>(readonlyValue) // 可赋值
expectError(readonlyValue.a = 2) // 只读属性不可写 → 编译失败
3.4 tsd vs expect-type
expect-type(Vitest 内嵌):适合「项目内类型断言」,与测试无缝
tsd:面向「库作者」,验证对外 .d.ts 契约,含 expectError(反例断言)
组合:项目内用 expectTypeOf,发布库用 tsd
一句话总结:tsd 是库作者的专属类型测试——expectType 断言契约、expectError 断言「错误用法确实报错」,确保对外 .d.ts 不破坏性变更。
4. 单元测试最佳实践
4.1 测试结构:AAA 模式
describe('checkout', () => {
it('calculates total with shipping', () => {
// Arrange:准备输入与依赖
const cart = buildCart({ items: 2, weight: 5 })
// Act:执行被测单元
const total = calculateTotal(cart)
// Assert:断言结果
expect(total).toBe(price(2) + shipping(5))
})
})
4.2 断言风格
// 精确匹配(优先)
expect(result).toBe(42)
expect(result).toEqual({ id: 1, tags: ['a'] })
// 对象部分匹配
expect(customer).toMatchObject({ name: 'Alice' })
// 数组/集合
expect(tags).toContain('ts')
expect(users).toHaveLength(3)
// 异常
expect(() => parse('bad')).toThrowError('invalid json')
4.3 一个测试只验证一个行为
坏:一个 it 里测了「校验+计算+落库」三个行为
好:拆成 3 个 it,失败时精确定位
命名:it('throws when amount is negative')(行为描述而非实现)
一句话总结:单元测试用 AAA 组织、精确断言、一测一行为——失败时快速定位,测试本身即文档。
5. 测试替身:Mock、Stub 与 Spy
5.1 三种替身
| 替身 | 用途 |
|---|---|
| Stub | 替换依赖,返回预设值(隔离被测单元) |
| Mock | 记录调用,断言「被调用了几次/参数是什么」 |
| Spy | 包裹真实实现,观察调用同时保留行为 |
5.2 Vitest 的 vi 工具
import { vi } from 'vitest'
// 替换模块(模块级 Mock)
vi.mock('./payment-service', () => ({
charge: vi.fn().mockResolvedValue({ success: true })
}))
// 单函数 Mock
const chargeMock = vi.fn().mockResolvedValue({ success: true })
// Spy 真实对象
const dbSpy = vi.spyOn(db, 'save').mockImplementation(async () => {})
// 断言调用
expect(chargeMock).toHaveBeenCalledTimes(1)
expect(chargeMock).toHaveBeenCalledWith(99.9, 'card-1')
5.3 Mock 的适度原则
过度 Mock 的坏处:测试「证明 Mock 行为」而非「真实逻辑」
原则:
1. 只 Mock 边界(网络/数据库/时间)
2. 业务逻辑尽量真实执行
3. 集成测试(真实依赖)与单元测试(Mock 边界)分层
一句话总结:Stub 隔离依赖、Mock 断言调用、Spy 观察行为——只 Mock「网络/数据库/时间」等边界,业务逻辑走真实。
6. 测试数据与工厂模式
6.1 工厂函数(Factory)
// 测试数据工厂:默认值 + 可覆盖
export function makeUser(overrides: Partial<User> = {}): User {
return {
id: 1,
name: 'Alice',
email: 'alice@example.com',
role: 'user',
...overrides, // 覆盖默认
}
}
// 使用
const admin = makeUser({ role: 'admin' })
const weird = makeUser({ name: '', email: 'bad' }) // 构造边界数据
6.2 数据构建器(Builder)
class UserBuilder {
private user: User = makeUser()
withName(name: string) { this.user = { ...this.user, name }; return this }
withEmail(email: string) { this.user = { ...this.user, email }; return this }
build(): User { return this.user }
}
const u = new UserBuilder().withName('Bob').withEmail('bob@x.com').build()
6.3 工厂的价值
1. 默认值让「只改关心的字段」
2. 集中管理构造,字段变更一处改
3. 类型安全(overrides 用 Partial<User> 限制)
4. 边界数据可复现
一句话总结:测试数据工厂提供「默认值 + 覆盖」模式,Builder 让复杂对象的构造可读——集中构造、类型安全、易维护。
7. 异步与并发测试
7.1 异步测试
it('fetches user', async () => {
const user = await fetchUser(1)
expect(user.name).toBe('Alice')
})
// 多个异步并行
it('loads parallel', async () => {
const [a, b] = await Promise.all([fetchA(), fetchB()])
expect(a + b).toBe(3)
})
7.2 时间控制(fake timers)
import { vi } from 'vitest'
it('debounces', () => {
vi.useFakeTimers()
const fn = vi.fn()
const debounced = debounce(fn, 500)
debounced()
vi.advanceTimersByTime(500)
expect(fn).toHaveBeenCalledTimes(1)
vi.useRealTimers()
})
7.3 并发测试的隔离
并发测试(test.concurrent)共享状态 → 小心全局/模块级状态
原则:测试间不共享可变状态;用 beforeEach 重置
一句话总结:异步测试用 async/await + Promise.all,时间敏感逻辑用 fake timers 推进;并发测试注意状态隔离。
8. 覆盖率与 CI 集成
8.1 覆盖率度量
vitest run --coverage
// vitest.config.ts
export default defineConfig({
test: {
coverage: {
provider: 'v8', // 或 istanbul
include: ['src/**'],
thresholds: { lines: 80, functions: 80, branches: 70 }
}
}
})
8.2 覆盖率不是一切
覆盖率是「探索盲区」的线索,不是质量目标本身
高覆盖率 + 弱断言 ≠ 好测试
更重要的指标:关键路径覆盖、异常分支覆盖、类型契约测试
8.3 CI 集成
# GitHub Actions 示例
name: test
on: [push, pull_request]
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: pnpm/action-setup@v4
- run: pnpm install
- run: pnpm typecheck # 类型检查
- run: pnpm test:run # 测试
- run: pnpm test:run --coverage # 覆盖率门禁
8.4 类型检查 + 测试双门禁
CI 中「tsc 类型检查」与「vitest 测试」分开跑
typecheck:防类型回归(快)
test :防逻辑回归(较慢)
合并示例:pnpm typecheck && pnpm test:run
一句话总结:覆盖率定阈值、CI 双门禁(typecheck + test)——覆盖率防盲区,类型测试防契约回归,两者互补。
9. 类型驱动的测试设计
9.1 类型先行的 TDD
先用类型定义「函数契约」→ 类型即测试蓝图
1. 写函数签名(参数类型、返回类型)
2. 写类型测试断言(expectTypeOf)
3. 再实现逻辑 + 行为测试
类型契约先行 → 行为测试围绕契约写
9.2 判别联合测试
type Result<T> = { ok: true; data: T } | { ok: false; error: string }
// 类型收窄后测试每种分支
expectTypeOf(result).toEqualTypeOf<Result<number>>()
function unwrap<T>(r: Result<T>): T {
if (r.ok) return r.data
throw new Error(r.error)
}
// 测试:ok 分支返回 data,error 分支抛错
9.3 类型契约即测试文档
良好的类型契约(判别联合、泛型约束、字面量类型)本身就是「可编译的文档」
测试验证:契约符合预期 + 行为符合契约
一句话总结:类型驱动的测试 = 契约先行、类型断言验证契约、行为测试验证实现——判别联合与泛型把「非法用法」挡在编译期。
10. 速查表
| 需求 | 方案 |
|---|---|
| 测试框架 | Vitest |
| 类型断言 | expectTypeOf / tsd |
| 反例断言 | tsd expectError |
| 隔离依赖 | vi.mock / vi.spyOn |
| 测试数据 | 工厂 + Builder |
| 异步 | async/await + fake timers |
| 覆盖率 | v8 + thresholds |
| CI | typecheck + test 双门禁 |
| 契约测试 | 判别联合 + expectTypeOf |
| 测试组织 | AAA + 一测一行为 |
一句话记忆:TS 测试双线并行——行为测试用 Vitest(单元/集成/替身),契约测试用 expectTypeOf/tsd 断言「类型恰好相等」;Stub 隔离边界、Mock 断言调用、工厂管数据、fake timers 管时间;CI 双门禁 typecheck + test,覆盖率定阈值防盲区;类型驱动的 TDD 让「契约先行、类型断言验证、行为测试落地」——把类型系统本身也纳入回归测试。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。