Java單元測試典型案例集錦

前言

近期,阿里巴巴CTO線卓越工程小組舉辦了阿里巴巴第一屆單元測試比賽《這!就是單測》並取得了圓滿成功。本人有幸作爲評委,在仔細地閱讀了各個小組的單元測試用例後,發現了大單元測試問題:

  1. 無效驗證問題:不進行有效地驗證數據對象、拋出異常和調用方法。
  2. 測試方法問題:不知道如何測試某些典型案例,要麼錯誤地測試、要麼不進行測試、要麼利用集成測試來保證覆蓋率。比如:
    ①錯誤地測試:利用測試返回節點佔比來測試隨機負載均衡策略;
    ②不進行測試:沒有人針對虛基類進行單獨地測試;
    ③利用集成測試:很多案例中,直接注入真實依賴對象,然後一起進行集成測試。

針對無效驗證問題,在我的ATA文章《那些年,我們寫過的無效單元測試》中,介紹瞭如何識別和解決單元測試無效驗證問題,這裏就不再累述了。在本文中,作者收集了一些的Java單元測試典型案例,主要是爲了解決這個測試方法問題

1. 如何測試不可達代碼

在程序代碼中,由於無法滿足進入條件,永遠都不會執行到的代碼,我們稱之爲"不可達代碼"。不可達代碼的危害主要有:複雜了代碼邏輯,增加了代碼運行和維護成本。不可達代碼是可以由單元測試檢測出來的——不管如何構造單元測試用例,都無法覆蓋到不可達代碼。

1.1. 案例代碼

在下面的案例代碼中,就存在一段不可達代碼。

/**
 * 交易訂單服務類
 */
@Service
public class TradeOrderService {
    /** 注入依賴對象 */
    /** 交易訂單DAO */
    @Autowired
    private TradeOrderDAO tradeOrderDAO;

    /**
     * 查詢交易訂單
     * 
     * @param orderQuery 訂單查詢
     * @return 交易訂單分頁
     */
    public PageDataVO<TradeOrderVO> queryTradeOrder(TradeOrderQueryVO orderQuery) {
        // 查詢交易訂單
        // 查詢交易訂單: 總共數量
        Long totalSize = tradeOrderDAO.countByCondition(orderQuery);
        // 查詢交易訂單: 數據列表
        List<TradeOrderVO> dataList = null;
        if (NumberHelper.isPositive(totalSize)) {
            List<TradeOrderDO> tradeOrderList = tradeOrderDAO.queryByCondition(orderQuery);
            if (CollectionUtils.isNotEmpty(tradeOrderList)) {
                dataList = convertTradeOrders(tradeOrderList);
            }
        }

        // 返回分頁數據
        return new PageDataVO<>(totalSize, dataList);
    }

    /**
     * 轉化交易訂單列表
     * 
     * @param tradeOrderList 交易訂單DO列表
     * @return 交易訂單VO列表
     */
    private static List<TradeOrderVO> convertTradeOrders(List<TradeOrderDO> tradeOrderList) {
        // 檢查訂單列表
        if (CollectionUtils.isEmpty(tradeOrderList)) {
            return Collections.emptyList();
        }

        // 轉化訂單列表
        return tradeOrderList.stream().map(TradeOrderService::convertTradeOrder)
            .collect(Collectors.toList());
    }

    /**
     * 轉化交易訂單
     * 
     * @param tradeOrder 交易訂單DO
     * @return 交易訂單VO
     */
    private static TradeOrderVO convertTradeOrder(TradeOrderDO tradeOrder) {
        TradeOrderVO tradeOrderVO = new TradeOrderVO();
        tradeOrderVO.setId(tradeOrder.getId());
        // ...
        return tradeOrderVO;
    }
}

由於方法convertTradeOrders(轉化交易訂單列表)傳入的參數tradeOrderList(交易訂單列表)不可能爲空,所以“檢查訂單列表”這段代碼是不可達代碼。

// 檢查訂單列表
        if (CollectionUtils.isEmpty(tradeOrderList)) {
            return Collections.emptyList();
        }

1.2. 方案1:刪除不可達代碼(推薦)

最簡單的方法,就是刪除方法convertTradeOrders(轉化交易訂單列表)中的不可達代碼。

/**
 * 轉化交易訂單列表
 * 
 * @param tradeOrderList 交易訂單DO列表
 * @return 交易訂單VO列表
 */
private static List<TradeOrderVO> convertTradeOrders(List<TradeOrderDO> tradeOrderList) {
    return tradeOrderList.stream().map(TradeOrderService2::convertTradeOrder)
        .collect(Collectors.toList());
}

1.3. 方案2:利用不可達代碼(推薦)

還有一種方法,把不可達代碼利用起來,可以降低方法queryTradeOrder(查詢交易訂單)的代碼複雜度。

/**
 * 查詢交易訂單
 * 
 * @param orderQuery 訂單查詢
 * @return 交易訂單分頁
 */
public PageDataVO<TradeOrderVO> queryTradeOrder(TradeOrderQueryVO orderQuery) {
    // 查詢交易訂單
    // 查詢交易訂單: 總共數量
    Long totalSize = tradeOrderDAO.countByCondition(orderQuery);
    // 查詢交易訂單: 數據列表
    List<TradeOrderVO> dataList = null;
    if (NumberHelper.isPositive(totalSize)) {
        List<TradeOrderDO> tradeOrderList = tradeOrderDAO.queryByCondition(orderQuery);
        dataList = convertTradeOrders(tradeOrderList);
    }

    // 返回分頁數據
    return new PageDataVO<>(totalSize, dataList);
}

1.4. 方案3:測試不可達代碼(不推薦)

對於一些祖傳代碼,有些小夥伴不敢刪除代碼。在某些情況下,可以針對不可達代碼進行單獨測試。

/**
 * 測試: 轉化交易訂單列表-交易訂單列表爲空
 * 
 * @throws Exception 異常信息
 */
@Test
public void testConvertTradeOrdersWithTradeOrderListEmpty() throws Exception {
    List<TradeOrderDO> tradeOrderList = null;
    Assert.assertSame("交易訂單列表不爲空", Collections.emptyList(),
        Whitebox.invokeMethod(TradeOrderService1.class, "convertTradeOrders", tradeOrderList));
}

2. 如何測試內部的構造方法

在這次單元測試總決賽中,有一個隨機負載均衡策略,需要針對Random(隨機數)進行單元測試。

2.1. 代碼案例

按照題目要求,編寫了一個簡單的隨機負載均衡策略。

/**
 * 隨機負載均衡策略類
 */
public class RandomLoadBalanceStrategy implements LoadBalanceStrategy {
    /**
     * 選擇服務節點
     * 
     * @param serverNodeList 服務節點列表
     * @param clientRequest 客戶請求
     * @return 服務節點
     */
    @Override
    public ServerNode selectNode(List<ServerNode> serverNodeList, ClientRequest clientRequest) {
        // 檢查節點列表
        if (CollectionUtils.isEmpty(serverNodeList)) {
            return null;
        }

        // 計算隨機序號
        int totalWeight = serverNodeList.stream().mapToInt(ServerNode::getWeight).sum();
        int randomIndex = new Random().nextInt(totalWeight);

        // 查找對應節點
        for (ServerNode serverNode : serverNodeList) {
            int currentWeight = serverNode.getWeight();
            if (currentWeight > randomIndex) {
                return serverNode;
            }
            randomIndex -= currentWeight;
        }
        return null;
    }
}

2.2. 方法1:直接測試法(不推薦)

有些參賽選手,不知道如何測試隨機數(主要原因是因爲不知道如何Mock構造方法),所以直接利用測試返回節點佔比來測試隨機負載均衡策略。

/**
 * 隨機負載均衡策略測試類
 */
@RunWith(MockitoJUnitRunner.class)
public class RandomLoadBalanceStrategyTest {
    /** 定義測試對象 */
    /** 隨機負載均衡策略 */
    @InjectMocks
    private RandomLoadBalanceStrategy randomLoadBalanceStrategy;

    /**
     * 測試: 選擇服務節點-隨機
     * 
     * @throws Exception 異常信息
     */
    @Test
    public void testSelectNodeWithRandom() throws Exception {
        int nodeCount1 = 0;
        int nodeCount2 = 0;
        int nodeCount3 = 0;
        ServerNode serverNode1 = new ServerNode(1L, 10);
        ServerNode serverNode2 = new ServerNode(2L, 20);
        ServerNode serverNode3 = new ServerNode(3L, 30);
        List<ServerNode> serverNodeList = Arrays.asList(serverNode1, serverNode2, serverNode3);
        ClientRequest clientRequest = new ClientRequest();
        for (int i = 0; i < 1000; i++) {
            ServerNode serviceNode = randomLoadBalanceStrategy.selectNode(serverNodeList, clientRequest);
            if (serviceNode == serverNode1) {
                nodeCount1++;
            } else if (serviceNode == serverNode2) {
                nodeCount2++;
            } else if (serviceNode == serverNode3) {
                nodeCount3++;
            }
        }
        Assert.assertEquals("節點1佔比不一致", serverNode1.getWeight() / 60.0D, nodeCount1 / 1000.0D, 1E-3D);
        Assert.assertEquals("節點2佔比不一致", serverNode2.getWeight() / 60.0D, nodeCount2 / 1000.0D, 1E-3D);
        Assert.assertEquals("節點3佔比不一致", serverNode3.getWeight() / 60.0D, nodeCount3 / 1000.0D, 1E-3D);
    }
}

這個測試用例主要存在3個問題:

  1. 執行時間長:被測方法需要被執行1000遍;
  2. 不一定通過:由於隨機數是隨機,並不一定保證比例,所以導致測試用例並不一定通過;
  3. 測試目標變更:單測測試的測試目標應該是負載均衡邏輯,現在感覺測試目標變成了Random方法。

2.3. 方法2:直接mock法(不推薦)

用過PowerMockito高級功能的,知道如何去Mock構造方法。

/**
 * 隨機負載均衡策略測試類
 */
