typescript-test
TypeScript 单元测试指南
一、测试框架选型
| 框架 | 推荐场景 |
|------|----------|
| Vitest | 新项目首选(Vite 生态,原生 TS,极快) |
| Jest | 传统项目,生态最成熟 |
| Node Test Runner | Node 22+ 内置,零依赖轻量方案 |
二、核心原则
2.1 测试行为,而非实现
- 行为 = 外部可见的契约:入参、返回值、异常、副作用(调了哪些依赖)
- 实现 = 内部怎么做:用什么算法、怎么查数据库、循环用 for 还是 while
行为变了 → 测试必须改(契约改了)
实现变了 → 测试不该改(行为没变)
2.2 Arrange-Act-Assert(三段式)
it('取消订单成功时更新状态并发送通知', async () => {
// Arrange — 准备数据
const mocks = createMocks();
const service = new OrderService(mocks.repo, mocks.notifier);
mocks.repo.findById.mockResolvedValue(buildOrder());
// Act — 执行
const result = await service.cancel(1, '用户取消');
// Assert — 断言
expect(result.status).toBe('cancelled');
expect(mocks.repo.save).toHaveBeenCalledOnce();
});
2.3 单一断言原则
每个 it 只测一个行为,但允许多个 expect 验证同一个行为的多个方面。
2.4 隔离性
测试间不共享可变状态,每个测试独立 setup,不依赖执行顺序。
三、依赖注入与可测试性
3.1 为什么 DI 有利于单元测试
- 隔离被测单元 — 不注入真实数据库/网络/文件系统
- 控制所有输入 — mock 精确控制返回值、抛异常、调用次数/参数
- 消除副作用 — 测试不会真的写数据库、发邮件
- 无需模块级 hack — 构造函数直接传 mock 实例,类型安全
// ❌ 紧耦合 — 内部 new,无法 mock
class OrderService {
private repo = new OrderRepo();
}
// ✅ 依赖注入 — 测试时传入 mock
class OrderService {
constructor(private repo: IOrderRepo) {}
}
// 测试
const mockRepo = { findById: vi.fn() };
const svc = new OrderService(mockRepo);
3.2 DI 与业务数据共存
DI 注入的是协作者(repo/logger/notifier),不是业务数据。两者可以共存:
| 模式 | DI 注入 | 业务数据 | 调用方式 |
|------|---------|----------|----------|
| Stateless Service | 构造函数 | 方法参数 | svc.cancel(1, 'reason') |
| Stateful Context | 构造函数 | 字段(init) | ctx.init(1, 'reason').execute() |
| 工厂函数 | 闭包捕获 | 方法参数 | cancelOrder(1, 'reason') |
// Stateless Service(最常用)
class OrderService {
constructor(private repo: IOrderRepo) {} // DI
async cancel(orderId: number, reason: string) {} // 业务数据
}
// Stateful Context
class CancelOrderContext {
constructor(private repo: IOrderRepo) {} // DI
init(orderId: number, reason: string): this { // 业务数据 → 字段
this.orderId = orderId;
return this;
}
async execute(): Promise<Order> {} // 无参数方法
}
// 工厂函数
function createCancelUseCase(repo: IOrderRepo) { // DI
return async (orderId: number, reason: string) => {} // 业务数据
}
四、Mock 技巧
4.1 vi.fn()
创建 mock 函数,记录调用次数、参数、返回值。
const fn = vi.fn();
fn('hello');
expect(fn).toHaveBeenCalled();
expect(fn).toHaveBeenCalledWith('hello');
// 控制返回值
vi.fn().mockReturnValue(42); // 同步
vi.fn().mockResolvedValue({ id: 1 }); // Promise.resolve
vi.fn().mockRejectedValue(new Error()); // Promise.reject
vi.fn().mockImplementation((x) => x); // 自定义实现
// 只生效一次
mockFn.mockResolvedValueOnce(order);
4.2 工厂函数模式
function createMocks() {
return {
repo: {
findById: vi.fn(),
save: vi.fn(),
} satisfies IOrderRepo,
notifier: {
send: vi.fn(),
} satisfies INotifier,
};
}
function buildOrder(overrides?: Partial<Order>): Order {
return {
id: 1,
userId: 42,
status: 'pending',
total: 100,
...overrides,
};
}
satisfies 确保 mock 对象符合接口类型,编译器检查。
五、行为 vs 实现(深入)
5.1 行为测试能抓到什么
| 实现错误类型 | 行为测试能否抓到 |
|--------------|-----------------|
| 逻辑错误(算错价格) | ✅ 返回值不对 |
| 条件漏了(没判断空值) | ✅ 异常没抛/结果不对 |
| 调错依赖(该调A调了B) | ✅ 副作用断言失败 |
5.2 行为测试抓不到什么
| 实现错误类型 | 原因 |
|--------------|------|
| 性能问题(O(n²)) | 行为正确但慢 |
| 资源泄漏(不关连接) | 行为正确但系统崩溃 |
| 竞态条件 | 单线程测不出来 |
| 安全漏洞(没鉴权) | happy path 通过 |
对策:分层测试
单元测试 → 逻辑正确性
集成测试 → 真实依赖交互
性能测试 → 非功能特性
安全测试 → 漏洞扫描
六、函数长度与可测试性
6.1 经验值
50 行以内 → 舒服
100 行左右 → 还能接受
200+ 行 → 明显该拆了
500+ 行 → 代码坏味道
6.2 判断标准
不是行数,而是单一职责:
这个函数能用一句话说清楚它在做什么吗?
├─ 能 → 行数不重要
└─ 不能 → 拆
6.3 大函数拆解
// ❌ 5000 行,十几个依赖,无法测
function bigHandler(orderId: number) { ... }
// ✅ 拆成小函数,每个 1~2 个依赖
class OrderHandler {
constructor(
private userRepo: IUserRepo,
private payment: IPaymentService,
private notifier: INotifier,
) {}
async handle(orderId: number) {
const user = await this.userRepo.findById(orderId);
await this.checkBalance(user);
const result = await this.charge(user, amount);
await this.notify(user, result);
}
private async checkBalance(user: User): Promise<boolean> { ... }
private async charge(user: User, amount: number): Promise<PaymentResult> { ... }
private async notify(user: User, result: PaymentResult): Promise<void> { ... }
}
七、完整示例
实现代码
// src/order.service.ts
export interface IOrderRepo {
findById(id: number): Promise<Order | null>;
save(order: Order): Promise<void>;
}
export interface INotifier {
send(userId: number, message: string): Promise<void>;
}
export interface Order {
id: number;
userId: number;
status: 'pending' | 'paid' | 'cancelled';
total: number;
}
export class OrderService {
constructor(
private repo: IOrderRepo,
private notifier: INotifier,
) {}
async cancel(orderId: number, reason: string): Promise<Order> {
const order = await this.repo.findById(orderId);
if (!order) throw new Error('Order not found');
if (order.status === 'cancelled') throw new Error('Already cancelled');
const updated = { ...order, status: 'cancelled' as const };
await this.repo.save(updated);
await this.notifier.send(order.userId, `订单已取消: ${reason}`);
return updated;
}
}
测试代码
// src/__tests__/order.service.test.ts
import { describe, it, expect, vi } from 'vitest';
import { OrderService, type IOrderRepo, type INotifier, type Order } from '../order.service';
function createMocks() {
return {
repo: { findById: vi.fn(), save: vi.fn() } satisfies IOrderRepo,
notifier: { send: vi.fn() } satisfies INotifier,
};
}
function buildOrder(overrides?: Partial<Order>): Order {
return { id: 1, userId: 42, status: 'pending', total: 100, ...overrides };
}
describe('OrderService', () => {
describe('cancel', () => {
it('取消成功时更新状态并发送通知', async () => {
const mocks = createMocks();
const service = new OrderService(mocks.repo, mocks.notifier);
const order = buildOrder();
mocks.repo.findById.mockResolvedValue(order);
const result = await service.cancel(1, '用户主动取消');
expect(result.status).toBe('cancelled');
expect(mocks.repo.save).toHaveBeenCalledWith(
expect.objectContaining({ id: 1, status: 'cancelled' }),
);
expect(mocks.notifier.send).toHaveBeenCalledWith(42, expect.stringContaining('用户主动取消'));
});
it('订单不存在时抛异常', async () => {
const mocks = createMocks();
const service = new OrderService(mocks.repo, mocks.notifier);
mocks.repo.findById.mockResolvedValue(null);
await expect(service.cancel(999, 'test')).rejects.toThrow('Order not found');
expect(mocks.repo.save).not.toHaveBeenCalled();
expect(mocks.notifier.send).not.toHaveBeenCalled();
});
it('已取消的订单不能重复取消', async () => {
const mocks = createMocks();
const service = new OrderService(mocks.repo, mocks.notifier);
mocks.repo.findById.mockResolvedValue(buildOrder({ status: 'cancelled' }));
await expect(service.cancel(1, 'again')).rejects.toThrow('Already cancelled');
expect(mocks.repo.save).not.toHaveBeenCalled();
});
});
});
八、反模式
| 反模式 | 说明 |
|--------|------|
| 测试私有方法 | 应通过公开行为间接验证 |
| 过度 mock | mock 越少,测试越有价值 |
| 条件逻辑 in test | 测试中不应有 if/switch |
| 依赖执行顺序 | 每个测试应独立运行 |
| 快照过大 | 超过 50 行难以审查 |
| 测试实现细节 | 重构时测试误报失败 |
| 一个函数测所有路径 | 5000 行不拆直接测 |
九、推荐工具链
Vitest + @testing-library/react (UI) + msw (API mock) + c8/istanbul (覆盖率)