测试反模式 (Testing Anti-Patterns)
摘要
测试反模式是测试驱动开发(TDD)过程中常见的不良测试实践,核心问题是违背了“测试真实代码行为,而非测试模拟行为”的原则。本文梳理了五类常见的测试反模式,给出了违反示例、问题成因、修正方案以及检查规则,同时指出严格遵循TDD流程可以有效避免这些反模式。该文档作为编写测试、添加模拟时的参考指南使用。
核心原则与铁律
核心原则
测试代码实际行为,而非模拟的行为;模拟(Mock)仅用于隔离依赖,本身不是测试对象。严格遵循TDD可以预防绝大多数此类反模式。
测试铁律
- 绝对不要测试模拟行为
- 绝对不要在生产类中添加仅测试使用的方法
- 绝对不要在不理解依赖的情况下进行模拟
常见测试反模式
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预防反模式的原因
- 先写测试:迫使开发者思考真正要测试的内容
- 观察测试失败:确认测试测试的是真实行为,而非模拟
- 最小化实现:避免测试仅方法侵入生产代码
- 优先使用真实依赖:在模拟前就能明确测试真正需要的内容
快速参考
| 反模式 | 修正方案 |
|---|---|
| 断言模拟元素 | 测试真实组件,或不使用模拟 |
| 生产代码中的仅测试方法 | 移动到测试工具中 |
| 不理解依赖就模拟 | 先理解依赖,最小化模拟 |
| 不完整模拟 | 完整镜像真实API结构 |
| 测试作为事后补充 | 遵循TDD,先写测试再开发 |
| 过度复杂的模拟 | 考虑改用集成测试 |
危险信号
- 断言检查带
*-mock后缀的测试ID - 生产类中存在仅测试文件调用的方法
- 模拟设置占测试代码超过50%
- 移除模拟后测试就失败
- 无法解释为什么需要这个模拟
- 为了”安全”才做模拟
总结
模拟只是用于隔离依赖的工具,本身不是测试对象。如果TDD流程发现你在测试模拟行为,就说明已经出错了,修正方式是测试真实行为,或者重新考虑是否真的需要模拟。