@RunWith(PowerMockRunner.class)
@PrepareForTest(RandomLoadBalanceStrategy.class)
public class RandomLoadBalanceStrategyTest {
    /** 定義測試對象 */
    /** 隨機負載均衡策略 */
    @InjectMocks
    private RandomLoadBalanceStrategy randomLoadBalanceStrategy;

    /**
     * 測試: 選擇服務節點-第一個節點
     * 
     * @throws Exception 異常信息
     */
    @Test
    public void testSelectNodeWithFirstNode() throws Exception {
        // 模擬依賴方法
        Random random = Mockito.mock(Random.class);
        Mockito.doReturn(9).when(random).nextInt(Mockito.anyInt());
        PowerMockito.whenNew(Random.class).withNoArguments().thenReturn(random);

        // 調用測試方法
        ServerNode serverNode1 = new ServerNode(1L, 10);
        ServerNode serverNode2 = new ServerNode(2L, 20);
        ServerNode serverNode3 = new ServerNode(3L, 30);
        List<ServerNode> serverNodeList = Arrays.asList(serverNode1, serverNode2, serverNode3);
        ClientRequest clientRequest = new ClientRequest();
        ServerNode serviceNode = randomLoadBalanceStrategy.selectNode(serverNodeList, clientRequest);
        Assert.assertEquals("服務節點不一致", serverNode1, serviceNode);

        // 驗證依賴方法
        int totalWeight = serverNodeList.stream().mapToInt(ServerNode::getWeight).sum();
        Mockito.verify(random).nextInt(totalWeight);
    }
}

但是,這個測試用例也存在問題:需要把RandomLoadBalanceStrategy加到@PrepareForTest註解中,導致Jacoco無法統計單元測試的覆蓋率。

2.4. 方法3:工具方法法(推薦)

其實,隨機數生成,還有很多工具方法,我們可以利用工具方法RandomUtils.nextInt代替構造方法。

2.4.1. 重構代碼

/**
 * 隨機負載均衡策略類
 */
public class RandomLoadBalanceStrategy implements LoadBalanceStrategy {
    /**
     * 選擇服務節點
     * 
     * @param serverNodeList 服務節點列表
     * @param clientRequest 客戶請求
     * @return 服務節點
     */
    @Override
    public ServerNode selectNode(List<ServerNode> serverNodeList, ClientRequest clientRequest) {
        // 檢查節點列表
        if (CollectionUtils.isEmpty(serverNodeList)) {
            return null;
        }

        // 計算隨機序號
        int totalWeight = serverNodeList.stream().mapToInt(ServerNode::getWeight).sum();
        int randomIndex = RandomUtils.nextInt(0, totalWeight);

        // 查找對應節點
        for (ServerNode serverNode : serverNodeList) {
            int currentWeight = serverNode.getWeight();
            if (currentWeight > randomIndex) {
                return serverNode;
            }
            randomIndex -= currentWeight;
        }
        return null;
    }
}

2.4.2. 測試用例

/**
 * 隨機負載均衡策略測試類
 */
@RunWith(PowerMockRunner.class)
@PrepareForTest(RandomUtils.class)
public class RandomLoadBalanceStrategyTest {
    /** 定義測試對象 */
    /** 隨機負載均衡策略 */
    @InjectMocks
    private RandomLoadBalanceStrategy randomLoadBalanceStrategy;

    /**
     * 測試: 選擇服務節點-第一個節點
     */
    @Test
    public void testSelectNodeWithFirstNode() {
        // 模擬依賴方法
        PowerMockito.mockStatic(RandomUtils.class);
        PowerMockito.when(RandomUtils.nextInt(Mockito.eq(0), Mockito.anyInt())).thenReturn(9);

        // 調用測試方法
        ServerNode serverNode1 = new ServerNode(1L, 10);
        ServerNode serverNode2 = new ServerNode(2L, 20);
        ServerNode serverNode3 = new ServerNode(3L, 30);
        List<ServerNode> serverNodeList = Arrays.asList(serverNode1, serverNode2, serverNode3);
        ClientRequest clientRequest = new ClientRequest();
        ServerNode serviceNode = randomLoadBalanceStrategy.selectNode(serverNodeList, clientRequest);
        Assert.assertEquals("服務節點不一致", serverNode1, serviceNode);

        // 驗證依賴方法
        PowerMockito.verifyStatic(RandomUtils.class);
        int totalWeight = serverNodeList.stream().mapToInt(ServerNode::getWeight).sum();
        RandomUtils.nextInt(0, totalWeight);
    }
}

2.5. 方法4:注入對象法(推薦)

如果不願意使用工具方法,也可以注入依賴對象,我們可以利用RandomProvider(隨機數提供者)來代替構造方法。

2.5.1. 重構代碼

/**
 * 隨機負載均衡策略類
 */
public class RandomLoadBalanceStrategy implements LoadBalanceStrategy {
    /** 注入依賴對象 */
    /** 隨機數提供者 */
    @Autowired
    private RandomProvider randomProvider;

    /**
     * 選擇服務節點
     * 
     * @param serverNodeList 服務節點列表
     * @param clientRequest 客戶請求
     * @return 服務節點
     */
    @Override
    public ServerNode selectNode(List<ServerNode> serverNodeList, ClientRequest clientRequest) {
        // 檢查節點列表
        if (CollectionUtils.isEmpty(serverNodeList)) {
            return null;
        }

        // 計算隨機序號
        int totalWeight = serverNodeList.stream().mapToInt(ServerNode::getWeight).sum();
        int randomIndex = randomProvider.nextInt(totalWeight);

        // 查找對應節點
        for (ServerNode serverNode : serverNodeList) {
            int currentWeight = serverNode.getWeight();
            if (currentWeight > randomIndex) {
                return serverNode;
            }
            randomIndex -= currentWeight;
        }
        return null;
    }
}

2.5.2. 測試用例

/**
 * 隨機負載均衡策略測試類
 */
@RunWith(MockitoJUnitRunner.class)
public class RandomLoadBalanceStrategyTest {
    /** 模擬依賴方法 */
    /** 隨機數提供者 */
    @Mock
    private RandomProvider randomProvider;

    /** 定義測試對象 */
    /** 隨機負載均衡策略 */
    @InjectMocks
    private RandomLoadBalanceStrategy randomLoadBalanceStrategy;

    /**
     * 測試: 選擇服務節點-第一個節點
     */
    @Test
    public void testSelectNodeWithFirstNode() {
        // 模擬依賴方法
        Mockito.doReturn(9).when(randomProvider).nextInt(Mockito.anyInt());

        // 調用測試方法
        ServerNode serverNode1 = new ServerNode(1L, 10);
        ServerNode serverNode2 = new ServerNode(2L, 20);
        ServerNode serverNode3 = new ServerNode(3L, 30);
        List<ServerNode> serverNodeList = Arrays.asList(serverNode1, serverNode2, serverNode3);
        ClientRequest clientRequest = new ClientRequest();
        ServerNode serviceNode = randomLoadBalanceStrategy.selectNode(serverNodeList, clientRequest);
        Assert.assertEquals("服務節點不一致", serverNode1, serviceNode);

        // 驗證依賴方法
        int totalWeight = serverNodeList.stream().mapToInt(ServerNode::getWeight).sum();
        Mockito.verify(randomProvider).nextInt(totalWeight);
    }
}

3. 如何測試虛基類和子類

在這次單元測試比賽中,很多選手都編寫了虛基類,但是沒有看到任何一個選手針對虛基類進行了單獨的測試。

3.1. 案例代碼

這裏,以Diamond屬性配置加載爲例說明。

3.1.1. 虛基類定義

首先,定義一個通用的虛基類,定義了需要子類實現的虛方法,實現了通用的配置解析方法。

/**
 * 虛屬性回調類
 *
 * @param <T> 配置類型
 */
@Slf4j
public abstract class AbstractPropertiesCallback<T> implements DiamondDataCallback {
    /** 注入依賴對象 */
    /** 環境 */
    @Autowired
    private Environment environment;
    /** 轉化服務 */
    @Autowired
    private ConversionService conversionService;

    /**
     * 接收到數據
     *
     * @param data 配置數據
     */
    @Override
    public void received(String data) {
        // 獲取配置參數
        String configName = getConfigName();
        Assert.notNull(configName, "配置名稱不能爲空");
        T configInstance = getConfigInstance();
        Assert.notNull(configInstance, "配置實例不能爲空");

        // 解析配置數據
        try {
            log.info("綁定屬性配置文件開始: configName={}", configName);
            Properties properties = new Properties();
            byte[] bytes = Optional.ofNullable(data.getBytes()).orElseGet(() -> new byte[0]);
            InputStream inputStream = new ByteArrayInputStream(bytes);
            BufferedReader bufferedReader = new BufferedReader(new InputStreamReader(inputStream));
            properties.load(bufferedReader);
            Bindable<T> bindable = Bindable.ofInstance(configInstance);
            Binder binder = new Binder(ConfigurationPropertySources.from(
                new PropertiesPropertySource(configName, properties)),
                new PropertySourcesPlaceholdersResolver(environment), conversionService);
            BindResult<T> result = binder.bind(configName, bindable);
            if (!result.isBound()) {
                log.error("綁定屬性配置文件失敗: configName={}", configName);
                return;
            }
            log.info("綁定屬性配置文件成功: configName={}, configInstance={}", configName, JSON.toJSONString(configInstance));
        } catch (IOException | RuntimeException e) {
            log.error("綁定屬性配置文件異常: configName={}", configName, e);
        }
    }

    /**
     * 獲取配置名稱
     *
     * @return 配置名稱
     */
    @NonNull
    protected abstract String getConfigName();

    /**
     * 獲取配置實例
     *
     * @return 配置實例
     */
    @NonNull
    protected abstract T getConfigInstance();
}

3.1.2. 子類實現

其次,定義了具體配置的子類,簡單地實現了基類定義的虛方法。

/**
 * 例子配置回調類
 */
@DiamondListener(groupId = "unittest-example", dataId = "example.properties", executeAfterInit = true)
public class ExampleConfigCallback extends AbstractPropertiesCallback<ExampleConfig> {
    /** 注入依賴對象 */
    /** 例子配置 */
    @Resource
    private ExampleConfig exampleConfig;

