一種極簡單的SpringBoot單元測試方法| 京東零售技術團隊

前言

本文主要提供了一種單元測試方法,力求0基礎人員可以從本文中受到啓發,可以搭建一套好用的單元測試環境,並能切實的提高交付代碼的質量。極簡體現在除了POM依賴和單元測試類之外,其他什麼都不需要引入,只需要一個本地能啓動的springboot項目。

目錄

1.POM依賴

2.單元測試類示例及註解釋義

3.單元測試經驗總結

一、POM依賴

Springboot版本: 2.6.6

<dependency>
  <groupId>org.springframework.boot</groupId>
  <artifactId>spring-boot-starter-test</artifactId>
  <scope>test</scope>
</dependency>
<dependency>
  <groupId>org.mockito</groupId>
  <artifactId>mockito-core</artifactId>
  <version>3.12.4</version>
</dependency>

二、單元測試類示例

主要有兩種

第一種,偏集成測試

需要啓動項目,需要連接數據庫、RPC註冊中心等

主要註解:@SpringBootTest + @RunWith(SpringRunner.class) + @Transactional + @Resource + @SpyBean + @Test

•@SpringBootTest + @RunWith(SpringRunner.class) 啓動了一套springboot的測試環境;

•@Transactional 對於一些修改數據庫的操作,會執行回滾,能測試執行sql,但是又不會真正的修改測試庫的數據;

•@Resource 主要引入被測試的類

•@SpyBean springboot環境下mock依賴的bean,可以搭配Mockito.doAnswer(…).when(xxServiceImpl).xxMethod(any())mock特定方法的返回值;

•@Test 標識一個測試方法

TIP:對於打樁有這幾個註解@Mock @Spy @MockBean @SpyBean,每一個都有其對應的搭配,簡單說@Mock和@Spy要搭配@InjectMocks去使用,@MockBean和@SpyBean搭配@SpringBootTest + @RunWith(SpringRunner.class)使用,@InjectMocks不用啓動應用,它啓動了一個完全隔離的測試環境,無法使用spring提供的所有bean,所有的依賴都需要被mock

上代碼:

/**
 * @author jiangbo8
 * @since 2024/4/24 9:52
 */
@Transactional
@SpringBootTest
@RunWith(SpringRunner.class)
public class SalesAmountPlanControllerAppTest {
    @Resource
    private SalesAmountPlanController salesAmountPlanController;
    @SpyBean
    private ISaleAmountHourHistoryService saleAmountHourHistoryServiceImpl;
    @SpyBean
    private ISaleAmountHourForecastService saleAmountHourForecastServiceImpl;
    @SpyBean
    private ISaleAmountHourPlanService saleAmountHourPlanServiceImpl;

    @Test
    public void testGraph1()  {
        // 不寫mock就走實際調用

        SalesAmountDTO dto = new SalesAmountDTO();
        dto.setDeptId1List(Lists.newArrayList(35));
        dto.setDeptId2List(Lists.newArrayList(235));
        dto.setDeptId3List(Lists.newArrayList(100));
        dto.setYoyType(YoyTypeEnum.SOLAR.getCode());
        dto.setShowWeek(true);
        dto.setStartYm("2024-01");
        dto.setEndYm("2024-10");
        dto.setTimeDim(GraphTimeDimensionEnum.MONTH.getCode());
        dto.setDataType(SalesAmountDataTypeEnum.AMOUNT.getCode());
        Result<ChartData> result = salesAmountPlanController.graph(dto);
        System.out.println(JSON.toJSONString(result));
        Assert.assertNotNull(result);
    }

    @Test
    public void testGraph11()  {
        // mock就走mock
        Mockito.doAnswer(this::mockSaleAmountHourHistoryListQuery).when(saleAmountHourHistoryServiceImpl).listBySaleAmountQueryBo(any());
        Mockito.doAnswer(this::mockSaleAmountHourPlansListQuery).when(saleAmountHourPlanServiceImpl).listBySaleAmountQueryBo(any());
        Mockito.doAnswer(this::mockSaleAmountHourForecastListQuery).when(saleAmountHourForecastServiceImpl).listBySaleAmountQueryBo(any());

        SalesAmountDTO dto = new SalesAmountDTO();
        dto.setDeptId1List(Lists.newArrayList(111));
        dto.setDeptId2List(Lists.newArrayList(222));
        dto.setDeptId3List(Lists.newArrayList(333));
        dto.setYoyType(YoyTypeEnum.SOLAR.getCode());
        dto.setShowWeek(true);
        dto.setStartYm("2024-01");
        dto.setEndYm("2024-10");
        dto.setTimeDim(GraphTimeDimensionEnum.MONTH.getCode());
        dto.setDataType(SalesAmountDataTypeEnum.AMOUNT.getCode());
        Result<ChartData> result = salesAmountPlanController.graph(dto);
        System.out.println(JSON.toJSONString(result));
        Assert.assertNotNull(result);
    }
    
