深入理解單元測試:技巧與最佳實踐

crossoverJie發表於2024-08-15

之前分享過如何快速上手開源專案以及如何在開源專案裡做整合測試,但還沒有講過具體的實操。

今天來詳細講講如何寫單元測試。

🤔什麼情況下需要單元測試

這個大家應該是有共識的,對於一些功能單一、核心邏輯、同時變化不頻繁的公開函式才有必要做單元測試。

對於業務複雜、鏈路繁瑣但也是核心流程的功能通常建議做 e2e 測試,這樣可以保證最終測試結果的一致性。

💀具體案例

我們都知道單測的主要目的是模擬執行你寫過的每一行程式碼,目的就是要覆蓋到主要分支,做到自己的每一行程式碼都心中有數。

下面以 Apache HertzBeat 的一些單測為例,講解如何編寫一個單元測試。


先以一個最簡單的 org.apache.hertzbeat.collector.collect.udp.UdpCollectImpl#preCheck 函式測試為例。
這裡的 preCheck 函式就是簡單的檢測做引數校驗。
測試時只要我們手動將 metrics 設定為 null 就可以進入這個 if 條件。

@ExtendWith(MockitoExtension.class)
class UdpCollectImplTest {

    @InjectMocks
    private UdpCollectImpl udpCollect;

    @Test
    void testPreCheck() {
        List<String> aliasField = new ArrayList<>();
        aliasField.add("responseTime");
        Metrics metrics = new Metrics();
        metrics.setAliasFields(aliasField);
        assertThrows(IllegalArgumentException.class, () -> udpCollect.preCheck(metrics));
    }
}    

來看具體的單測程式碼,我們一行行的來看:

@ExtendWith(MockitoExtension.class)Junit5 提供的一個註解,裡面傳入的 MockitoExtension.class 是我們單測 mock 常用的框架。

簡單來說就是告訴 Junit5 ,當前的測試類會使用 mockito 作為擴充套件執行,從而可以 mock 我們執行時的一些物件。


@InjectMocks  
private UdpCollectImpl udpCollect;

@InjectMocks 也是 mockito 這個庫提供的註解,通常用於宣告需要測試的類。

@InjectMocks  
private AbstractCollect udpCollect;

需要注意的是這個註解必須是一個具體的類,不可以是一個抽象類或者是介面。

其實當我們瞭解了他的原理就能知道具體的原因:

當我們 debug 執行時會發現 udpCollect 物件是有值的,而如果我們去掉這個註解 @InjectMocks 再執行就會拋空指標異常。

因為並沒有初始化 udpCollect

而使用 @InjectMocks註解後,mockito 框架會自動給 udpCollect 注入一個代理物件;而如果是一個介面或者是抽象類,mockito 框架是無法知道建立具體哪個物件。

當然在這個簡單場景下,我們直接 udpCollect = new UdpCollectImpl() 進行測試也是可以的。

🔥配合 jacoco 輸出單測覆蓋率


在 IDEA 中我們可以以 Coverage 的方式執行,IDEA 就將我們的單測覆蓋情況顯示在原始碼中,綠色的部分就代表在實際在執行時執行到的地方。

我們也可以在 maven 專案中整合 jacoco,只需要新增一個根目錄的 pom.xml 中新增一個 plugin 就可以了。

<plugin>  
    <groupId>org.jacoco</groupId>  
    <artifactId>jacoco-maven-plugin</artifactId>  
    <version>${jacoco-maven-plugin.version}</version>  
    <executions>  
        <execution>  
            <goals>  
                <goal>prepare-agent</goal>  
            </goals>  
        </execution>  
        <execution>  
            <id>report</id>  
            <phase>test</phase>  
            <goals>  
                <goal>report</goal>  
            </goals>  
        </execution>  
    </executions>  
</plugin>

之後執行 mvn test 就會在 target 目錄下生成測試報告了。