    /**
     * 獲取配置名稱
     *
     * @return 配置名稱
     */
    @Override
    protected String getConfigName() {
        return "example";
    }

    /**
     * 獲取配置實例
     *
     * @return 配置實例
     */
    @Override
    protected ExampleConfig getConfigInstance() {
        return exampleConfig;
    }
}

3.2. 方法1:聯合測試法(不推薦)

最簡單的測試方法,就是通過子類對虛基類進行聯合測試,這樣同時把子類和虛基類都測試了。

/**
 * 例子配置回調測試類
 */
@RunWith(MockitoJUnitRunner.class)
public class ExampleConfigCallbackTest {
    /** 定義靜態常量 */
    /** 資源路徑 */
    private static final String RESOURCE_PATH = "testExampleConfigCallback/";

    /** 模擬依賴對象 */
    /** 配置環境 */
    @Mock
    private ConfigurableEnvironment environment;
    /** 轉化服務 */
    @Mock
    private ConversionService conversionService;

    /** 定義測試對象 */
    /** BOSS取消費配置回調 */
    @InjectMocks
    private ExampleConfigCallback exampleConfigCallback;

    /**
     * 測試: 接收-正常
     */
    @Test
    public void testReceivedWithNormal() {
        // 模擬依賴對象
        ExampleConfig exampleConfig = new ExampleConfig();
        Whitebox.setInternalState(exampleConfigCallback, "exampleConfig", exampleConfig);

        // 調用測試方法
        String text = ResourceHelper.getResourceAsString(getClass(), RESOURCE_PATH + "exampleConfig.properties");
        exampleConfigCallback.received(text);

        // 驗證依賴對象
        text = ResourceHelper.getResourceAsString(getClass(), RESOURCE_PATH + "exampleConfig.json");
        Assert.assertEquals("取消費用配置不一致", text, JSON.toJSONString(exampleConfig, SerializerFeature.MapSortField));
    }
}

3.3. 方法2:獨立測試法(推薦)

其實,更好的方法是對虛基類和子類獨立單元測試。

3.3.1. 基類測試

虛基類的單元測試,專注於虛基類的通用配置解析。

/**
 * 虛屬性回調測試類
 */
@RunWith(MockitoJUnitRunner.class)
public class AbstractPropertiesCallbackTest {
    /** 靜態常量相關 */
    /** 資源目錄 */
    private static final String RESOURCE_PATH = "testAbstractPropertiesCallback/";

    /** 模擬依賴對象 */
    /** 環境 */
    @Mock
    private ConfigurableEnvironment environment;
    /** 轉化服務 */
    @Mock
    private ConversionService conversionService;

    /** 定義測試對象 */
    /** 虛屬性回調 */
    @InjectMocks
    private AbstractPropertiesCallback<ExampleConfig> propertiesCallback =
        CastUtils.cast(Mockito.spy(AbstractPropertiesCallback.class));

    /**
     * 測試: 接收到-正常
     */
    @Test
    public void testReceivedWithNormal() {
        // 模擬依賴方法
        // 模擬依賴方法: propertiesCallback.getConfigName
        String configName = "example";
        Mockito.doReturn(configName).when(propertiesCallback).getConfigName();
        // 模擬依賴方法: propertiesCallback.getConfigInstance
        ExampleConfig configInstance = new ExampleConfig();
        Mockito.doReturn(configInstance).when(propertiesCallback).getConfigInstance();

        // 調用測試方法
        String text1 = ResourceHelper.getResourceAsString(getClass(), RESOURCE_PATH + "exampleConfig.properties");
        propertiesCallback.received(text1);
        String text2 = ResourceHelper.getResourceAsString(getClass(), RESOURCE_PATH + "exampleConfig.json");
        Assert.assertEquals("任務配置不一致", text2, JSON.toJSONString(configInstance));

        // 驗證依賴方法
        // 驗證依賴方法: propertiesCallback.received
        Mockito.verify(propertiesCallback).received(text1);
        // 驗證依賴方法: propertiesCallback.getConfigName
        Mockito.verify(propertiesCallback).getConfigName();
        // 驗證依賴方法: propertiesCallback.getConfigInstance
        Mockito.verify(propertiesCallback).getConfigInstance();
    }
}

3.3.2. 子類測試

子類的單元測試,專注於對虛基類定義虛方法的實現,避免了每個子類都要針對虛基類的通用配置解析進行測試。

/**
 * 例子配置回調測試類
 */
@RunWith(MockitoJUnitRunner.class)
public class ExampleConfigCallbackTest {
    /** 定義測試對象 */
    /** BOSS取消費配置回調 */
    @InjectMocks
    private ExampleConfigCallback exampleConfigCallback;
    
    /**
     * 測試: 獲取配置實例
     */
    @Test
    public void testGetConfigInstance() {
        Assert.assertEquals("配置實例不一致", exampleConfig, exampleConfigCallback.getConfigInstance());
    }

    /**
     * 測試: 獲取配置名稱
     */
    @Test
    public void testGetConfigName() {
        Assert.assertEquals("配置名稱不一致", "example", exampleConfigCallback.getConfigName());
    }
}

4. 如何測試策略模式的策略服務

4.1. 案例代碼

在這次單元測試比賽中,很多選手都編寫了策略服務類,但是沒有看到任何一個選手針對策略服務類進行了單獨的測試。這裏,還是以負載均衡的策略服務爲例說明。

4.1.1. 策略接口

首先,定義一個負載均衡策略接口。

/**
 * 負載均衡策略接口
 */
public interface LoadBalanceStrategy {
    /**
     * 支持策略類型
     * 
     * @return 策略類型
     */
    LoadBalanceStrategyType supportType();

    /**
     * 選擇服務節點
     * 
     * @param serverNodeList 服務節點列表
     * @param clientRequest 客戶請求
     * @return 服務節點
     */
    ServerNode selectNode(List<ServerNode> serverNodeList, ClientRequest clientRequest);
}

4.1.2. 策略服務

其次,實現一個負載均衡策略服務,根據負載均衡策略類型選擇對應的負載均衡策略來執行。

/**
 * 負載均衡服務類
 */
public class LoadBalanceService {
    /** 負載均衡策略映射 */
    private final Map<LoadBalanceStrategyType, LoadBalanceStrategy> strategyMap;

    /**
     * 構造方法
     * 
     * @param strategyList 負載均衡策略列表
     */
    public LoadBalanceService(List<LoadBalanceStrategy> strategyList) {
        strategyMap = new EnumMap<>(LoadBalanceStrategyType.class);
        for (LoadBalanceStrategy strategy : strategyList) {
            strategyMap.put(strategy.supportType(), strategy);
        }
    }

    /**
     * 選擇服務節點
     * 
     * @param strategyType 策略類型
     * @param serverNodeList 服務節點列表
     * @param clientRequest 客戶請求
     * @return 服務節點
     */
    public ServerNode selectNode(LoadBalanceStrategyType strategyType,
        List<ServerNode> serverNodeList, ClientRequest clientRequest) {
        // 獲取負載均衡策略
        LoadBalanceStrategy strategy = strategyMap.get(strategyType);
        if (Objects.isNull(strategy)) {
            throw new BusinessException("負載均衡策略不存在");
        }

        // 執行負載均衡策略
        return strategy.selectNode(serverNodeList, clientRequest);
    }
}

4.1.3. 策略實現

最後,實現一個隨機負載均衡策略實現類。

/**
 * 隨機負載均衡策略類
 */
public class RandomLoadBalanceStrategy implements LoadBalanceStrategy {
    /**
     * 支持策略類型
     * 
     * @return 策略類型
     */
    @Override
    public LoadBalanceStrategyType supportType() {
        return LoadBalanceStrategyType.RANDOM;
    }

    /**
     * 選擇服務節點
     * 
     * @param serverNodeList 服務節點列表
     * @param clientRequest 客戶請求
     * @return 服務節點
     */
    @Override
    public ServerNode selectNode(List<ServerNode> serverNodeList, ClientRequest clientRequest) {
        // 檢查節點列表
        if (CollectionUtils.isEmpty(serverNodeList)) {
            return null;
        }

        // 計算隨機序號
        int totalWeight = serverNodeList.stream().mapToInt(ServerNode::getWeight).sum();
        int randomIndex = RandomUtils.nextInt(0, totalWeight);

        // 查找對應節點
        for (ServerNode serverNode : serverNodeList) {
            int currentWeight = serverNode.getWeight();
            if (currentWeight > randomIndex) {
                return serverNode;
            }
            randomIndex -= currentWeight;
        }
        return null;
    }
}

4.2. 方法1:聯合測試法(不推薦)

很多時候,策略模式是用來優化if-else代碼的。所以,採用聯合測試法(策略服務和策略實現同時測試),能夠最大限度地利用原有的單元測試代碼。

/**
 * 負載均衡服務測試類
 */
@RunWith(PowerMockRunner.class)
@PrepareForTest(RandomUtils.class)
public class LoadBalanceServiceTest {
    /**
     * 測試: 選擇服務節點-正常
     */
    @Test
    public void testSelectNodeWithNormal() {
        // 模擬依賴方法
        PowerMockito.mockStatic(RandomUtils.class);
        PowerMockito.when(RandomUtils.nextInt(Mockito.eq(0), Mockito.anyInt())).thenReturn(9);

        // 調用測試方法
        ServerNode serverNode1 = new ServerNode(1L, 10);
        ServerNode serverNode2 = new ServerNode(2L, 20);
        ServerNode serverNode3 = new ServerNode(3L, 30);
        List<ServerNode> serverNodeList = Arrays.asList(serverNode1, serverNode2, serverNode3);
        ClientRequest clientRequest = new ClientRequest();
        RandomLoadBalanceStrategy randomLoadBalanceStrategy = new RandomLoadBalanceStrategy();
        LoadBalanceService loadBalanceService = new LoadBalanceService(Arrays.asList(randomLoadBalanceStrategy));
        ServerNode serviceNode = loadBalanceService.selectNode(LoadBalanceStrategyType.RANDOM,
            serverNodeList, clientRequest);
        Assert.assertEquals("服務節點不一致", serverNode1, serviceNode);

        // 驗證依賴方法
        PowerMockito.verifyStatic(RandomUtils.class);
        int totalWeight = serverNodeList.stream().mapToInt(ServerNode::getWeight).sum();
        RandomUtils.nextInt(0, totalWeight);
    }
}

