SuperAPI AI 工具集

writing-good-tests

测试与质量

查看项目

更新于 10-16 04:12

技能介绍

用途

本技能用于在编写或审查测试时提供指导,覆盖测试哲学、基于条件的等待、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]) 装饰器);不重新实现(不在断言中重述函数逻辑);现实约束(策略匹配真实世界输入约束)。