单元测试的五个反模式

举报
yd_251777447 发表于 2026/11/05 21:04:01 2026/11/05
【摘要】 写测试容易,写好测试难。很多项目的测试覆盖率很高,但真正出问题时一个都拦不住——因为测的是"代码长什么样",不是"代码该做什么"。 这篇列五个常见的反模式,都是我自己踩过或者在评审里反复见到的。 一、把实现细节测进去 最典型的写法: 这个测试断言的是"调用了某个函数",而不是"返回了正确的用户"。 问题是:只要重构——比如把 findById 换成 findOne ,或者加一层

写测试容易,写好测试难。很多项目的测试覆盖率很高,但真正出问题时一个都拦不住——因为测的是"代码长什么样",不是"代码该做什么"。

这篇列五个常见的反模式,都是我自己踩过或者在评审里反复见到的。

一、把实现细节测进去

最典型的写法:

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 上失败之后重跑又不挂——最消耗人的一类问题。

根因是共享了可变状态。解决办法有两种:

  1. 每个测试自己造数据(推荐):用 beforeEach 建、afterEach 清,测试之间零依赖。
  2. 用工厂函数:写一个 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 语法,其他框架的思路相同。

【声明】本内容来自华为云开发者社区博主,不代表华为云及华为云开发者社区的观点和立场。转载时必须标注文章的来源(华为云社区)、文章链接、文章作者等基本信息,否则作者和本社区有权追究责任。如果您发现本社区中有涉嫌抄袭的内容,欢迎发送邮件进行举报,并提供相关证据,一经查实,本社区将立刻删除涉嫌侵权内容,举报邮箱: cloudbbs@huaweicloud.com
  • 点赞
  • 收藏
  • 关注作者

评论(0)

0/1000
抱歉,系统识别当前为高风险访问,暂不支持该操作

全部回复

上滑加载中

设置昵称

在此一键设置昵称,即可参与社区互动!

*长度不超过10个汉字或20个英文字符,设置后3个月内不可修改。

*长度不超过10个汉字或20个英文字符,设置后3个月内不可修改。