策略模式的聯合測試法主要有以下問題:

  1. 策略服務依賴於策略實現,需要了解策略實現的具體邏輯,才能寫出策略服務的單元測試;
  2. 對於策略服務來說,該單元測試並不關心策略服務的實現,這是黑盒測試而不是白盒測試。

如果我們對策略服務進行以下破壞,該單元測試並不能發現問題:

  1. strategyMap沒有根據strategyList生成;
  2. strategyMap.get(strategyType)爲空時,初始化一個RandomLoadBalanceStrategy。
/**
 * 負載均衡服務類
 */
public class LoadBalanceService {
    /** 負載均衡策略映射 */
    private final Map<LoadBalanceStrategyType, LoadBalanceStrategy> strategyMap;

    /**
     * 構造方法
     * 
     * @param strategyList 負載均衡策略列表
     */
    public LoadBalanceService(List<LoadBalanceStrategy> strategyList) {
        strategyMap = new EnumMap<>(LoadBalanceStrategyType.class);
    }

    /**
     * 選擇服務節點
     * 
     * @param strategyType 策略類型
     * @param serverNodeList 服務節點列表
     * @param clientRequest 客戶請求
     * @return 服務節點
     */
    public ServerNode selectNode(LoadBalanceStrategyType strategyType,
        List<ServerNode> serverNodeList, ClientRequest clientRequest) {
        // 獲取負載均衡策略
        LoadBalanceStrategy strategy = strategyMap.get(strategyType);
        if (Objects.isNull(strategy)) {
            strategy = new RandomLoadBalanceStrategy();
        }

        // 執行負載均衡策略
        return strategy.selectNode(serverNodeList, clientRequest);
    }
}

4.3. 方法2:獨立測試法(推薦)

現在,先假設策略實現RandomLoadBalanceStrategy(隨機負載均衡策略)不存在,直接對策略服務LoadBalanceService(負載均衡服務)獨立測試,而且是分別對構造方法和selectNode(選擇服務節點)方法進行獨立測試。其中,測試構造方法是爲了保證strategyMap構造邏輯沒有問題,測試selectNode(選擇服務節點)方法是爲了保證選擇策略邏輯沒有問題。

/**
 * 負載均衡服務測試類
 */
public class LoadBalanceServiceTest {
    /**
     * 測試: 構造方法
     */
    @Test
    public void testConstructor() {
        // 模擬依賴方法
        LoadBalanceStrategy loadBalanceStrategy = Mockito.mock(LoadBalanceStrategy.class);
        Mockito.doReturn(LoadBalanceStrategyType.RANDOM).when(loadBalanceStrategy).supportType();

        // 調用測試方法
        LoadBalanceService loadBalanceService = new LoadBalanceService(Arrays.asList(loadBalanceStrategy));
        Map<LoadBalanceStrategyType, LoadBalanceStrategy> strategyMap =
            Whitebox.getInternalState(loadBalanceService, "strategyMap");
        Assert.assertEquals("策略映射大小不一致", 1, strategyMap.size());
        Assert.assertEquals("策略映射對象不一致", loadBalanceStrategy, strategyMap.get(LoadBalanceStrategyType.RANDOM));

        // 驗證依賴方法
        Mockito.verify(loadBalanceStrategy).supportType();
    }

    /**
     * 測試: 選擇服務節點-正常
     */
    @Test
    public void testSelectNodeWithNormal() {
        // 模擬依賴方法
        LoadBalanceStrategy loadBalanceStrategy = Mockito.mock(LoadBalanceStrategy.class);
        // 模擬依賴方法: loadBalanceStrategy.supportType
        Mockito.doReturn(LoadBalanceStrategyType.RANDOM).when(loadBalanceStrategy).supportType();
        // 模擬依賴方法: loadBalanceStrategy.selectNode
        ServerNode serverNode = Mockito.mock(ServerNode.class);
        Mockito.doReturn(serverNode).when(loadBalanceStrategy)
            .selectNode(Mockito.anyList(), Mockito.any(ClientRequest.class));

        // 調用測試方法
        List<ServerNode> serverNodeList = CastUtils.cast(Mockito.mock(List.class));
        ClientRequest clientRequest = Mockito.mock(ClientRequest.class);
        LoadBalanceService loadBalanceService = new LoadBalanceService(Arrays.asList(loadBalanceStrategy));
        Assert.assertEquals("服務節點不一致", serverNode,
            loadBalanceService.selectNode(LoadBalanceStrategyType.RANDOM, serverNodeList, clientRequest));

        // 驗證依賴方法
        // 驗證依賴方法: loadBalanceStrategy.supportType
        Mockito.verify(loadBalanceStrategy).supportType();
        // 驗證依賴方法: loadBalanceStrategy.selectNode
        Mockito.verify(loadBalanceStrategy).selectNode(serverNodeList, clientRequest);
    }
}

其實,不只是策略模式,很多模式下都不建議聯合測試,而是推薦採用獨立的單元測試。因爲單元測試是白盒測試——一種專注於自身代碼邏輯的測試。

5. 如何測試Lambda表達式

在有些單元測試中,Lambda表達式並不一定被執行,所以導致Lambda表達式沒有被測試。

5.1. 案例代碼

這裏,以從ODPS中查詢用戶交易訂單爲例說明。

5.1.1. 被測代碼

交易訂單查詢服務,其中有一段轉化訂單的Lambda表達式。

/**
 * 交易訂單服務
 */
@Service
public class TradeOrderService {
    /** 注入依賴對象 */
    /** 交易ODPS服務 */
    @Autowired
    private TradeOdpsService tradeOdpsService;

    /**
     * 查詢交易訂單
     * 
     * @param userId 用戶標識
     * @param maxCount 最大數量
     * @return 交易訂單列表
     */
    public List<TradeOrderVO> queryTradeOrder(Long userId, Integer maxCount) {
        String format = ResourceHelper.getResourceAsString(getClass(), "query_trade_order.sql");
        String sql = String.format(format, userId, maxCount);
        return tradeOdpsService.executeQuery(sql, record -> {
            TradeOrderVO tradeOrder = new TradeOrderVO();
            tradeOrder.setId(record.getBigint("id"));
            // ...
            return tradeOrder;
        });
    }
}

5.1.2. 依賴代碼

封裝了通用的ODPS查詢方法。

/**
 * 交易ODPS服務類
 */
@Slf4j
@Service
public class TradeOdpsService {
    /** 注入依賴對象 */
    /** 交易ODPS */
    @Resource(name = "tradeOdps")
    private Odps tradeOdps;

    /**
     * 執行查詢
     *
     * @param <T> 模板類型
     * @param sql SQL語句
     * @param dataParser 數據解析器
     * @return 查詢結果列表
     */
    public <T> List<T> executeQuery(String sql, Function<Record, T> dataParser) {
        try {
            // 打印提示信息
            log.info("開始執行ODPS數據查詢...");

            // 執行ODPS查詢
            Instance instance = SQLTask.run(tradeOdps, sql);
            instance.waitForSuccess();

            // 獲取查詢結果
            List<Record> recordList = SQLTask.getResult(instance);
            if (CollectionUtils.isEmpty(recordList)) {
                log.info("完成執行ODPS數據查詢: totalSize=0");
                return Collections.emptyList();
            }

            // 依次讀取數據
            List<T> dataList = new ArrayList<>();
            for (Record record : recordList) {
                T data = dataParser.apply(record);
                if (Objects.nonNull(data)) {
                    dataList.add(data);
                }
            }

            // 打印提示信息
            log.info("完成執行ODPS數據查詢: totalSize={}", dataList.size());

            // 返回查詢結果
            return dataList;
        } catch (OdpsException e) {
            log.warn("執行ODPS數據查詢異常: sql={}", sql, e);
            throw new BusinessException("執行ODPS數據查詢異常", e);
        }
    }
}

5.2. 方法1:直接測試法(不推薦)

按照通用的單元測試方法進行測試,發現Lambda表達式沒有被測試到。

/**
 * 交易訂單服務測試類
 */
@RunWith(MockitoJUnitRunner.class)
public class TradeOrderServiceTest {
    /** 定義靜態常量 */
    /** 資源路徑 */
    private static final String RESOURCE_PATH = "testTradeOrderService/";

    /** 模擬依賴對象 */
    /** 交易ODPS服務 */
    @Mock
    private TradeOdpsService tradeOdpsService;

    /** 定義測試對象 */
    /** 交易訂單服務 */
    @InjectMocks
    private TradeOrderService tradeOrderService;

    /**
     * 測試: 查詢交易訂單-正常
     */
    @Test
    public void testQueryTradeOrderWithNormal() {
        // 模擬依賴方法
        // 模擬依賴方法: tradeOdpsService.executeQuery
        List<TradeOrderVO> tradeOrderList = CastUtils.cast(Mockito.mock(List.class));
        Mockito.doReturn(tradeOrderList).when(tradeOdpsService).executeQuery(Mockito.anyString(), Mockito.any());

        // 調用測試方法
        Long userId = 12345L;
        Integer maxCount = 100;
        Assert.assertSame("交易訂單列表不一致", tradeOrderList, tradeOrderService.queryTradeOrder(userId, maxCount));

        // 驗證依賴方法
        // 驗證依賴方法: tradeOdpsService.executeQuery
        String path = RESOURCE_PATH + "testQueryTradeOrderWithNormal/";
        String text = ResourceHelper.getResourceAsString(getClass(), path + "queryTradeOrder.sql");
        Mockito.verify(tradeOdpsService).executeQuery(Mockito.eq(text), Mockito.any());
    }
}

5.3. 方法2:聯合測試法(不推薦)

有人建議,可以把TradeOrderService(交易訂單服務)和TradeOdpsService(交易ODPS服務)聯合測試,這樣就可以保證Lambda表達式被測試到。

/**
 * 交易訂單服務測試類
 */