	private List<SaleAmountHourHistory> mockSaleAmountHourHistoryListQuery(org.mockito.invocation.InvocationOnMock s) {
        SaleAmountQueryBo queryBo = s.getArgument(0);
        if (queryBo.getGroupBy().contains("ymd")) {
            List<SaleAmountHourHistory> historyList = Lists.newArrayList();
            List<String> ymdList = DateUtil.rangeWithDay(DateUtil.parseFirstDayLocalDate(queryBo.getStartYm()), DateUtil.parseLastDayLocalDate(queryBo.getStartYm()));
            for (String ymd : ymdList) {
                SaleAmountHourHistory history = new SaleAmountHourHistory();
                history.setYear(Integer.parseInt(queryBo.getStartYm().split("-")[0]));
                history.setMonth(Integer.parseInt(queryBo.getStartYm().split("-")[1]));
                history.setYm(queryBo.getStartYm());
                history.setYmd(DateUtil.parseLocalDateByYmd(ymd));

                history.setAmount(new BigDecimal("1000"));
                history.setAmountSp(new BigDecimal("2000"));
                history.setAmountLunarSp(new BigDecimal("3000"));

                history.setSales(new BigDecimal("100"));
                history.setSalesSp(new BigDecimal("200"));
                history.setSalesLunarSp(new BigDecimal("300"));

                history.setCostPrice(new BigDecimal("100"));
                history.setCostPriceSp(new BigDecimal("100"));
                history.setCostPriceLunarSp(new BigDecimal("100"));
                historyList.add(history);
            }

            return historyList;
        }

        List<String> ymList = DateUtil.rangeWithMonth(DateUtil.parseFirstDayLocalDate(queryBo.getStartYm()), DateUtil.parseLastDayLocalDate(queryBo.getEndYm()));
        List<SaleAmountHourHistory> historyList = Lists.newArrayList();
        for (String ym : ymList) {
            SaleAmountHourHistory history = new SaleAmountHourHistory();
            history.setYear(Integer.parseInt(ym.split("-")[0]));
            history.setMonth(Integer.parseInt(ym.split("-")[1]));
            history.setYm(ym);

            history.setAmount(new BigDecimal("10000"));
            history.setAmountSp(new BigDecimal("20000"));
            history.setAmountLunarSp(new BigDecimal("30000"));

            history.setSales(new BigDecimal("1000"));
            history.setSalesSp(new BigDecimal("2000"));
            history.setSalesLunarSp(new BigDecimal("3000"));

            history.setCostPrice(new BigDecimal("100"));
            history.setCostPriceSp(new BigDecimal("100"));
            history.setCostPriceLunarSp(new BigDecimal("100"));
            historyList.add(history);
        }

        return historyList;
    } 
}

第二種,單元測試

不需要啓動項目,也不會連接數據庫、RPC註冊中心等,但是相應的所有數據都需要打樁mock

這種方法可以使用testMe快速生成單元測試類的框架,具體方法見: 基於testMe快速生成單元測試類(框架)

主要註解:@InjectMocks + @Mock + @Test

•@InjectMocks標識了一個需要被測試的類,這個類中依賴的bean都需要被@Mock,並mock返回值,不然就會空指針

•@Mock mock依賴,具體mock數據還要搭配when(xxService.xxMethod(any())).thenReturn(new Object()); mock返回值

•@Test 標識一個測試方法

上代碼:

/**
 * Created by jiangbo8 on 2022/10/17 15:02
 */
