name: testing-anti-patterns description: 在编写或修改测试、添加模拟或试图在生成代码中添加仅用于测试的方法时使用 - 防止测试模拟行为、用测试专用方法污染生成代码以及不理解依赖关系就进行模拟
测试反模式
概述
测试必须验证真实行为,而不是模拟行为。模拟是用来隔离的手段,而不是被测试的东西。
核心原则: 测试代码做什么,而不是模拟做什么。
遵循严格的TDD可以防止这些反模式。
铁律
1. 永远不要测试模拟行为
2. 永远不要向生成类添加仅用于测试的方法
3. 永远不要在不理解依赖关系的情况下进行模拟
反模式1:测试模拟行为
违反:
// ❌ 错误:测试模拟的存在
test('renders sidebar', () => {
render(<Page />);
expect(screen.getByTestId('sidebar-mock')).toBeInTheDocument();
});
为什么这是错误的:
- 你在验证模拟是否工作,而不是组件是否工作
- 测试在模拟存在时通过,不存在时失败
- 没有告诉你关于真实行为的任何信息
你的人类伙伴的纠正: “我们是在测试模拟的行为吗?”
修复:
// ✅ 正确:测试真实组件或不模拟它
test('renders sidebar', () => {
render(<Page />); // 不模拟侧边栏
expect(screen.getByRole('navigation')).toBeInTheDocument();
});
// 或者如果必须模拟侧边栏以隔离:
// 不要断言模拟 - 测试侧边栏存在时页面的行为
关卡函数
在断言任何模拟元素之前:
问:“我是在测试真实组件行为还是仅仅模拟的存在?”
如果测试模拟存在:
停止 - 删除断言或不模拟组件
改为测试真实行为
反模式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('detects duplicate server', () => {
// 模拟阻止了测试依赖的配置写入!
vi.mock('ToolCatalog', () => ({
discoverAndCacheTools: vi.fn().mockResolvedValue(undefined)
}));
await addServer(config);
await addServer(config); // 应该抛出 - 但不会!
});
为什么这是错误的:
- 模拟的方法有测试依赖的副作用(写入配置)
- 过度模拟以“安全”破坏了实际行为
- 测试因错误原因通过或神秘失败
修复:
// ✅ 正确:在正确的层级模拟
test('detects duplicate server', () => {
// 模拟慢的部分,保留测试需要的行为
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揭示你在测试模拟行为,你就走错了。
修复:测试真实行为或质疑为什么一开始要模拟。