@RunWith(PowerMockRunner.class)
@PrepareForTest({SQLTask.class})
public class TradeOrderServiceTest {
    /** 定義靜態常量 */
    /** 資源路徑 */
    private static final String RESOURCE_PATH = "testTradeOrderService/";

    /** 模擬依賴對象 */
    /** 交易ODPS */
    @Mock(name = "tradeOdps")
    private Odps tradeOdps;

    /** 定義測試對象 */
    /** 交易ODPS服務 */
    @InjectMocks
    private TradeOdpsService tradeOdpsService = Mockito.spy(TradeOdpsService.class);
    /** 交易訂單服務 */
    @InjectMocks
    private TradeOrderService tradeOrderService;

    /**
     * 測試: 查詢交易訂單-正常
     * 
     * @throws OdpsException ODPS異常
     */
    @Test
    public void testQueryTradeOrderWithNormal() throws OdpsException {
        // 模擬依賴方法
        PowerMockito.mockStatic(SQLTask.class);
        // 模擬依賴方法: SQLTask.run
        Instance instance = Mockito.mock(Instance.class);
        PowerMockito.when(SQLTask.run(Mockito.eq(tradeOdps), Mockito.anyString())).thenReturn(instance);
        // 模擬依賴方法: SQLTask.getResult
        Record record1 = PowerMockito.mock(Record.class);
        Record record2 = PowerMockito.mock(Record.class);
        List<Record> recordList = Arrays.asList(record1, record2);
        PowerMockito.when(SQLTask.getResult(instance)).thenReturn(recordList);
        // 模擬依賴方法: record.getString
        Mockito.doReturn(1L).when(record1).getBigint("id");
        Mockito.doReturn(2L).when(record2).getBigint("id");

        // 調用測試方法
        Long userId = 12345L;
        Integer maxCount = 100;
        List<TradeOrderVO> tradeOrderList = tradeOrderService.queryTradeOrder(userId, maxCount);
        String path = RESOURCE_PATH + "testQueryTradeOrderWithNormal/";
        String text = ResourceHelper.getResourceAsString(getClass(), path + "tradeOrderList.json");
        Assert.assertEquals("交易訂單列表不一致", text, JSON.toJSONString(tradeOrderList));

        // 驗證依賴方法
        PowerMockito.verifyStatic(SQLTask.class);
        // 驗證依賴方法: SQLTask.run
        text = ResourceHelper.getResourceAsString(getClass(), path + "queryTradeOrder.sql");
        SQLTask.run(tradeOdps, text);
        // 驗證依賴方法: SQLTask.getResult
        SQLTask.getResult(instance);
        // 驗證依賴方法: instance.waitForSuccess
        Mockito.verify(instance).waitForSuccess();
        // 驗證依賴方法: record.getString
        Mockito.verify(record1).getBigint("id");
        Mockito.verify(record2).getBigint("id");
    }
}

主要問題:需要了解TradeOdpsService.executeQuery(執行查詢)方法的邏輯並構建單元測試用例,導致TradeOrderService.queryTradeOrder(查詢交易訂單)方法的單測測試用例非常複雜。

5.3. 方法3:重構測試法(推薦)

其實,只需要把這段Lambda表達式提取成一個convertTradeOrder(轉化交易訂單)方法,即可讓代碼變得清晰明瞭,又可以讓代碼更容易單元測試。

5.3.1. 重構代碼

提取Lambda表達式爲convertTradeOrder(轉化交易訂單)方法。

/**
 * 交易訂單服務類
 */
@Service
public class TradeOrderService {
    /** 注入依賴對象 */
    /** 交易ODPS服務 */
    @Autowired
    private TradeOdpsService tradeOdpsService;

    /**
     * 查詢交易訂單
     * 
     * @param userId 用戶標識
     * @param maxCount 最大數量
     * @return 交易訂單列表
     */
    public List<TradeOrderVO> queryTradeOrder(Long userId, Integer maxCount) {
        String format = ResourceHelper.getResourceAsString(getClass(), "query_trade_order.sql");
        String sql = String.format(format, userId, maxCount);
        return tradeOdpsService.executeQuery(sql, TradeOrderService2::convertTradeOrder);
    }

    /**
     * 轉化交易訂單
     * 
     * @param record ODPS記錄
     * @return 交易訂單
     */
    private static TradeOrderVO convertTradeOrder(Record record) {
        TradeOrderVO tradeOrder = new TradeOrderVO();
        tradeOrder.setId(record.getBigint("id"));
        // ...
        return tradeOrder;
    }
}

5.3.2. 測試用例

針對queryTradeOrder(查詢交易訂單)方法和convertTradeOrder(轉化交易訂單)方法分別進行單元測試。

/**
 * 交易訂單服務測試類
 */
@RunWith(MockitoJUnitRunner.class)
public class TradeOrderServiceTest {
    /** 定義靜態常量 */
    /** 資源路徑 */
    private static final String RESOURCE_PATH = "testTradeOrderService/";

    /** 模擬依賴對象 */
    /** 交易ODPS服務 */
    @Mock
    private TradeOdpsService tradeOdpsService;

    /** 定義測試對象 */
    /** 交易訂單服務 */
    @InjectMocks
    private TradeOrderService tradeOrderService;

    /**
     * 測試: 查詢交易訂單-正常
     */
    @Test
    public void testQueryTradeOrderWithNormal() {
        // 模擬依賴方法
        // 模擬依賴方法: tradeOdpsService.executeQuery
        List<TradeOrderVO> tradeOrderList = CastUtils.cast(Mockito.mock(List.class));
        Mockito.doReturn(tradeOrderList).when(tradeOdpsService).executeQuery(Mockito.anyString(), Mockito.any());

        // 調用測試方法
        Long userId = 12345L;
        Integer maxCount = 100;
        Assert.assertSame("交易訂單列表不一致", tradeOrderList, tradeOrderService.queryTradeOrder(userId, maxCount));

        // 驗證依賴方法
        // 驗證依賴方法: tradeOdpsService.executeQuery
        String path = RESOURCE_PATH + "testQueryTradeOrderWithNormal/";
        String text = ResourceHelper.getResourceAsString(getClass(), path + "queryTradeOrder.sql");
        Mockito.verify(tradeOdpsService).executeQuery(Mockito.eq(text), Mockito.any());
    }

    /**
     * 測試: 轉化交易訂單
     * 
     * @throws Exception 異常信息
     */
    @Test
    public void testConvertTradeOrder() throws Exception {
        // 模擬依賴方法
        Long id = 12345L;
        Record record = Mockito.mock(Record.class);
        Mockito.doReturn(id).when(record).getBigint("id");

        // 調用測試方法
        TradeOrderVO tradeOrder = Whitebox.invokeMethod(TradeOrderService2.class, "convertTradeOrder", record);
        Assert.assertEquals("訂單標識不一致", id, tradeOrder.getId());

        // 驗證依賴方法
        Mockito.verify(record).getBigint("id");
    }
}

6. 如何測試鏈式調用

在日常編碼過程中,很多人都喜歡使用鏈式調用,這樣可以讓代碼變得更簡潔。

6.1. 案例代碼

這裏,通過修改後的添加跨域支持代碼來舉例說明(原方法沒有返回值)。

/**
 * 跨域輔助類
 */
public class CorsHelper {
    /** 定義靜態常量 */
    /** 最大生命週期 */
    private static final long MAX_AGE = 3600L;

    /**
     * 添加跨域支持
     * 
     * @param registry 跨域註冊器
     * @return 跨域註冊
     */
    public static CorsRegistration addCorsMapping(CorsRegistry registry) {
        return registry.addMapping("/**")
            .allowedOrigins("*")
            .allowedMethods("GET", "HEAD", "POST", "PUT", "DELETE", "OPTIONS")
            .allowCredentials(true)
            .maxAge(MAX_AGE)
            .allowedHeaders("*");
    }
}

6.2. 方法1:普通測試法(不推薦)

正常情況下,每一個依賴對象及其調用方法都要mock,編寫的代碼如下:

/**
 * 跨域輔助測試類
 */
public class CorsHelperTest {

    /**
     * 測試: 添加跨域支持
     */
    @Test
    public void testAddCorsMapping() {
        // 模擬依賴方法
        CorsRegistry registry = Mockito.mock(CorsRegistry.class);
        CorsRegistration registration = Mockito.mock(CorsRegistration.class);
        Mockito.doReturn(registration).when(registry).addMapping(Mockito.anyString());
        Mockito.doReturn(registration).when(registration).allowedOrigins(Mockito.any());
        Mockito.doReturn(registration).when(registration).allowedMethods(Mockito.any());
        Mockito.doReturn(registration).when(registration).allowCredentials(Mockito.anyBoolean());
        Mockito.doReturn(registration).when(registration).maxAge(Mockito.anyLong());
        Mockito.doReturn(registration).when(registration).allowedHeaders(Mockito.any());

        // 調用測試方法
        Assert.assertEquals("跨域註冊不一致", registration, CorsHelper.addCorsMapping(registry));

        // 驗證依賴方法
        Mockito.verify(registry).addMapping("/**");
        Mockito.verify(registration).allowedOrigins("*");
        Mockito.verify(registration).allowedMethods("GET", "HEAD", "POST", "PUT", "DELETE", "OPTIONS");
        Mockito.verify(registration).allowCredentials(true);
        Mockito.verify(registration).maxAge(3600L);
        Mockito.verify(registration).allowedHeaders("*");
    }
}

6.3. 方法2:利用RETURNS_DEEP_STUBS參數法(推薦)

對於鏈式調用,Mockito提供了更加簡便的單元測試方法——提供Mockito.RETURNS_DEEP_STUBS參數,實現鏈式調用返回對象的自動mock。利用Mockito.RETURNS_DEEP_STUBS參數編寫的測試用例如下:

/**
 * 跨域輔助測試類
 */
