name: testing-anti-patterns description: 在编写或修改测试、添加模拟或诱惑向生产代码添加仅测试方法时使用 - 防止测试模拟行为、生产代码被仅测试方法污染以及不理解依赖关系就进行模拟
测试反模式
概述
测试必须验证真实行为,而不是模拟行为。模拟是隔离的手段,而不是被测试的东西。
核心原则: 测试代码做什么,而不是模拟做什么。
遵循严格的TDD可以防止这些反模式。
铁律
1. 绝不测试模拟行为
2. 绝不向生产类添加仅测试方法
3. 绝不不理解依赖关系就进行模拟
反模式1:测试模拟行为
违规示例:
// ❌ 错误:测试模拟是否存在
test('渲染侧边栏', () => {
render(<Page />);
expect(screen.getByTestId('sidebar-mock')).toBeInTheDocument();
});
为什么这是错误的:
- 你在验证模拟是否工作,而不是组件是否工作
- 当模拟存在时测试通过,不存在时失败
- 没有告诉你真实行为的信息
你的伙伴的纠正: “我们是在测试模拟的行为吗?”
修复方法:
// ✅ 正确:测试真实组件或不模拟它
test('渲染侧边栏', () => {
render(<Page />); // 不模拟侧边栏
expect(screen.getByRole('navigation')).toBeInTheDocument();
});
// 或者如果必须为隔离而模拟侧边栏:
// 不要断言模拟 - 测试有侧边栏时Page的行为
门控函数
在对任何模拟元素进行断言之前:
问:“我是在测试真实组件行为还是仅仅测试模拟存在?”
如果测试模拟存在:
停止 - 删除断言或取消模拟组件
测试真实行为代替
反模式2:生产代码中的仅测试方法
违规示例:
// ❌ 错误:destroy()仅在测试中使用
class Session {
async destroy() { // 看起来像生产API!
await this._workspaceManager?.destroyWorkspace(this.id);
// ... 清理
}
}
// 在测试中
afterEach(() => session.destroy());
为什么这是错误的:
- 生产类被仅测试代码污染
- 如果在生产中意外调用会很危险
- 违反YAGNI和关注点分离
- 混淆对象生命周期与实体生命周期
修复方法:
// ✅ 正确:测试工具处理测试清理
// Session没有destroy() - 在生产中它是无状态的
// 在test-utils/
export async function cleanupSession(session: Session) {
const workspace = session.getWorkspaceInfo();
if (workspace) {
await workspaceManager.destroyWorkspace(workspace.id);
}
}
// 在测试中
afterEach(() => cleanupSession(session));
门控函数
在向生产类添加任何方法之前:
问:“这仅在测试中使用吗?”
如果是:
停止 - 不要添加它
将其放在测试工具中代替
问:“这个类拥有这个资源的生命周期吗?”
如果否:
停止 - 这个方法放在错误的类中
反模式3:不理解就模拟
违规示例:
// ❌ 错误:模拟破坏了测试逻辑
test('检测重复服务器', () => {
// 模拟防止了测试依赖的配置写入!
vi.mock('ToolCatalog', () => ({
discoverAndCacheTools: vi.fn().mockResolvedValue(undefined)
}));
await addServer(config);
await addServer(config); // 应该抛出异常 - 但不会!
});
为什么这是错误的:
- 模拟的方法有测试依赖的副作用(写入配置)
- 过度模拟以“安全”破坏了实际行为
- 测试因错误原因通过或神秘失败
修复方法:
// ✅ 正确:在正确级别模拟
test('检测重复服务器', () => {
// 模拟慢速部分,保留测试需要的行为
vi.mock('MCPServerManager'); // 只模拟慢速服务器启动
await addServer(config); // 配置写入
await addServer(config); // 检测到重复 ✓
});
门控函数
在模拟任何方法之前:
停止 - 先不要模拟
1. 问:“真实方法有什么副作用?”
2. 问:“这个测试依赖于任何这些副作用吗?”
3. 问:“我完全理解这个测试需要什么吗?”
如果依赖于副作用:
在更低级别模拟(实际慢速/外部操作)
或使用保留必要行为的测试替身
不是测试依赖的高级方法
如果不确定测试依赖什么:
先用真实实现运行测试
观察实际需要发生什么
然后在正确级别添加最小模拟
红旗:
- “我会模拟这个以安全”
- “这可能很慢,最好模拟它”
- 不理解依赖链就模拟
反模式4:不完整的模拟
违规示例:
// ❌ 错误:部分模拟 - 只包括你认为需要的字段
const mockResponse = {
status: 'success',
data: { userId: '123', name: 'Alice' }
// 缺失:下游代码使用的元数据
};
// 稍后:当代码访问response.metadata.requestId时中断
为什么这是错误的:
- 部分模拟隐藏结构假设 - 你只模拟了你知道的字段
- 下游代码可能依赖于你未包括的字段 - 静默失败
- 测试通过但集成失败 - 模拟不完整,真实API完整
- 虚假信心 - 测试没有证明任何关于真实行为的信息
铁则: 模拟完整的真实数据结构,而不仅仅是你立即测试使用的字段。
修复方法:
// ✅ 正确:镜像真实API的完整性
const mockResponse = {
status: 'success',
data: { userId: '123', name: 'Alice' },
metadata: { requestId: 'req-789', timestamp: 1234567890 }
// 真实API返回的所有字段
};
门控函数
在创建模拟响应之前:
检查:“真实API响应包含什么字段?”
行动:
1. 检查文档/示例中的实际API响应
2. 包括系统可能在下游消耗的所有字段
3. 验证模拟完全匹配真实响应模式
关键:
如果你创建一个模拟,你必须理解整个结构
当代码依赖于省略的字段时,部分模拟静默失败
如果不确定:包括所有文档化字段
反模式5:集成测试作为事后想法
违规示例:
✅ 实现完成
❌ 没有编写测试
“准备测试”
为什么这是错误的:
- 测试是实施的一部分,不是可选的后续
- TDD会捕获这个问题
- 没有测试不能声称完成
修复方法:
TDD周期:
1. 编写失败测试
2. 实施以通过
3. 重构
4. 然后声称完成
当模拟变得太复杂时
警告信号:
- 模拟设置比测试逻辑长
- 模拟所有以使测试通过
- 模拟缺少真实组件有的方法
- 模拟更改时测试中断
你的伙伴的问题: “我们需要在这里使用模拟吗?”
考虑: 使用真实组件的集成测试通常比复杂模拟更简单
TDD防止这些反模式
为什么TDD有帮助:
- 先写测试 → 迫使你思考你实际在测试什么
- 看它失败 → 确认测试测试真实行为,而不是模拟
- 最小实现 → 防止仅测试方法渗入
- 真实依赖 → 在模拟之前,你看到测试实际需要什么
如果你在测试模拟行为,你违反了TDD - 你添加了模拟而没有先用真实代码看测试失败。
快速参考
| 反模式 | 修复 |
|---|---|
| 断言模拟元素 | 测试真实组件或取消模拟它 |
| 生产代码中的仅测试方法 | 移动到测试工具 |
| 不理解就模拟 | 先理解依赖,最小模拟 |
| 不完整的模拟 | 完全镜像真实API |
| 测试作为事后想法 | TDD - 测试先写 |
| 过度复杂的模拟 | 考虑集成测试 |
红旗
- 断言检查
*-mock测试ID - 方法仅在测试文件中调用
- 模拟设置占测试的>50%
- 当你移除模拟时测试失败
- 不能解释为什么需要模拟
- 模拟“只是为了安全”
底线
模拟是隔离的工具,不是要测试的东西。
如果TDD揭示你在测试模拟行为,你就错了。
修复:测试真实行为或质疑为什么你一开始在模拟。