用途
本技能用于在编写或审查测试时提供指导,覆盖测试哲学、基于条件的等待、Mock 策略和测试隔离。其目标是帮助开发者建立对代码正确性的信心,而非追求覆盖率数字。
测试哲学
核心理念引用 Kent C. Dodds 的观点:“写测试。不要太多。主要是集成测试。”测试应验证真实行为,而非实现细节。核心原则包括:
- 测试行为,而非实现——重构不应破坏测试
- 集成测试比单元测试提供更好的信心成本比
- 等待实际条件,而非任意超时
- 策略性地 Mock——可行时使用真实依赖,对外部系统使用 Mock
- 不要用仅用于测试的方法污染生产代码
测试结构
使用 Arrange-Act-Assert(或 Given-When-Then)结构。每个测试一个动作,如果多个断言验证同一行为则可以接受。示例:
test('user can cancel reservation', async () => {
// Arrange
const reservation = await createReservation({ userId: 'user-1', roomId: 'room-1' });
// Act
const result = await cancelReservation(reservation.id);
// Assert
expect(result.status).toBe('cancelled');
expect(await getReservation(reservation.id)).toBeNull();
});
基于条件的等待
不稳定的测试常常猜测时序,导致本地通过但 CI 失败的竞态条件。应等待条件而非时间:
// BAD: Guessing at timing
await new Promise(r => setTimeout(r, 50));
const result = getResult();
// GOOD: Waiting for condition
await waitFor(() => getResult() !== undefined);
const result = getResult();
通用轮询函数:
async function waitFor<T>(
condition: () => T | undefined | null | false,
description: string,
timeoutMs = 5000
): Promise<T> {
const startTime = Date.now();
while (true) {
const result = condition();
if (result) return result;
if (Date.now() - startTime > timeoutMs) {
throw new Error(`Timeout waiting for ${description} after ${timeoutMs}ms`);
}
await new Promise(r => setTimeout(r, 10)); // Poll every 10ms
}
}
常见模式:等待事件用 waitFor(() => events.find(e => e.type === 'DONE'));等待状态用 waitFor(() => machine.state === 'ready');等待计数用 waitFor(() => items.length >= 5)。
仅在测试实际时序行为(防抖、节流、间隔)时,任意超时才是正确的,并应注释说明原因。
Mock 策略
引用 J.B. Rainsberger 的观点:“你不讨厌 Mock;你讨厌副作用。”Mock 揭示了副作用使代码复杂化的地方,应策略性使用而非条件反射式使用。
不要 Mock 你不拥有的东西:为第三方库创建薄包装器,Mock 你自己的包装器,而非库本身。
管理型与非管理型依赖:
- 管理型(你控制的):你的数据库、你的文件系统——使用真实实例
- 非管理型(外部的):第三方 API、SMTP、消息总线——使用 Mock
与受管依赖的通信是实现细节,可以自由重构;与非受管依赖的通信是可观察行为,Mock 可防止外部变化。
反模式:
- 测试 Mock 行为:断言 Mock 元素存在而非真实行为。在断言任何 Mock 元素前,问自己:“我在测试真实行为还是 Mock 的存在?”
- 无理解地 Mock:Mock 破坏了测试逻辑。在 Mock 前,问自己:“这有什么副作用?我的测试依赖它们吗?”
- 不完整的 Mock:应完整镜像真实 API 的数据结构,包括下游代码使用的元数据。
- Mock 过于复杂:警告信号包括 Mock 设置比测试逻辑更长、为让测试通过而 Mock 一切、Mock 变化时测试中断。考虑使用真实组件的集成测试。
- 生产代码中的测试专用方法:在向生产类添加任何方法前,问自己:“这是否仅被测试使用?”如果是,放入测试工具中。
测试隔离
测试不应依赖执行顺序,但隔离不意味着清理一切。
必须清理的长期资源:虚拟机、容器、Kubernetes 作业/容器组/部署、云资源(实例、存储桶)、后台进程/守护进程。优先使用产品自身的清理机制,产品工具不可用时使用侧信道清理。
可以保留的:数据库工件(测试记录、日志条目、会过期的缓存数据)。数据库应处理自己的生命周期。需要原始状态的测试应创建唯一标识符,而非依赖清理。
防止顺序依赖:使用唯一标识符,例如 const testId = test-${Date.now()}-${Math.random()};。
快速参考
| 问题 | 修复 |
|---|---|
| 测试中的任意 setTimeout | 使用基于条件的等待 |
| 断言 Mock 元素 | 测试真实组件或取消 Mock |
| 直接 Mock 第三方 | 创建包装器,Mock 包装器 |
| 生产中的测试专用方法 | 移至测试工具 |
| 无理解地 Mock | 先理解依赖 |
| 不完整的 Mock | 完整镜像真实 API |
| 过于复杂的 Mock | 考虑集成测试 |
| 长期资源未清理 | 清理虚拟机、k8s 作业、云资源 |
危险信号
看到以下情况应停下来重新考虑:无理由的任意 setTimeout/sleep;对 Mock 元素或测试 ID 的断言;仅在测试文件中调用的方法;Mock 设置占测试代码 50% 以上;“只是为了安全而 Mock”;测试依赖另一个测试先运行;长期资源未清理。
TDD 联系
TDD 可防止大多数测试反模式:先写测试迫使思考测试内容;看它失败确认测试真实行为而非 Mock;最小实现防止测试专用方法混入;先使用真实依赖让你在 Mock 前看到测试需要什么。
基于属性的测试(PBT)
对于某些模式,基于属性的测试比基于示例的测试提供更强的覆盖。
适用场景:序列化对(encode/decode、toJSON/fromJSON)——往返属性捕获边缘情况;规范化器(sanitize、canonicalize、format)——幂等性属性确保稳定性;验证器(is_valid、validate)——规范化后有效属性;纯函数(业务逻辑、计算)——多个属性验证契约;排序/排序(sort、rank、compare)——排序 + 幂等性属性。
不适用场景:无转换的简单 CRUD;UI/展示逻辑;需要外部设置的集成测试;具体示例足够且边缘情况已充分理解;需求多变时的原型开发。
PBT 质量门禁(提交前):非重言式(断言不比较同一表达式);强属性(不只是“不崩溃”,追求往返、幂等性或不变量);非空泛(assume() 调用不滤除大多数输入);边缘情况显式(包含 @example([])、@example([1]) 装饰器);不重新实现(不在断言中重述函数逻辑);现实约束(策略匹配真实世界输入约束)。