测试反模式 (Testing Anti-Patterns)

摘要

测试反模式是测试驱动开发(TDD)过程中常见的不良测试实践,核心问题是违背了“测试真实代码行为,而非测试模拟行为”的原则。本文梳理了五类常见的测试反模式,给出了违反示例、问题成因、修正方案以及检查规则,同时指出严格遵循TDD流程可以有效避免这些反模式。该文档作为编写测试、添加模拟时的参考指南使用。

核心原则与铁律

核心原则

测试代码实际行为,而非模拟的行为;模拟(Mock)仅用于隔离依赖,本身不是测试对象。严格遵循TDD可以预防绝大多数此类反模式。

测试铁律

  1. 绝对不要测试模拟行为
  2. 绝对不要在生产类中添加仅测试使用的方法
  3. 绝对不要在不理解依赖的情况下进行模拟

常见测试反模式

1. 测试模拟行为

违规示例

仅验证模拟元素存在,不对真实组件行为做断言:

test('renders sidebar', () => {
  render(<Page />);
  expect(screen.getByTestId('sidebar-mock')).toBeInTheDocument();
});

问题

  • 验证的是模拟正常工作,而非真实组件功能
  • 测试结果仅和模拟是否存在绑定,无法反映真实功能正确性

修正方案

测试真实组件行为,不对模拟本身做断言:如果不需要隔离就不做模拟,直接断言真实元素;如果必须模拟,也只测试被测试组件在模拟依赖存在时的真实行为,不验证模拟本身。

检查规则

在对任意模拟元素做断言前,先确认是否在测试真实行为,如果只是测试模拟存在,就删除该断言改为测试真实行为。


2. 生产代码中存在仅测试方法

违规示例

为了测试清理,给生产类添加仅测试会调用的destroy()方法:

class Session {
  async destroy() {  // 仅用于测试
    await this._workspaceManager?.destroyWorkspace(this.id);
  }
}
afterEach(() => session.destroy());

问题

  • 生产代码被仅测试用代码污染
  • 存在被误调用到生产环境的风险
  • 违反YAGNI原则和关注点分离,混淆对象生命周期

修正方案

把测试相关逻辑移到测试工具类中,保持生产代码干净:

// 测试工具中实现清理逻辑
export async function cleanupSession(session: Session) {
  const workspace = session.getWorkspaceInfo();
  if (workspace) {
    await workspaceManager.destroyWorkspace(workspace.id);
  }
}
afterEach(() => cleanupSession(session));

检查规则

给生产类添加方法前,先确认是否仅测试使用,如果是就移到测试工具中;同时确认该类是否确实拥有对应资源的生命周期,不属于该类的方法不要添加。


3. 不理解依赖就进行模拟

违规示例

错误模拟了测试本身依赖其副作用的高层方法,导致测试逻辑失效:

vi.mock('ToolCatalog', () => ({
  discoverAndCacheTools: vi.fn().mockResolvedValue(undefined)
})); // 该方法原本的写配置副作用被抹除,测试无法触发重复检测
await addServer(config);
await addServer(config); // 本应抛出错误却不会触发

问题

  • 模拟抹去了测试依赖的原有副作用,破坏测试逻辑
  • 过度模拟会导致测试通过得毫无意义,或出现莫名其妙的失败

修正方案

在正确层级做模拟:只模拟缓慢/外部的底层操作,保留测试需要的原有高层行为:

vi.mock('MCPServerManager'); // 仅模拟缓慢的服务启动,保留写配置逻辑
await addServer(config);  // 配置正常写入
await addServer(config);  // 重复检测正常触发 ✓

检查规则

添加模拟前先确认:真实方法有哪些副作用?测试是否依赖这些副作用?如果依赖就去更低层级做模拟,不要模拟测试依赖的高层方法;不确定就先运行真实实现,再添加最小化的正确模拟。


4. 不完整模拟

违规示例

只构造测试当前用到的字段,遗漏下游代码依赖的其他字段:

const mockResponse = {
  status: 'success',
  data: { userId: '123', name: 'Alice' }
  // 下游需要的metadata字段被遗漏,运行时出错
};

问题

  • 部分模拟会掩盖代码的结构依赖,下游代码依赖缺失字段时会引发静默失败
  • 测试通过但真实集成会失败,给开发者错误的信心

修正方案

按照真实API的结构,完整模拟所有返回字段,保证和实际结构一致:

const mockResponse = {
  status: 'success',
  data: { userId: '123', name: 'Alice' },
  metadata: { requestId: 'req-789', timestamp: 1234567890 }
  // 包含真实API返回的所有字段
};

检查规则

创建模拟响应前,先确认真实API包含的所有字段,包含系统下游可能用到的全部字段,保证模拟和真实响应结构完全一致。


5. 集成测试作为事后补充

违规示例

开发完成后才补测试,将测试视为实现完成后的额外步骤:

✅ 实现完成 ❌ 没有编写测试 “可以开始测试了”

问题

测试是实现的一部分,而非可选的后续步骤,没有测试就不算完成开发。

修正方案

遵循TDD开发流程:1. 编写失败的测试 → 2. 编写实现让测试通过 → 3. 重构 → 4. 才算开发完成。

复杂模拟警告信号

当出现以下情况时,说明模拟可能过度复杂,应当考虑使用真实组件做集成测试:

  • 模拟设置代码比测试逻辑还长
  • 为了让测试通过几乎模拟了所有内容
  • 模拟缺失真实组件拥有的方法
  • 模拟一修改测试就失败

TDD预防反模式的原因

  1. 先写测试:迫使开发者思考真正要测试的内容
  2. 观察测试失败:确认测试测试的是真实行为,而非模拟
  3. 最小化实现:避免测试仅方法侵入生产代码
  4. 优先使用真实依赖:在模拟前就能明确测试真正需要的内容

快速参考

反模式修正方案
断言模拟元素测试真实组件,或不使用模拟
生产代码中的仅测试方法移动到测试工具中
不理解依赖就模拟先理解依赖,最小化模拟
不完整模拟完整镜像真实API结构
测试作为事后补充遵循TDD,先写测试再开发
过度复杂的模拟考虑改用集成测试

危险信号

  • 断言检查带*-mock后缀的测试ID
  • 生产类中存在仅测试文件调用的方法
  • 模拟设置占测试代码超过50%
  • 移除模拟后测试就失败
  • 无法解释为什么需要这个模拟
  • 为了”安全”才做模拟

总结

模拟只是用于隔离依赖的工具,本身不是测试对象。如果TDD流程发现你在测试模拟行为,就说明已经出错了,修正方式是测试真实行为,或者重新考虑是否真的需要模拟。