public class CorsHelperTest {
    /**
     * 測試: 添加跨域支持
     */
    @Test
    public void testAddCorsMapping() {
        // 模擬依賴方法
        CorsRegistry registry = Mockito.mock(CorsRegistry.class, Answers.RETURNS_DEEP_STUBS);
        CorsRegistration registration = Mockito.mock(CorsRegistration.class);
        Mockito.when(registry.addMapping(Mockito.anyString())
            .allowedOrigins(Mockito.any())
            .allowedMethods(Mockito.any())
            .allowCredentials(Mockito.anyBoolean())
            .maxAge(Mockito.anyLong())
            .allowedHeaders(Mockito.any()))
            .thenReturn(registration);

        // 調用測試方法
        Assert.assertEquals("跨域註冊不一致", registration, CorsHelper.addCorsMapping(registry));

        // 驗證依賴方法
        Mockito.verify(registry.addMapping("/**")
            .allowedOrigins("*")
            .allowedMethods("GET", "HEAD", "POST", "PUT", "DELETE", "OPTIONS")
            .allowCredentials(true)
            .maxAge(3600L))
            .allowedHeaders("*");
    }
}

代碼說明:

  1. 在mock對象時,需要指定Mockito.RETURNS_DEEP_STUBS參數;
  2. 在mock方法時,採用when-then模式,when內容是鏈式調用,then內容是返回的值;
  3. 在verify方法時,只需要驗證最後1次方法調用,verify內容是前n次鏈式調用;如果驗證時某個方法調用的某個參數指定錯誤時,最後一個方法調用驗證將因爲這個mock對象沒有方法調用而拋出異常。

6.4. 方法3:利用RETURNS_SELF參數法(推薦)

對於相同返回值的鏈式調用,Mockito提供了更加簡便的單元測試方法——提供Mockito.RETURNS_SELF參數,實現鏈式調用返回對象的自動mock,而且還能返回同一mock對象。利用Mockito.RETURNS_SELF參數編寫的測試用例如下:

/**
 * 跨域輔助測試類
 */
public class CorsHelperTest {
    /**
     * 測試: 添加跨域支持
     */
    @Test
    public void testAddCorsMapping() {
        // 模擬依賴方法
        CorsRegistry registry = Mockito.mock(CorsRegistry.class);
        CorsRegistration registration = Mockito.mock(CorsRegistration.class, Answers.RETURNS_SELF);
        Mockito.doReturn(registration).when(registry).addMapping(Mockito.anyString());

        // 調用測試方法
        Assert.assertEquals("跨域註冊不一致", registration, CorsHelper.addCorsMapping(registry));

        // 驗證依賴方法
        Mockito.verify(registry).addMapping("/**");
        Mockito.verify(registration).allowedOrigins("*");
        Mockito.verify(registration).allowedMethods("GET", "HEAD", "POST", "PUT", "DELETE", "OPTIONS");
        Mockito.verify(registration).allowCredentials(true);
        Mockito.verify(registration).maxAge(3600L);
        Mockito.verify(registration).allowedHeaders("*");
    }
}

代碼說明:

  1. 在mock對象時,對於自返回對象,需要指定Mockito.RETURNS_SELF參數;
  2. 在mock方法時,無需對自返回對象進行mock方法,因爲框架已經mock方法返回了自身;
  3. 在verify方法時,可以像普通測試法一樣優美地驗證所有方法調用。

方法對比:

  1. 普通測試法:mock調用方法語句較多;
  2. 利用RETURNS_DEEP_STUBS參數法:mock調用方法語句較少,適合於鏈式調用返回不同值;
  3. 利用RETURNS_SELF參數法:mock調用方法語句最少,適合於鏈式調用返回相同值。

7. 如何測試相同參數返回不同值

在有些場景下,存在相同參數多次調用返回不同值的情況,比如:讀取文本文件的readLine方法。

7.1. 案例代碼

這裏,以ODPS的RecordReader爲例,讀取每一行數據記錄。

/**
 * 讀取數據
 *
 * @param <T> 模板類型
 * @param recordReader 記錄讀取器
 * @param dataParser 數據解析器
 * @return 數據列表
 * @throws IOException IO異常
 */
public static <T> List<T> readData(RecordReader recordReader, Function<Record, T> dataParser) throws IOException {
    Record record;
    List<T> dataList = new ArrayList<>();
    while (Objects.nonNull(record = recordReader.read())) {
        T data = dataParser.apply(record);
        if (Objects.nonNull(data)) {
            dataList.add(data);
        }
    }
    return dataList;
}

7.2. 測試用例

爲了mock相同參數返回不同值,需要使用到Mockito.doReturn的可變數組功能。

/**
 * 測試: 讀取數據-正常
 * 
 * @throws IOException IO異常
 */
@Test
public void testReadDataWithNormal() throws IOException {
    // 模擬依賴方法
    // 模擬依賴方法: recordReader.read
    Record record1 = Mockito.mock(Record.class);
    Record record2 = Mockito.mock(Record.class);
    TunnelRecordReader recordReader = Mockito.mock(TunnelRecordReader.class);
    Mockito.doReturn(record1, record2, null).when(recordReader).read();
    // 模擬依賴方法: dataParser.apply
    Function<Record, Object> dataParser = CastUtils.cast(Mockito.mock(Function.class));
    Object object1 = new Object();
    Object object2 = new Object();
    Mockito.doReturn(object1).when(dataParser).apply(record1);
    Mockito.doReturn(object2).when(dataParser).apply(record2);

    // 調用測試方法
    List<Object> dataList = OdpsHelper.readData(recordReader, dataParser);
    Assert.assertEquals("數據列表不一致", Arrays.asList(object1, object2), dataList);

    // 驗證依賴方法
    // 驗證依賴方法: recordReader.read
    Mockito.verify(recordReader, Mockito.times(3)).read();
    // 驗證依賴方法: dataParser.apply
    Mockito.verify(dataParser).apply(record1);
    Mockito.verify(dataParser).apply(record2);
}

8. 如何測試已變更的方法參數值

在單元測試中,我們通常通過ArgumentCaptor進行方法參數捕獲並驗證。但是,在有些情況下,我們捕獲的可能是已經變更的方法參數,所以無法對這些方法參數值進行驗證。

8.1. 案例代碼

這裏,以分批讀取並保存ODPS數據爲例說明。其中,dataList在每次存儲後,都進行了一次清除操作。

/**
 * 讀取數據
 *
 * @param <T> 模板類型
 * @param recordReader 記錄讀取器
 * @param batchSize 批量大小
 * @param dataParser 數據解析器
 * @param dataStorage 數據存儲器
 * @throws IOException IO異常
 */
public static <T> void readData(RecordReader recordReader, int batchSize,
    Function<Record, T> dataParser, Consumer<List<T>> dataStorage) throws IOException {
    // 依次讀取數據
    Record record;
    List<T> dataList = new ArrayList<>(batchSize);
    while (Objects.nonNull(record = recordReader.read())) {
        // 解析添加數據
        T data = dataParser.apply(record);
        if (Objects.nonNull(data)) {
            dataList.add(data);
        }

        // 批量存儲數據
        if (dataList.size() == batchSize) {
            dataStorage.accept(dataList);
            dataList.clear();
        }
    }

    // 存儲剩餘數據
    if (CollectionUtils.isNotEmpty(dataList)) {
        dataStorage.accept(dataList);
        dataList.clear();
    }
}

8.2. 問題測試

通常情況下,我們利用ArgumentCaptor編寫的測試用例如下:

/**
 * 測試: 讀取數據-正常
 * 
 * @throws IOException IO異常
 */
@Test
public void testReadDataWithNormal() throws IOException {
    // 模擬依賴方法
    // 模擬依賴方法: recordReader.read
    Record record1 = Mockito.mock(Record.class);
    Record record2 = Mockito.mock(Record.class);
    TunnelRecordReader recordReader = Mockito.mock(TunnelRecordReader.class);
    Mockito.doReturn(record1, record2, null).when(recordReader).read();
    // 模擬依賴方法: dataParser.apply
    Function<Record, Object> dataParser = CastUtils.cast(Mockito.mock(Function.class));
    Object object1 = new Object();
    Object object2 = new Object();
    Mockito.doReturn(object1).when(dataParser).apply(record1);
    Mockito.doReturn(object2).when(dataParser).apply(record2);

    // 調用測試方法
    int batchSize = 2;
    Consumer<List<Object>> dataStorage = CastUtils.cast(Mockito.mock(Consumer.class));
    OdpsHelper.readData(recordReader, batchSize, dataParser, dataStorage);

    // 驗證依賴方法
    // 驗證依賴方法: recordReader.read
    Mockito.verify(recordReader, Mockito.times(3)).read();
    // 驗證依賴方法: dataParser.apply
    Mockito.verify(dataParser).apply(record1);
    Mockito.verify(dataParser).apply(record2);
    // 驗證依賴方法: dataStorage.test
    ArgumentCaptor<List<Object>> dataListCaptor = CastUtils.cast(ArgumentCaptor.forClass(List.class));
    Mockito.verify(dataStorage).accept(dataListCaptor.capture());
    Assert.assertEquals("數據列表不一致", Arrays.asList(object1, object2), dataListCaptor.getValue());
}

執行該單元測試後,會出現以下錯誤:

java.lang.AssertionError: 數據列表不一致 expected:<[java.lang.Object@7eaa2bc6, java.lang.Object@6dae70f9]> but was:<[]>

因爲,我們捕獲的方法參數dataList只是一個對象引用,其數據內容早已被clear方法清除乾淨了。

8.3. 正確測試

對於這種情況,我們可以利用Mockito.doAnswer來保存這些臨時值,最後再進行統一的數據驗證。

/**
 * 測試: 讀取數據-正常
 * 
 * @throws IOException IO異常
 */