public class CheckAndFillProcessorTest {
    @Mock
    Logger log;
    @Mock
    OrderRelService orderRelService;
    @Mock
    VenderServiceSdk venderServiceSdk;
    @Mock
    AfsServiceSdk afsServiceSdk;
    @Mock
    PriceServiceSdk priceServiceSdk;
    @Mock
    ProductInfoSdk productInfoSdk;
    @Mock
    OrderMidServiceSdk orderMidServiceSdk;
    @Mock
    OrderQueueService orderQueueService;
    @Mock
    SendpayMarkService sendpayMarkService;
    @Mock
    TradeOrderService tradeOrderService;

    @InjectMocks
    CheckAndFillProcessor checkAndFillProcessor;

    @Before
    public void setUp() {
        MockitoAnnotations.initMocks(this);
    }

    @Test
    public void testProcess2() throws Exception {

        OrderRel orderRel = new OrderRel();
        //orderRel.setJdOrderId(2222222L);
        orderRel.setSopOrderId(1111111L);
        orderRel.setVenderId("123");

        when(orderRelService.queryOrderBySopOrderId(anyLong())).thenReturn(orderRel);

        OrderDetailRel orderDetailRel = new OrderDetailRel();
        orderDetailRel.setJdSkuId(1L);
        when(orderRelService.queryDetailList(any())).thenReturn(Collections.singletonList(orderDetailRel));

        Vender vender = new Vender();
        vender.setVenderId("123");
        vender.setOrgId(1);
        when(venderServiceSdk.queryVenderByVenderId(anyString())).thenReturn(vender);
        when(afsServiceSdk.queryAfsTypeByJdSkuAndVender(anyLong(), anyString())).thenReturn(0);
        when(priceServiceSdk.getJdToVenderPriceByPriorityAndSaleTime(anyString(), anyString(), any())).thenReturn(new BigDecimal("1"));
        when(productInfoSdk.getProductInfo(any())).thenReturn(new HashMap<Long, Map<String, String>>() {{
            put(1L, new HashMap<String, String>() {{
                put("String", "String");
            }});
        }});

        when(orderQueueService.updateQueueBySopOrderId(any())).thenReturn(true);

        Order sopOrder = new Order();
        sopOrder.setYn(1);
        when(orderMidServiceSdk.getOrderByIdFromMiddleWare(anyLong())).thenReturn(sopOrder);

        when(sendpayMarkService.isFreshOrder(anyLong(), anyString())).thenReturn(true);

        doNothing().when(tradeOrderService).fillOrderProduceTypeInfo(any(), anyInt(), any());
        doNothing().when(tradeOrderService).fillOrderFlowFlagInfo(any(), any(), anyInt(), any());

        Field field = ResourceContainer.class.getDeclaredField("allInPlateConfig");
        field.setAccessible(true);
        field.set("allInPlateConfig", new AllInPlateConfig());

        OrderQueue orderQueue = new OrderQueue();
        orderQueue.setSopOrderId(1111111L);
        DispatchResult result = checkAndFillProcessor.process(orderQueue);
        Assert.assertNotNull(result);
    }
}

三、單元測試經驗總結

在工作中總結了一些單元測試的使用場景:

1.重構,如果我們拿到了一個代碼,我們要去重構這個代碼,如果這個代碼本身的單元測試比較完善,那麼我們重構完之後可以執行一下現有的單元測試,以保證重構前後代碼在各個場景的邏輯保證最終一致,但是如果單元測試不完善甚至沒有,那我建議大家可以基於AI去生成這個代碼的單元測試,然後進行重構,再用生成的單元測試去把控質量,這裏推薦Diffblue去生成,有興趣的可以去了解一下。

2.新功能,新功能建議使用上面推薦的兩種方法去做單測,第一種方法因爲偏集成測試,單元測試代碼編寫的壓力比較小,可以以黑盒測試的視角去覆蓋測試case就可以了,但是如果某場景極爲複雜,想要單獨對某個複雜計算代碼塊進行專門的測試,那麼可以使用第二種方法,第二種方法是很單純的單元測試,聚焦專門代碼塊,但是如果普遍使用的話,單元測試代碼編寫量會很大,不建議單純使用某一種,可以具體情況具體分析。

建議大家做單元測試不要單純的追求行覆蓋率,還是要本着提高質量的心態去做單元測試。

作者:京東零售 姜波

來源:京東雲開發者社區

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章