我给一个祖传方法补单测,改到怀疑人生
9 月初,师兄让我给订单模块的几个核心方法补单元测试,说是要接入 SonarQube 看覆盖率。我挑了个"看起来最简单"的方法开工,然后就掉坑里了。
方法长这样:
@Service
public class OrderService {
public BigDecimal calcPayAmount(Long orderId) {
Order order = OrderDAO.getById(orderId); // 静态方法
if (order == null) {
throw new BizException("订单不存在");
}
UserLevel level = UserLevelUtil.getUserLevel(order.getUserId()); // 静态方法
BigDecimal amount = order.getTotalAmount();
if (level == UserLevel.VIP) {
amount = amount.multiply(new BigDecimal("0.9"));
} else if (level == UserLevel.SVIP) {
amount = amount.multiply(new BigDecimal("0.8"));
}
if (LocalDateTime.now().isAfter(PromotionUtil.DOUBLE_11_END)) { // 依赖当前时间
amount = amount.subtract(new BigDecimal("20"));
}
String couponNo = RedisUtil.get("coupon:" + order.getUserId()); // 静态方法
if (StringUtils.isNotBlank(couponNo)) {
amount = amount.subtract(new BigDecimal("10"));
}
return amount.compareTo(BigDecimal.ZERO) < 0 ? BigDecimal.ZERO : amount;
}
}
我盯着这段代码看了十分钟,一个问题都没测出来:OrderDAO、UserLevelUtil、RedisUtil 全是静态调用,Mockito 默认 mock 不了静态方法;LocalDateTime.now() 每次结果都不一样,断言写不出来。
先让它可测试
结论是:这段代码不是"难测",是"不可测"。可测性不是测试阶段能补出来的,是写代码时就决定的。我把它重构成了这样:
@Service
public class OrderService {
private final OrderMapper orderMapper;
private final UserLevelService userLevelService;
private final CouponService couponService;
private final Clock clock; // 时间可注入
public OrderService(OrderMapper orderMapper,
UserLevelService userLevelService,
CouponService couponService,
Clock clock) {
this.orderMapper = orderMapper;
this.userLevelService = userLevelService;
this.couponService = couponService;
this.clock = clock;
}
@Bean
public Clock clock() {
return Clock.systemDefaultZone();
}
public BigDecimal calcPayAmount(Long orderId) {
Order order = orderMapper.selectById(orderId);
if (order == null) {
throw new BizException("订单不存在");
}
BigDecimal amount = order.getTotalAmount();
amount = applyLevelDiscount(amount, userLevelService.getLevel(order.getUserId()));
amount = applyPromotion(amount, LocalDateTime.now(clock));
amount = applyCoupon(amount, order.getUserId());
return amount.max(BigDecimal.ZERO);
}
BigDecimal applyLevelDiscount(BigDecimal amount, UserLevel level) {
if (level == null) {
return amount;
}
switch (level) {
case VIP: return amount.multiply(new BigDecimal("0.9"));
case SVIP: return amount.multiply(new BigDecimal("0.8"));
default: return amount;
}
}
BigDecimal applyPromotion(BigDecimal amount, LocalDateTime now) {
if (now.isAfter(PromotionUtil.DOUBLE_11_END)) {
return amount.subtract(new BigDecimal("20"));
}
return amount;
}
// applyCoupon 省略
}
几个改动点:
- 静态调用改成注入。
OrderDAO.getById换成orderMapper.selectById,用构造器注入。这是最重要的一步。 - 时间用
Clock。JDK 8 就有这个类,测试时传Clock.fixed(...)就能固定时间。别再到处写LocalDateTime.now()了。 - 拆出小方法。
applyLevelDiscount、applyPromotion这些纯函数拆开之后,每个都能单独测,不用构造整个订单上下文。我把它们的可见性设成包级私有,测试类放同一个包下就能直接调,不用为了测试把方法改成 public。
mock 的边界在哪
改完之后单测就好写了。但新的问题是:什么该 mock,什么不该 mock?我一开始的做法是"把所有依赖全 mock 掉",测出来一片绿,却什么都没测到。
我现在的判断标准:
| 东西 | 处理 | 理由 |
|---|---|---|
| 外部 RPC / HTTP 调用 | mock | 慢、不稳定、需要对方环境 |
| 数据库、Redis、MQ | 单测用 mock,集成测试用真实实例 | 单测不该依赖存储 |
| 时间、随机数、UUID | mock / 固定 | 结果不可预测 |
| 被测类自己的依赖 Service | mock | 隔离被测逻辑 |
| 值对象、POJO、DTO、Builder | 不 mock | 没有行为,mock 没意义 |
| 被测类本身的部分方法 | 不 mock | 说明该拆类了 |
最后两条是我踩过的。有次我 mock 了被测类的另一个方法(用 spy),测试通过了,但重构时那个方法改了签名,测试还是绿的——因为 mock 记录的是方法名和参数,签名变了 Mockito 静默返回默认值。这种测试比没有测试更危险。
正确的 OrderService 测试长这样:
@RunWith(MockitoJUnitRunner.class)
public class OrderServiceTest {
@Mock
private OrderMapper orderMapper;
@Mock
private UserLevelService userLevelService;
@Mock
private CouponService couponService;
// 固定时间:2019-11-12 10:00
private final Clock clock = Clock.fixed(
LocalDateTime.of(2019, 11, 12, 10, 0)
.atZone(ZoneId.systemDefault()).toInstant(),
ZoneId.systemDefault());
private OrderService orderService;
@Before
public void setUp() {
orderService = new OrderService(orderMapper, userLevelService, couponService, clock);
}
@Test
public void svip用户_双十一后_有券_应付金额打八折再减30() {
Order order = new Order();
order.setTotalAmount(new BigDecimal("1000.00"));
when(orderMapper.selectById(1001L)).thenReturn(order);
when(userLevelService.getLevel(anyLong())).thenReturn(UserLevel.SVIP);
when(couponService.getUsableCoupon(anyLong())).thenReturn("C123");
BigDecimal result = orderService.calcPayAmount(1001L);
// 1000 * 0.8 - 20(大促) - 10(券) = 770
assertEquals(new BigDecimal("770.00"), result.stripTrailingZeros());
}
@Test(expected = BizException.class)
public void 订单不存在_抛业务异常() {
when(orderMapper.selectById(9999L)).thenReturn(null);
orderService.calcPayAmount(9999L);
}
@Test
public void 应付金额不会出现负数() {
Order order = new Order();
order.setTotalAmount(new BigDecimal("15.00"));
when(orderMapper.selectById(1002L)).thenReturn(order);
when(userLevelService.getLevel(anyLong())).thenReturn(UserLevel.NORMAL);
when(couponService.getUsableCoupon(anyLong())).thenReturn("C123");
// 15 - 20 - 10 = -15,应该被截成 0
assertEquals(BigDecimal.ZERO, orderService.calcPayAmount(1002L));
verify(orderMapper).selectById(1002L);
}
}
注意测试方法的命名,我用的是中文的"场景_预期结果"格式。中文方法名在 JUnit 4 里完全合法,读测试报告的时候比 testCalcPayAmount1 清楚太多。组里一开始有人反对,用了两周后大家都在这么写。
几个我常用的 Mockito 技巧
参数捕获。想验证"传给下游的对象里某个字段对不对",用 ArgumentCaptor:
@Test
public void 下单成功后_消息里必须带订单号和金额() {
orderService.createOrder(request);
ArgumentCaptor<OrderPaidMessage> captor =
ArgumentCaptor.forClass(OrderPaidMessage.class);
verify(mqProducer).send(captor.capture());
OrderPaidMessage msg = captor.getValue();
assertEquals("20190907000123", msg.getOrderNo());
assertEquals(new BigDecimal("99.00"), msg.getAmount());
}
验证调用次数和顺序。verify 默认要求"恰好一次":
verify(orderMapper, times(1)).insert(any(Order.class)); // 恰好 1 次
verify(orderMapper, never()).delete(anyLong()); // 从没调用过
verify(orderMapper, atLeastOnce()).updateById(any()); // 至少 1 次
verifyNoMoreInteractions(orderMapper); // 没有其他交互了
InOrder inOrder = inOrder(orderMapper, mqProducer);
inOrder.verify(orderMapper).insert(any());
inOrder.verify(mqProducer).send(any()); // 必须先落库再发消息
异常场景。异常分支最容易漏测,但它往往是线上出问题的地方:
@Test
public void 库存扣减失败_订单状态回滚为已取消() {
when(orderMapper.selectById(1003L)).thenReturn(buildOrder());
doThrow(new RuntimeException("库存服务超时"))
.when(stockService).deduct(anyLong(), anyInt());
try {
orderService.pay(1003L);
fail("应该抛异常");
} catch (RuntimeException e) {
assertEquals("库存服务超时", e.getMessage());
}
// 关键:验证状态确实回滚了
ArgumentCaptor<Order> captor = ArgumentCaptor.forClass(Order.class);
verify(orderMapper, atLeastOnce()).updateById(captor.capture());
assertEquals(OrderStatus.CANCELED, captor.getValue().getStatus());
}
注意 doThrow 的写法和无返回值方法的 stub 必须用 doXxx().when() 形式,不能写 when(mock.voidMethod()).thenThrow(),编译都过不了。
不要写这些测试
补覆盖率那阵子我看到组里有这种测试:
@Test
public void testGetterSetter() {
Order order = new Order();
order.setOrderNo("123");
order.setAmount(new BigDecimal("10"));
assertEquals("123", order.getOrderNo());
assertEquals(new BigDecimal("10"), order.getAmount());
}
@Test
public void testMapper() {
// 所谓的"测试",只是把 SQL 又执行了一遍
Order order = orderMapper.selectById(1L);
assertNotNull(order);
}
第一个测试的是 Lombok 生成的代码,第二个测试的是 MyBatis 和数据库。它们唯一的作用是让覆盖率数字好看,实际价值是零,还要花时间维护。
我给自己定的规矩:没有分支、没有计算、没有外部交互的代码,不写单测。要测的是逻辑,不是代码行数。具体到我们项目,重点测三类:金额计算、状态机流转、异常处理分支。这三块是出过线上事故的地方。
还有个容易忽略的:别在单测里连真实数据库。那个 testMapper 用 SpringBootTest 跑一次要 40 秒(启动 Spring 容器),全项目 300 个测试跑一遍 8 分钟,没人愿意跑。我们现在区分开了:纯单测用 MockitoJUnitRunner,不启动容器,单个测试类 200 毫秒以内;需要真实存储的用 @SpringBootTest + H2 内存库,单独一个目录,只在 CI 上跑。
数据
折腾了三周,订单模块的覆盖率从 12% 到 67%。过程中发现 4 个真实的 bug:
- SVIP 折扣和大促优惠叠加时,金额可能为负(原代码没处理,我加了
max(BigDecimal.ZERO)) - 优惠券过期后仍然被使用,因为只判断了非空没判断有效期
- 库存扣减失败时订单状态没回滚,卡在"支付中"
- 金额比较用了
equals而不是compareTo,10.0和10.00判等失败
这 4 个 bug 里,前 3 个都不是我在写测试时"想出来"的,是为了让代码可测而重构时顺手发现的。这大概就是单测最大的价值:它逼着你把代码写得能拆开、能独立验证。
小结
- 可测性来自设计。静态方法、
new出来的依赖、直接调用LocalDateTime.now(),这三样是单测的头号障碍。用构造器注入 +Clock就能解决大部分。 - mock 的边界:外部依赖、时间随机数、被测类的依赖 Service 要 mock;值对象、DTO 不要 mock;被测类自己的方法不要用 spy mock,签名改了测试还绿,比没测试更危险。
- 断言要针对结果,别针对调用。
verify用来验证"副作用有没有发生"(比如有没有发消息、状态有没有回滚)。 - 异常分支和边界条件(金额为负、空集合、超长字符串)是最该测的地方,正常路径反而不容易出错。
- 不测 getter、不测框架、不连真实数据库写单测。用
MockitoJUnitRunner而不是SpringBootTest,跑得快才有人跑。