我們還可以在 GitHub 的 CI 中整合 Codecov,他會直接讀取 jacoco 的測試資料,並且在 PR 的評論區加上測試報告。

需要從 Codecov 裡將你專案的 token 新增到 repo 的 環境變數中即可。

具體可以參考這個 PR:https://github.com/apache/hertzbeat/pull/1985

☀️複雜一點的單測

剛才展示的是一個非常簡單的場景,下面來看看稍微複雜的。

我們以這個單測為例:
org.apache.hertzbeat.collector.collect.redis.RedisClusterCollectImplTest

@ExtendWith(MockitoExtension.class)
public class RedisClusterCollectImplTest {
    
    @InjectMocks
    private RedisCommonCollectImpl redisClusterCollect;


    @Mock
    private StatefulRedisClusterConnection<String, String> connection;

    @Mock
    private RedisAdvancedClusterCommands<String, String> cmd;

    @Mock
    private RedisClusterClient client;
}

這個單測在剛才的基礎上多了一個 @Mock 的註解。

這是因為我們需要測試的 RedisCommonCollectImpl 類中需要依賴 StatefulRedisClusterConnection/RedisAdvancedClusterCommands/RedisClusterClient 這幾個類所提供的服務。

單測的時候需要使用 mockito 建立一個他們的物件,並且注入到需要被測試的 RedisCommonCollectImpl類中。

不然我們就需要準備單測所需要的資源,比如可以使用的 Redis、MySQL 等。

🚤模擬行為

只是注入進去還不夠,我們還需要模擬它的行為:

  • 比如呼叫某個函式可以模擬返回資料
  • 模擬函式呼叫丟擲異常
  • 模擬函式呼叫耗時

這裡以最常見的模擬函式返回為例:

String clusterNodes = connection.sync().clusterInfo();

在原始碼裡看到會使用 connection 的 clusterInfo() 函式返回叢集資訊。

        String clusterKnownNodes = "2";
        String clusterInfoTemp = """
                cluster_slots_fail:0
                cluster_known_nodes:%s
                """;
        String clusterInfo = String.format(clusterInfoTemp, clusterKnownNodes);
        Mockito.when(cmd.clusterInfo()).thenReturn(clusterInfo);        

此時我們就可以使用 Mockito.when().thenReturn() 來模擬這個函式的返回資料。

而其中的 cmd 自然也是需要模擬返回的:

        Mockito.mockStatic(RedisClusterClient.class).when(()->RedisClusterClient.create(Mockito.any(ClientResources.class),
                Mockito.any(RedisURI.class))).thenReturn(client);
        Mockito.when(client.connect()).thenReturn(connection);
        
        Mockito.when(connection.sync()).thenReturn(cmd);
        Mockito.when(cmd.info(metrics.getName())).thenReturn(info);
        Mockito.when(cmd.clusterInfo()).thenReturn(clusterInfo);

cmd 是透過 Mockito.when(connection.sync()).thenReturn(cmd);返回的,而 connection 又是從 client.connect() 返回的。

最終就像是套娃一樣,client 在原始碼中是透過一個靜態函式建立的。

⚡模擬靜態函式

我依稀記得在我剛接觸 mockito 的 16~17 年那段時間還不支援模擬呼叫靜態函式,不過如今已經支援了:

@Mock  
private RedisClusterClient client;


Mockito.mockStatic(RedisClusterClient.class).when(()->RedisClusterClient.create(Mockito.any(ClientResources.class),  
        Mockito.any(RedisURI.class))).thenReturn(client);

這樣就可以模擬靜態函式的返回值了,但前提是返回的 client 需要使用 @Mock 註解。

💥模擬建構函式


