SpringBoot中如何测试调用多个内部已测方法的业务方法
问题说明
需要对购物车全选业务方法cartSelectAll做单元测试,目标方法逻辑如下:
public CartPriceResponse cartSelectAll(final String check, final User user) { List<Cart> carts = cartRepository.findByUser(user); List<CartPricePerSellerResponse> cartPricePerSellerResponses = new ArrayList<>(); for (Cart cart : carts) { cart.setIsSelected(check.equals("true")); cartPricePerSellerResponses.add(cartPricePerSeller(user, cart.getItem().getSeller())); } final CartPriceTotalResponse cartPriceTotalResponse = cartPriceTotal(user); return CartPriceResponse.builder() .cartPricePerSellerResponses(cartPricePerSellerResponses) .cartPriceTotalResponse(cartPriceTotalResponse) .build(); }
方法核心逻辑:
- 入参
check值为字符串"true"时,将当前用户所有购物车条目的选中状态置为true,否则置为false - 遍历购物车条目时调用
cartPricePerSeller计算单卖家维度的购物车价格 - 最后调用
cartPriceTotal计算全量购物车总价,组装为CartPriceResponse返回
当前测试前提:已经完成cartPricePerSeller、cartPriceTotal两个方法的独立单元测试,不需要在cartSelectAll测试中重复验证二者内部逻辑,需要明确内部方法调用的Mock/Spy方案。同时现有代码存在设计缺陷,需要可落地的重构指引。
涉及的关联方法代码如下:
public CartPricePerSellerResponse cartPricePerSeller(User user, Seller seller) { final List<Cart> carts = cartRepository.findByUserAndIsSelectedTrue(user); final List<Cart> cartList = carts.stream().filter(cart -> cart.getItem().getSeller().equals(seller)).collect(Collectors.toList()); long cartTotalPricePerSeller = 0; int cartTotalQuantityPerSeller = 0; for (Cart cart : cartList) { cartTotalPricePerSeller += cart.getItemOption().getItemPrice().getPriceNow() * cart.getQuantity(); cartTotalQuantityPerSeller += cart.getQuantity(); } return CartPricePerSellerResponse.builder() .sellerId(seller.getId()) .itemTotalPricePerSeller(cartTotalPricePerSeller) .itemTotalQuantityPerSeller(cartTotalQuantityPerSeller) .itemShippingFeePerSeller(shippingFeeCheck(seller.getId(), cartTotalPricePerSeller)) .build(); } public CartPriceTotalResponse cartPriceTotal(User user) { final List<Cart> carts = cartRepository.findByUserAndIsSelectedTrue(user); long cartTotalPrice = 0; int cartTotalQuantity = 0; int cartTotalShippingFee = 0; final List<Seller> sellers = carts.stream().map(Cart::getItem).map(Item::getSeller).distinct().collect(Collectors.toList()); for (Seller seller : sellers) { final CartPricePerSellerResponse cartPricePerSellerResponse = this.cartPricePerSeller(user, seller); cartTotalPrice += cartPricePerSellerResponse.getItemTotalPricePerSeller(); cartTotalQuantity += cartPricePerSellerResponse.getItemTotalQuantityPerSeller(); cartTotalShippingFee += cartPricePerSellerResponse.getItemShippingFeePerSeller(); } return CartPriceTotalResponse.builder() .cartTotalPrice(cartTotalPrice) .cartTotalQuantity(cartTotalQuantity) .cartTotalShippingFee(cartTotalShippingFee) .build(); }
单元测试Spy/Mock实现方案
三个方法都在同一个类中,不能直接用普通Mock注入,需要用@Spy注解部分模拟被测类实例,只打桩内部依赖的方法,保留cartSelectAll本身的真实逻辑:
- 用
@Mock模拟cartRepository这类外部依赖 - 用
@InjectMocks+@Spy标注被测服务实例,保证同类内部方法调用可以被打桩 - 固定
cartRepository.findByUser的返回值为提前构造的测试购物车数据 - 对
cartPricePerSeller、cartPriceTotal方法做打桩,返回提前构造的Mock响应,不需要执行方法真实逻辑 - 核心验证点:
- 传入
check="true"时,所有购物车条目的isSelected属性都被设置为true;传入其他值时,所有isSelected属性被设置为false - 遍历每个购物车条目所属卖家时,都调用了一次
cartPricePerSeller,入参为当前用户和对应卖家 - 最后调用了一次
cartPriceTotal,入参为当前用户 - 返回结果正确组装了两个方法打桩返回的响应数据
- 传入
基于Mockito的示例测试代码片段:
@ExtendWith(MockitoExtension.class) class CartServiceTest { @Mock private CartRepository cartRepository; @Spy @InjectMocks private CartService cartService; @Test void cartSelectAll_shouldSetSelectedAndAssembleResponse_whenCheckIsTrue() { // 构造测试数据 User testUser = new User(1L); Seller seller1 = new Seller(1L); Seller seller2 = new Seller(2L); Cart cart1 = Cart.builder().user(testUser).item(Item.builder().seller(seller1).build()).build(); Cart cart2 = Cart.builder().user(testUser).item(Item.builder().seller(seller2).build()).build(); List<Cart> userCarts = List.of(cart1, cart2); // 构造Mock返回 CartPricePerSellerResponse sellerResp1 = CartPricePerSellerResponse.builder().sellerId(1L).build(); CartPricePerSellerResponse sellerResp2 = CartPricePerSellerResponse.builder().sellerId(2L).build(); CartPriceTotalResponse totalResp = CartPriceTotalResponse.builder().cartTotalPrice(1000L).build(); // 打桩 when(cartRepository.findByUser(testUser)).thenReturn(userCarts); // 打桩同类内部方法,跳过真实逻辑 doReturn(sellerResp1).when(cartService).cartPricePerSeller(testUser, seller1); doReturn(sellerResp2).when(cartService).cartPricePerSeller(testUser, seller2); doReturn(totalResp).when(cartService).cartPriceTotal(testUser); // 执行方法 CartPriceResponse result = cartService.cartSelectAll("true", testUser); // 验证逻辑 assertTrue(cart1.getIsSelected()); assertTrue(cart2.getIsSelected()); verify(cartService, times(1)).cartPricePerSeller(testUser, seller1); verify(cartService, times(1)).cartPricePerSeller(testUser, seller2); verify(cartService, times(1)).cartPriceTotal(testUser); assertEquals(2, result.getCartPricePerSellerResponses().size()); assertEquals(totalResp, result.getCartPriceTotalResponse()); } }
代码重构方案
现有代码的核心问题是职责耦合、重复查询、逻辑分层不清:
- 价格计算逻辑和购物车状态修改逻辑耦合在同一个服务类中
cartPricePerSeller每次调用都会全量查询当前用户所有选中的购物车条目,循环调用时会产生N次重复数据库查询- 运费计算逻辑
shippingFeeCheck直接硬编码在方法内部,后续运费规则调整需要修改核心价格计算逻辑 cartPriceTotal内部循环调用cartPricePerSeller,如果上层方法也循环调用cartPricePerSeller会产生重复计算
重构按分层拆分职责即可:
- 拆分独立的价格计算组件:新建
CartPriceCalculator类,将cartPricePerSeller、cartPriceTotal、shippingFeeCheck方法迁移到这个独立组件中,和购物车状态修改的逻辑解耦。迁移后cartSelectAll中直接注入CartPriceCalculator调用对应方法,此时单元测试可以直接Mock计算器组件,不需要再用Spy处理同类调用 - 消除重复数据库查询:调整价格计算方法入参,不要在方法内部查询购物车数据,上层方法先一次性查询出需要的购物车列表,作为入参传给价格计算方法,避免循环内重复查库
- 拆分运费计算规则:新建
ShippingFeeStrategy接口,将shippingFeeCheck的逻辑抽象为策略实现,后续新增运费规则只需要新增策略实现类,不需要修改购物车价格计算核心逻辑 - 优化全选方法逻辑:重构后
cartSelectAll逻辑简化为:查询用户所有购物车→批量更新选中状态→一次性把购物车列表传给价格计算器,拿到卖家维度价格和总价→组装返回,全程只有1次购物车查询,没有重复计算。
内容的提问来源于stack exchange,提问作者user275616
相关产品推荐
相关产品推荐