@Test
public void testReadDataWithNormal() throws IOException {
    // 模擬依賴方法
    // 模擬依賴方法: recordReader.read
    Record record1 = Mockito.mock(Record.class);
    Record record2 = Mockito.mock(Record.class);
    TunnelRecordReader recordReader = Mockito.mock(TunnelRecordReader.class);
    Mockito.doReturn(record1, record2, null).when(recordReader).read();
    // 模擬依賴方法: dataParser.apply
    Function<Record, Object> dataParser = CastUtils.cast(Mockito.mock(Function.class));
    Object object1 = new Object();
    Object object2 = new Object();
    Mockito.doReturn(object1).when(dataParser).apply(record1);
    Mockito.doReturn(object2).when(dataParser).apply(record2);
    // 模擬依賴方法: dataStorage.test
    List<Object> dataList = new ArrayList<>();
    Consumer<List<Object>> dataStorage = CastUtils.cast(Mockito.mock(Consumer.class));
    Mockito.doAnswer(invocation -> dataList.addAll(invocation.getArgument(0)))
        .when(dataStorage).accept(Mockito.anyList());

    // 調用測試方法
    int batchSize = 2;
    OdpsHelper.readData(recordReader, batchSize, dataParser, dataStorage);
    Assert.assertEquals("數據列表不一致", Arrays.asList(object1, object2), dataList);

    // 驗證依賴方法
    // 驗證依賴方法: recordReader.read
    Mockito.verify(recordReader, Mockito.times(3)).read();
    // 驗證依賴方法: dataParser.apply
    Mockito.verify(dataParser).apply(record1);
    Mockito.verify(dataParser).apply(record2);
    // 驗證依賴方法: dataStorage.test
    Mockito.verify(dataStorage).accept(Mockito.anyList());
}

9. 如何測試相同返回值的代碼分支

在業務代碼中,經常會出現不同的代碼分支返回相同值的情況。這個時候,僅通過驗證返回值是沒法判斷是否命中了對應的代碼分支的。那麼,這種情況如何進行單元測試呢?

9.1. 案例代碼

這裏,以灰度發佈服務判定方法爲例說明。

/**
 * 灰度發佈服務類
 */
@Slf4j
@Service
public class GrayReleaseService {
    /** 定義靜態常量 */
    /** 灰度發佈分子 */
    private static final long GRAY_NUMERATOR = 0L;
    /** 灰度發佈分母 */
    private static final long GRAY_DENOMINATOR = 10000L;

    /** 注入依賴對象 */
    /** 灰度發佈配置 */
    @Autowired
    private GrayReleaseConfig grayReleaseConfig;

    /**
     * 是否灰度發佈
     * 
     * @param key 主鍵
     * @param channel 渠道
     * @param userId 用戶標識
     * @param value 取值
     * @return 判斷結果
     */
    public boolean isGrayRelease(String key, String channel, String userId, Object value) {
        // 判斷灰度發佈取值
        if (Objects.isNull(value)) {
            log.info("命中灰度取值爲空");
            return false;
        }

        // 獲取灰度發佈映射
        Map<String, GrayReleaseItem> grayReleaseMap = grayReleaseConfig.getGrayReleaseMap();
        if (MapUtils.isEmpty(grayReleaseMap)) {
            log.info("命中灰度發佈映射爲空");
            return false;
        }

        // 獲取灰度發佈項
        GrayReleaseItem grayReleaseItem = grayReleaseMap.get(key);
        if (Objects.isNull(grayReleaseItem)) {
            log.info("命中灰度發佈映項爲空: key={}", key);
            return false;
        }

        // 判斷渠道白名單
        Set<String> channelWhiteSet = grayReleaseItem.getChannelWhiteSet();
        if (CollectionUtils.isNotEmpty(channelWhiteSet) && channelWhiteSet.contains(channel)) {
            log.info("命中渠道白名單灰度: key={}, channel={}", key, channel);
            return true;
        }

        // 判斷用戶白名單
        Set<String> userIdWhiteSet = grayReleaseItem.getUserIdWhiteSet();
        if (CollectionUtils.isNotEmpty(userIdWhiteSet) && userIdWhiteSet.contains(userId)) {
            log.info("命中用戶白名單灰度: key={}, userId={}", key, userId);
            return true;
        }

        // 判斷灰度發佈比例
        long grayNumerator = Optional.ofNullable(grayReleaseItem.getGrayNumerator()).orElse(GRAY_NUMERATOR);
        long grayDenominator = Optional.ofNullable(grayReleaseItem.getGrayDenominator()).orElse(GRAY_DENOMINATOR);
        boolean isGray = Math.abs(Objects.hashCode(value)) % grayDenominator <= grayNumerator;
        log.info("命中灰度發佈比例: key={}, value={}, isGray={}", key, value, isGray);
        return isGray;
    }
}

9.2. 普通測試法(不推薦)

這裏,只測試了命中渠道白名單的情況。

/**
 * 灰度發佈服務測試類
 */
@RunWith(MockitoJUnitRunner.class)
public class GrayReleaseServiceTest {
    /** 模擬依賴方法 */
    /** 灰度發佈配置 */
    @Mock
    private GrayReleaseConfig grayReleaseConfig;

    /** 定義測試對象 */
    /** 灰度發佈服務 */
    @InjectMocks
    private GrayReleaseService grayReleaseService;

    /**
     * 測試: 是否灰度發佈-命中渠道白名單
     */
    @Test
    public void testIsGrayReleaseWithChannelWhiteSet() {
        // 模擬依賴方法
        GrayReleaseItem grayReleaseItem = new GrayReleaseItem();
        grayReleaseItem.setChannelWhiteSet(Sets.newHashSet("alipay"));
        grayReleaseItem.setUserIdWhiteSet(Sets.newHashSet("123456"));
        Map<String, GrayReleaseItem> grayReleaseMap = Maps.newHashMap();
        grayReleaseMap.put("test", grayReleaseItem);
        Mockito.doReturn(grayReleaseMap).when(grayReleaseConfig).getGrayReleaseMap();

        // 調用測試方法
        String key = "test";
        String channel = "alipay";
        String userId = "123456";
        Object value = 1234567890L;
        Assert.assertTrue("判斷結果不爲真", grayReleaseService.isGrayRelease(key, channel, userId, value));

        // 驗證依賴方法
        Mockito.verify(grayReleaseConfig).getGrayReleaseMap();
    }
}

在一次代碼重構中,把"判斷用戶白名單"放在"判斷渠道白名單"之前,這個單元測試是無法檢測出來的。

9.3. 驗證測試法(推薦)

通過對mock方法的驗證,可以相對準確地確定命中的代碼分支。

/**
 * 灰度發佈服務測試類
 */
@RunWith(MockitoJUnitRunner.class)
public class GrayReleaseServiceTest {
    /** 模擬依賴方法 */
    /** 灰度發佈配置 */
    @Mock
    private GrayReleaseConfig grayReleaseConfig;

    /** 定義測試對象 */
    /** 灰度發佈服務 */
    @InjectMocks
    private GrayReleaseService grayReleaseService;

    /**
     * 測試: 是否灰度發佈-命中渠道白名單
     */
    @Test
    public void testIsGrayReleaseWithChannelWhiteSet() {
        // 模擬依賴方法
        // 模擬依賴方法: grayReleaseItem.getChannelWhiteSet
        GrayReleaseItem grayReleaseItem = Mockito.mock(GrayReleaseItem.class);
        Mockito.doReturn(Sets.newHashSet("alipay")).when(grayReleaseItem).getChannelWhiteSet();
        // 模擬依賴方法: grayReleaseItem.getUserIdWhiteSet
        Mockito.doReturn(Sets.newHashSet("123456")).when(grayReleaseItem).getUserIdWhiteSet();
        // 模擬依賴方法: grayReleaseConfig.getGrayReleaseMap
        Map<String, GrayReleaseItem> grayReleaseMap = Maps.newHashMap();
        grayReleaseMap.put("test", grayReleaseItem);
        Mockito.doReturn(grayReleaseMap).when(grayReleaseConfig).getGrayReleaseMap();

        // 調用測試方法
        String key = "test";
        String channel = "alipay";
        String userId = "123456";
        Object value = 1234567890L;
        Assert.assertTrue("判斷結果不爲真", grayReleaseService.isGrayRelease(key, channel, userId, value));

        // 驗證依賴方法
        // 驗證依賴方法: grayReleaseConfig.getGrayReleaseMap
        Mockito.verify(grayReleaseConfig).getGrayReleaseMap();
        // 驗證依賴方法: grayReleaseItem.getChannelWhiteSet
        Mockito.verify(grayReleaseItem).getChannelWhiteSet();

        // 驗證依賴對象
        Mockito.verifyNoMoreInteractions(grayReleaseConfig, grayReleaseItem);
    }
}

如果把"判斷用戶白名單"放在"判斷渠道白名單"之前,這個單元測試會報出以下錯誤日誌:

Wanted but not invoked:
grayReleaseItem.getChannelWhiteSet();

錯誤日誌告訴我們,grayReleaseItem.getChannelWhiteSet方法並沒有被調用,所以不可能命中渠道白名單代碼分支。

9.4. 日誌測試法(推薦)

對於有日誌打印的代碼,可以通過驗證日誌方法來確定命中的代碼分支,而且這種驗證方法是非常簡單直白的。如果沒有日誌打印,我們也可以添加日誌打印(可能會涉及日誌存儲成本的增加)。

/**
 * 灰度發佈服務測試類
 */
@RunWith(MockitoJUnitRunner.class)
public class GrayReleaseServiceTest {
    /** 模擬依賴方法 */
    /** 日誌器 */
    @Mock
    private Logger log;
    /** 灰度發佈配置 */
    @Mock
    private GrayReleaseConfig grayReleaseConfig;

    /** 定義測試對象 */
    /** 灰度發佈服務 */
    @InjectMocks
    private GrayReleaseService grayReleaseService;

    /**
     * 在測試前
     */
    @Before
    public void beforeTest() {
        FieldHelper.writeStaticFinalField(GrayReleaseService.class, "log", log);
    }

