TypeScript 测试策略:单元测试、类型测试与测试替身实战

系统覆盖 TypeScript 项目的完整测试策略:Vitest 与测试框架选型、类型测试(expect-type/tsd)与编译期断言、单元测试最佳实践、测试替身(mock/stub/spy)、测试数据与工厂模式、覆盖率与 CI 集成、以及类型驱动的测试设计。

引言

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

1.1 两者对比

维度VitestJest
速度快(原生 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
CItypecheck + test 双门禁
契约测试判别联合 + expectTypeOf
测试组织AAA + 一测一行为

一句话记忆:TS 测试双线并行——行为测试用 Vitest(单元/集成/替身),契约测试用 expectTypeOf/tsd 断言「类型恰好相等」;Stub 隔离边界、Mock 断言调用、工厂管数据、fake timers 管时间;CI 双门禁 typecheck + test,覆盖率定阈值防盲区;类型驱动的 TDD 让「契约先行、类型断言验证、行为测试落地」——把类型系统本身也纳入回归测试。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「typescript」更多文章

  1. TypeScript 错误处理:Result 模式、类型化错误与错误边界实战
  2. TypeScript 构建性能优化:增量编译、缓存与工具链选型
  3. TypeScript 库作者指南:声明文件、API 演进与包发布