单元测试的五个反模式
写测试容易,写好测试难。很多项目的测试覆盖率很高,但真正出问题时一个都拦不住——因为测的是"代码长什么样",不是"代码该做什么"。
这篇列五个常见的反模式,都是我自己踩过或者在评审里反复见到的。
一、把实现细节测进去
最典型的写法:
test('getUser 调用 repository.findById', async () => {
const repo = { findById: jest.fn().mockResolvedValue({ id: 1, name: 'a' }) };
const svc = new UserService(repo);
await svc.getUser(1);
expect(repo.findById).toHaveBeenCalledWith(1);
});
这个测试断言的是"调用了某个函数",而不是"返回了正确的用户"。
问题是:只要重构——比如把 findById 换成 findOne,或者加一层缓存——测试就红了,但功能完全没变。于是你得改测试,改着改着就开始怀疑测试的意义。
更糟的是,这种测试拦不住真正的 bug。假设 getUser 忘了处理 null,测试照样绿,因为它只验证了"调用发生过"。
正确写法是断言输出:
test('getUser 返回用户信息', async () => {
const repo = { findById: async () => ({ id: 1, name: 'a' }) };
const svc = new UserService(repo);
const user = await svc.getUser(1);
expect(user).toEqual({ id: 1, name: 'a' });
});
test('getUser 用户不存在时返回 null', async () => {
const repo = { findById: async () => null };
const svc = new UserService(repo);
await expect(svc.getUser(1)).resolves.toBeNull();
});
判断标准:如果重构内部实现(不改行为)会导致测试失败,那这个测试写错了。
二、一个测试断言太多件事
test('创建订单', async () => {
const order = await createOrder({ userId: 1, items: [...] });
expect(order.id).toBeDefined();
expect(order.status).toBe('pending');
expect(order.total).toBe(199);
expect(order.items).toHaveLength(2);
expect(mockDb.insert).toHaveBeenCalled();
expect(mockEvent.emit).toHaveBeenCalledWith('order.created');
});
看起来覆盖很全,实际上很难维护:一旦失败,你得从头看是哪一条断的;而且前面断言失败时,后面的根本没跑。
更好的做法是拆成多个测试,每个只关心一件事:
test('创建订单时生成 id 和初始状态', ...);
test('创建订单时计算总价', ...);
test('创建订单后发出事件', ...);
拆开之后还有个好处:失败信息本身就能说明问题——"计算总价"这条红了,不用往下看。
三、测试之间互相依赖
let userId;
test('创建用户', async () => {
const u = await createUser({ name: 'a' });
userId = u.id;
});
test('查询用户', async () => {
const u = await getUser(userId); // 依赖上一个测试
expect(u.name).toBe('a');
});
这种写法在单跑时会挂,在并行执行时会随机挂,在 CI 上失败之后重跑又不挂——最消耗人的一类问题。
根因是共享了可变状态。解决办法有两种:
- 每个测试自己造数据(推荐):用
beforeEach建、afterEach清,测试之间零依赖。 - 用工厂函数:写一个
makeUser()返回新对象,需要什么造什么。
判断标准:单独跑任意一个测试,都应该通过。
四、用 sleep 等异步
test('异步任务完成后状态变为 done', async () => {
startTask();
await new Promise(r => setTimeout(r, 3000)); // 等 3 秒
expect(getStatus()).toBe('done');
});
这个测试有三个问题:
- 慢(每个用例多 3 秒,几百个用例就是十几分钟)
- 不稳(机器慢一点就挂)
- 掩盖真实耗时(如果任务实际要 10 秒,这里却"通过"了)
正确做法是等信号而不是等时间:
test('异步任务完成后状态变为 done', async () => {
const done = waitForEvent('task.done'); // 返回 Promise
startTask();
await done; // 事件到了就继续,最多等超时
expect(getStatus()).toBe('done');
});
如果代码里没有可等的事件,那就说明设计上缺少一个可观测点——这本身值得改。
五、为了覆盖率而写测试
覆盖率是个诊断指标,不是目标。
一旦变成 KPI,就会出现这类测试:
test('覆盖率', () => {
const result = someFunction();
expect(result).toBeDefined(); // 只要有返回值就算测过
});
它让覆盖率从 60% 涨到 85%,但一行逻辑都没验证。
真正有价值的指标是:当你故意改坏一行代码时,有多少测试会红。 这叫变异测试(mutation testing),比覆盖率诚实得多。
实践中更实用的判断是问自己三个问题:
- 这段代码出错的后果有多严重?(支付、权限、数据迁移 → 必须有测试)
- 它多久改一次?(频繁改动的地方值得测)
- 它上次出 bug 是什么时候?(出过 bug 的地方最容易再出)
按这三点排优先级,比盯着覆盖率数字有用。
六、小结
五个反模式,其实指向同一件事:
- 测行为,不测实现
- 一个测试一件事
- 测试之间零依赖
- 等信号,不等时间
- 覆盖率是结果,不是目标
写测试的时候有个简单的自检方法:问自己"如果这段代码写错了,哪个测试会红?" 如果答不上来,那这个测试大概率只是在陪跑。
文中示例基于 Jest 语法,其他框架的思路相同。
- 点赞
- 收藏
- 关注作者
评论(0)