有時候我們也需要模擬建構函式,從而可以模擬後續這個物件的行為。

        MockedConstruction<FTPClient> mocked = Mockito.mockConstruction(FTPClient.class,
                (ftpClient, context) -> {
                    Mockito.doNothing().when(ftpClient).connect(ftpProtocol.getHost(),
                            Integer.parseInt(ftpProtocol.getPort()));

                    Mockito.doAnswer(invocationOnMock -> true).when(ftpClient)
                            .login(ftpProtocol.getUsername(), ftpProtocol.getPassword());
                    Mockito.when(ftpClient.changeWorkingDirectory(ftpProtocol.getDirection())).thenReturn(isActive);
                    Mockito.doNothing().when(ftpClient).disconnect();
                });

可以使用 Mockito.mockConstruction 來進行模擬,該物件的一些行為就直接寫在這個模擬函式內。

需要注意的是返回的 mocked 物件需要記得關閉。

不需要 Mock

當然也不是所有的場景都需要 mock

比如剛才第一個場景,沒有依賴任何外部服務時就不需要 mock

類似於這個 PR 裡的測試,只是依賴一個基礎的記憶體快取元件,就沒必要 mock,但如果依賴的是 Redis 快取元件還是需要 mock 的。
https://github.com/apache/hertzbeat/pull/2021

⚙️修改原始碼

如果有些測試場景下需要獲取內部變數方便後續的測試,但是該測試類也沒有提供獲取變數的函式,我們就只有修改原始碼來配合測試了。

比如這個 PR

當然如果只是給測試環境下使用的函式或變數,我們可以加上 @VisibleForTesting註解標明一下,這個註解沒有其他作用,可以讓後續的維護者更清楚的知道這是做什麼用的。

📈整合測試

單元測試只能測試一些功能單一的函式,要保證整個軟體的質量僅依賴單測是不夠的,我們還需要整合測試。

通常是需要對外提供服務的開源專案都需要整合測試:

  • Pulsar
  • Kafka
  • Dubbo 等

以我接觸到的服務型應用主要分為兩類:一個是 Java 應用一個是 Golang 應用。

🐳Golang

Golang 因為工具鏈沒有 Java 那麼強大,所以大部分的整合測試的功能都是透過編寫 Makefile 和 shell 指令碼實現的。

還是以我熟悉的 Pulsar 的 go-client 為例,它在 GitHub 的整合測試是透過 GitHub action 觸發的,定義如下:

最終呼叫的是 Makefile 中的 test 命令,並且把需要測試的 Golang 版本傳入進去。

Dockerfile

這個映象簡單來說就是將 Pulsar 的映象作為基礎執行映象(這裡麵包含了 Pulsar 的服務端),然後將這個 pulsar-client-go 的程式碼複製進去編譯。

接著執行:

cd /pulsar/pulsar-client-go && ./scripts/run-ci.sh

也就是測試指令碼。

測試指令碼的邏輯也很簡單:

  • 啟動 pulsar 服務端
  • 執行測試程式碼
    因為所有的測試程式碼裡連線服務端的地址都是 localhost,所以可以直接連線。

透過這裡的 action 日誌可以跟蹤所有的執行情況。

☕Java

Java 因為工具鏈強大,所以整合測試幾乎不需要用 Makefile 和指令碼配合執行。

還是以 Pulsar 為例,它的整合測試是需要模擬在本地啟動一個服務端(因為 Pulsar 的服務端原始碼和測試程式碼都是 Java 寫的,更方便做測試),然後再執行測試程式碼。

這個的好處是任何一個單測都可以在本地直接執行,而 Go 的程式碼還需要先在本地啟動一個服務端,測試起來比較麻煩。

來看看它是如何實現的,我以其中一個 BrokerClientIntegrationTest為例:


會在單測啟動的時候先啟動服務端。

最終會呼叫 PulsarTestContextbuild 函式啟動 broker(服務端),而執行單測也只需要使用 mvn test 就可以自動觸發這些單元測試。

只是每一個單測都需要啟停服務端,所以要把 Pulsar 的所有單測跑完通常需要 1~2 個小時。

以上就是日常編寫單測可能會碰到的場景,希望對大家有所幫助。

相關文章