APE
文章标签归档关于

© 2026 APE.PUB

2026-07-25· 15 分钟

typescript-test

typescripttest

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 (覆盖率)
← 返回首页