    /**
     * 測試: 是否灰度發佈-命中渠道白名單
     */
    @Test
    public void testIsGrayReleaseWithChannelWhiteSet() {
        // 模擬依賴方法
        GrayReleaseItem grayReleaseItem = new GrayReleaseItem();
        grayReleaseItem.setChannelWhiteSet(Sets.newHashSet("alipay"));
        grayReleaseItem.setUserIdWhiteSet(Sets.newHashSet("123456"));
        Map<String, GrayReleaseItem> grayReleaseMap = Maps.newHashMap();
        grayReleaseMap.put("test", grayReleaseItem);
        Mockito.doReturn(grayReleaseMap).when(grayReleaseConfig).getGrayReleaseMap();

        // 調用測試方法
        String key = "test";
        String channel = "alipay";
        String userId = "123456";
        Object value = 1234567890L;
        Assert.assertTrue("判斷結果不爲真", grayReleaseService.isGrayRelease(key, channel, userId, value));

        // 驗證依賴方法
        // 驗證依賴方法: grayReleaseConfig.getGrayReleaseMap
        Mockito.verify(grayReleaseConfig).getGrayReleaseMap();
        // 驗證依賴方法: log.info
        Mockito.verify(log).info("命中渠道白名單灰度: key={}, channel={}", key, channel);

        // 驗證依賴對象
        Mockito.verifyNoInteractions(log, grayReleaseConfig);
    }
}

如果把"判斷用戶白名單"放在"判斷渠道白名單"之前,這個單元測試會報出以下錯誤日誌:

Argument(s) are different! Wanted:
log.info(
    "命中渠道白名單灰度: key={}, channel={}",
    "test",
    "alipay"
);
-> at ...
Actual invocations have different arguments:
log.info(
    "命中用戶白名單灰度: key={}, userId={}",
    "test",
    "123456"
);

錯誤日誌告訴我們,我們期望命中渠道白名單灰度代碼分支,實際卻命中的是用戶白名單灰度代碼分支。

10. 如何測試多線程併發編程

Java多線程併發編程,就是通過多個線程同時執行多個任務來縮短執行時間、提高執行效率的方法。在JDK1.8中,新增了CompletableFuture類,實現了對任務編排的能力——可以輕鬆地組織不同任務的運行順序、規則及方式。

10.1. 案例代碼

這裏,以並行獲取批量交易訂單爲例說明。

/**
 * 交易訂單服務類
 */
@Slf4j
@Service
public class TradeOrderService {
    /** 定義靜態常量 */
    /** 等待時間(毫秒) */
    private static final long WAIT_TIME = 1000L;

    /** 注入依賴對象 */
    /** 交易訂單DAO */
    @Autowired
    private TradeOrderDAO tradeOrderDAO;
    /** 執行器服務 */
    @Autowired
    private ExecutorService executorService;

    /**
     * 獲取交易訂單列表
     * 
     * @param orderIdList 訂單標識列表
     * @return 交易訂單列表
     */
    public List<TradeOrderVO> getTradeOrders(List<Long> orderIdList) {
        // 檢查訂單標識列表
        if (CollectionUtils.isEmpty(orderIdList)) {
            return Collections.emptyList();
        }

        // 獲取交易訂單期望
        List<CompletableFuture<TradeOrderVO>> futureList = orderIdList.stream()
            .map(this::getTradeOrder).collect(Collectors.toList());

        // 聚合交易訂單期望
        CompletableFuture<List<TradeOrderVO>> joinFuture =
            CompletableFuture.allOf(futureList.toArray(new CompletableFuture[0]))
                .thenApply(v -> futureList.stream().map(CompletableFuture::join).collect(Collectors.toList()));

        // 返回交易訂單列表
        try {
            return joinFuture.get(WAIT_TIME, TimeUnit.MILLISECONDS);
        } catch (InterruptedException e) {
            Thread.currentThread().interrupt();
            log.warn("獲取訂單中斷異常", e);
            throw new BusinessException("獲取訂單中斷異常", e);
        } catch (ExecutionException | TimeoutException | RuntimeException e) {
            log.warn("獲取訂單其它異常", e);
            throw new BusinessException("獲取訂單其它異常", e);
        }
    }

    /**
     * 獲取交易訂單
     * 
     * @param orderId 訂單標識
     * @return 交易訂單期望
     */
    private CompletableFuture<TradeOrderVO> getTradeOrder(Long orderId) {
        return CompletableFuture.supplyAsync(() -> tradeOrderDAO.get(orderId), executorService)
            .thenApply(TradeOrderService::convertTradeOrder);
    }

    /**
     * 轉化交易訂單
     * 
     * @param tradeOrder 交易訂單DO
     * @return 交易訂單VO
     */
    private static TradeOrderVO convertTradeOrder(TradeOrderDO tradeOrder) {
        TradeOrderVO tradeOrderVO = new TradeOrderVO();
        tradeOrderVO.setId(tradeOrder.getId());
        // ...
        return tradeOrderVO;
    }
}

10.2. 測試用例

對於多線程併發編程,如果採集mock靜態方法的方式進行單元測試,將會使單元測試用例變得非常複雜。通過實踐總結,採用注入線程池的方式,將會使單元測試用例變得非常簡單。

/**
 * 交易訂單服務測試類
 */
@RunWith(MockitoJUnitRunner.class)
public class TradeOrderServiceTest {

    /** 定義靜態常量 */
    /** 資源路徑 */
    private static final String RESOURCE_PATH = "testTradeOrderService/";

    /** 模擬依賴對象 */
    /** 交易訂單DAO */
    @Mock
    private TradeOrderDAO tradeOrderDAO;
    /** 執行器服務 */
    @Spy
    private ExecutorService executorService = Executors.newFixedThreadPool(10);

    /** 定義測試對象 */
    /** 交易訂單服務 */
    @InjectMocks
    private TradeOrderService tradeOrderService;

    /**
     * 測試: 獲取交易訂單列表-正常
     */
    @Test
    public void testGetTradeOrdersWithNormal() {
        // 模擬依賴方法
        // 模擬依賴方法: tradeOrderDAO.get
        String path = RESOURCE_PATH + "testGetTradeOrdersWithNormal/";
        String text = ResourceHelper.getResourceAsString(getClass(), path + "tradeOrderMap.json");
        Map<Long, TradeOrderDO> tradeOrderMap = JSON.parseObject(text, new TypeReference<Map<Long, TradeOrderDO>>() {});
        Mockito.doAnswer(invocation -> tradeOrderMap.get(invocation.getArgument(0)))
            .when(tradeOrderDAO).get(Mockito.anyLong());

        // 調用測試方法
        text = ResourceHelper.getResourceAsString(getClass(), path + "orderIdList.json");
        List<Long> orderIdList = JSON.parseArray(text, Long.class);
        List<TradeOrderVO> tradeOrderList = tradeOrderService.getTradeOrders(orderIdList);
        text = ResourceHelper.getResourceAsString(getClass(), path + "tradeOrderList.json");
        Assert.assertEquals("交易訂單列表不一致", text, JSON.toJSONString(tradeOrderList));

        // 驗證依賴方法
        // 驗證依賴方法: tradeOrderDAO.get
        ArgumentCaptor<Long> orderIdCaptor = ArgumentCaptor.forClass(Long.class);
        Mockito.verify(tradeOrderDAO, Mockito.atLeastOnce()).get(orderIdCaptor.capture());
        Assert.assertEquals("訂單標識列表不一致", orderIdList, orderIdCaptor.getAllValues());
    }
}

11. 附錄

11.1. 引入Maven單測包

<dependency>
    <groupId>junit</groupId>
    <artifactId>junit</artifactId>
    <version>4.13.1</version>
    <scope>test</scope>
</dependency>
<dependency>
    <groupId>org.mockito</groupId>
    <artifactId>mockito-core</artifactId>
    <version>3.3.3</version>
    <scope>test</scope>
</dependency>
<dependency>
    <groupId>org.powermock</groupId>
    <artifactId>powermock-module-junit4</artifactId>
    <version>2.0.9</version>
    <scope>test</scope>
</dependency>
<dependency>
    <groupId>org.powermock</groupId>
    <artifactId>powermock-api-mockito2</artifactId>
    <version>2.0.9</version>
    <scope>test</scope>
</dependency>

11.2. 使用到的工具方法

11.2.1. 以字符串方式獲取資源

ResourceHelper.getResourceAsString(以字符串方式獲取資源)通過Apache的IOUtils.toString方法實現,提供以字符串方式獲取資源的功能。

/**
 * 資源輔助類
 */
public final class ResourceHelper {
    /**
     * 以字符串方式獲取資源
     * 
     * @param clazz 類
     * @param name 資源名稱
     * @return 字符串
     */
    public static <T> String getResourceAsString(Class<T> clazz, String name) {
        try (InputStream is = clazz.getResourceAsStream(name)) {
            return IOUtils.toString(is, StandardCharsets.UTF_8);
        } catch (IOException e) {
            throw new IllegalArgumentException(String.format("以字符串方式獲取資源(%s)異常", name), e);
        }
    }
}

11.2.2. 寫入靜態常量字段

FieldHelper.writeStaticFinalField(寫入靜態常量字段)通過Apache的FieldUtils相關方法實現,提供寫入靜態常量字段的功能。

/**
 * 字段輔助類
 */
public final class FieldHelper {
    /**
     * 寫入靜態常量字段
     * 
     * @param clazz 類
     * @param fieldName 字段名稱
     * @param fieldValue 字段取值
     */
    public static void writeStaticFinalField(Class<?> clazz, String fieldName, Object fieldValue) {
        try {
            Field field = clazz.getDeclaredField(fieldName);
            FieldUtils.removeFinalModifier(field);
            FieldUtils.writeStaticField(field, fieldValue, true);
        } catch (NoSuchFieldException | IllegalAccessException e) {
            throw new UnsupportedOperationException("寫入靜態常量字段異常", e);
        }
    }
}

後記

其實在很久之前,有人就希望我整理一個單元測試案例庫。我遲遲沒有行動,主要原因如下:

  1. 單元測試案例是無窮無盡的,如何系統化地呈現給讀者是個大工程;
  2. 單元測試案例必須典型、合理、有意義,如何構建這些案例也很消耗精力。

現在,終於鼓起勇氣整理這篇《Java單元測試典型案例集錦》,主要是因爲單元測試案例是單元測試重要的一環,也是爲了給我的Java單元測試系列文章劃上一個完美的句號。

最後,根據本文主題吟詩一首:

《單測案例》單元測試百家說,
案例總結方法多。
芳草滿園花滿目,
綠肥紅瘦自斟酌。

原文鏈接

本文爲阿里雲原創內容,未經允許不得轉載。

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