Administrator
发布于 2019-09-07 / 3195 阅读
36

单元测试怎么写才有用?JUnit + Mockito 实践

我给一个祖传方法补单测,改到怀疑人生

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;
    }
}

我盯着这段代码看了十分钟,一个问题都没测出来:OrderDAOUserLevelUtilRedisUtil 全是静态调用,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() 了。
  • 拆出小方法applyLevelDiscountapplyPromotion 这些纯函数拆开之后,每个都能单独测,不用构造整个订单上下文。我把它们的可见性设成包级私有,测试类放同一个包下就能直接调,不用为了测试把方法改成 public。

mock 的边界在哪

改完之后单测就好写了。但新的问题是:什么该 mock,什么不该 mock?我一开始的做法是"把所有依赖全 mock 掉",测出来一片绿,却什么都没测到。

我现在的判断标准:

东西处理理由
外部 RPC / HTTP 调用mock慢、不稳定、需要对方环境
数据库、Redis、MQ单测用 mock,集成测试用真实实例单测不该依赖存储
时间、随机数、UUIDmock / 固定结果不可预测
被测类自己的依赖 Servicemock隔离被测逻辑
值对象、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 而不是 compareTo10.010.00 判等失败

这 4 个 bug 里,前 3 个都不是我在写测试时"想出来"的,是为了让代码可测而重构时顺手发现的。这大概就是单测最大的价值:它逼着你把代码写得能拆开、能独立验证。

小结

  • 可测性来自设计。静态方法、new 出来的依赖、直接调用 LocalDateTime.now(),这三样是单测的头号障碍。用构造器注入 + Clock 就能解决大部分。
  • mock 的边界:外部依赖、时间随机数、被测类的依赖 Service 要 mock;值对象、DTO 不要 mock;被测类自己的方法不要用 spy mock,签名改了测试还绿,比没测试更危险。
  • 断言要针对结果,别针对调用。verify 用来验证"副作用有没有发生"(比如有没有发消息、状态有没有回滚)。
  • 异常分支和边界条件(金额为负、空集合、超长字符串)是最该测的地方,正常路径反而不容易出错。
  • 不测 getter、不测框架、不连真实数据库写单测。用 MockitoJUnitRunner 而不是 SpringBootTest,跑得快才有人跑。

参考