深入理解單元測試:技巧與優(yōu)秀實踐
之前分享過如何快速上手開源項目以及如何在開源項目里做集成測試,但還沒有講過具體的實操。
今天來詳細講講如何寫單元測試。
什么情況下需要單元測試
這個大家應該是有共識的,對于一些功能單一、核心邏輯、同時變化不頻繁的公開函數才有必要做單元測試。
對于業(yè)務復雜、鏈路繁瑣但也是核心流程的功能通常建議做 e2e 測試,這樣可以保證最終測試結果的一致性。
具體案例
我們都知道單測的主要目的是模擬執(zhí)行你寫過的每一行代碼,目的就是要覆蓋到主要分支,做到自己的每一行代碼都心中有數。
下面以 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 提供的一個注解,里面?zhèn)魅氲?nbsp;MockitoExtension.class 是我們單測 mock 常用的框架。
簡單來說就是告訴 Junit5 ,當前的測試類會使用 mockito 作為擴展運行,從而可以 mock 我們運行時的一些對象。
@InjectMocks
private UdpCollectImpl udpCollect;
@InjectMocks 也是 mockito 這個庫提供的注解,通常用于聲明需要測試的類。
@InjectMocks
private AbstractCollect udpCollect;
需要注意的是這個注解必須是一個具體的類,不可以是一個抽象類或者是接口。
其實當我們了解了他的原理就能知道具體的原因:
當我們 debug 運行時會發(fā)現 udpCollect 對象是有值的,而如果我們去掉這個注解 @InjectMocks 再運行就會拋空指針異常。
因為并沒有初始化 udpCollect
而使用 @InjectMocks注解后,mockito 框架會自動給 udpCollect 注入一個代理對象;而如果是一個接口或者是抽象類,mockito 框架是無法知道創(chuàng)建具體哪個對象。
當然在這個簡單場景下,我們直接 udpCollect = new UdpCollectImpl() 進行測試也是可以的。
配合 jacoco 輸出單測覆蓋率
在 IDEA 中我們可以以 Coverage 的方式運行,IDEA 就將我們的單測覆蓋情況顯示在源代碼中,綠色的部分就代表在實際在運行時執(zhí)行到的地方。
我們也可以在 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 的評論區(qū)加上測試報告。
需要從 Codecov 里將你項目的 token 添加到 repo 的 環(huán)境變量中即可。
具體可以參考這個 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 創(chuàng)建一個他們的對象,并且注入到需要被測試的 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 在源碼中是通過一個靜態(tài)函數創(chuàng)建的。
模擬靜態(tài)函數
我依稀記得在我剛接觸 mockito 的 16~17 年那段時間還不支持模擬調用靜態(tài)函數,不過如今已經支持了:
@Mock
private RedisClusterClient client;
Mockito.mockStatic(RedisClusterClient.class).when(()->RedisClusterClient.create(Mockito.any(ClientResources.class),
Mockito.any(RedisURI.class))).thenReturn(client);
這樣就可以模擬靜態(tài)函數的返回值了,但前提是返回的 client 需要使用 @Mock 注解。
模擬構造函數
有時候我們也需要模擬構造函數,從而可以模擬后續(xù)這個對象的行為。
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
修改源碼
如果有些測試場景下需要獲取內部變量方便后續(xù)的測試,但是該測試類也沒有提供獲取變量的函數,我們就只有修改源碼來配合測試了。
比如這個 PR:
當然如果只是給測試環(huán)境下使用的函數或變量,我們可以加上 @VisibleForTesting注解標明一下,這個注解沒有其他作用,可以讓后續(xù)的維護者更清楚的知道這是做什么用的。
集成測試
單元測試只能測試一些功能單一的函數,要保證整個軟件的質量僅依賴單測是不夠的,我們還需要集成測試。
通常是需要對外提供服務的開源項目都需要集成測試:
- Pulsar
- Kafka
- Dubbo 等
以我接觸到的服務型應用主要分為兩類:一個是 Java 應用一個是 Golang 應用。
Golang
Golang 因為工具鏈沒有 Java 那么強大,所以大部分的集成測試的功能都是通過編寫 Makefile 和 shell 腳本實現的。
還是以我熟悉的 Pulsar 的 go-client 為例,它在 GitHub 的集成測試是通過 GitHub action 觸發(fā)的,定義如下:
最終調用的是 Makefile 中的 test 命令,并且把需要測試的 Golang 版本傳入進去。
Dockerfile:
這個鏡像簡單來說就是將 Pulsar 的鏡像作為基礎運行鏡像(這里面包含了 Pulsar 的服務端),然后將這個 pulsar-client-go 的代碼復制進去編譯。
接著運行:
cd /pulsar/pulsar-client-go && ./scripts/run-ci.sh
也就是測試腳本。
測試腳本的邏輯也很簡單:
- 啟動 pulsar 服務端
- 運行測試代碼 因為所有的測試代碼里連接服務端的地址都是 localhost,所以可以直接連接。
通過這里的 action 日志可以跟蹤所有的運行情況。
Java
Java 因為工具鏈強大,所以集成測試幾乎不需要用 Makefile 和腳本配合執(zhí)行。
還是以 Pulsar 為例,它的集成測試是需要模擬在本地啟動一個服務端(因為 Pulsar 的服務端源碼和測試代碼都是 Java 寫的,更方便做測試),然后再運行測試代碼。
這個的好處是任何一個單測都可以在本地直接運行,而 Go 的代碼還需要先在本地啟動一個服務端,測試起來比較麻煩。
來看看它是如何實現的,我以其中一個 BrokerClientIntegrationTest為例:
會在單測啟動的時候先啟動服務端。
最終會調用 PulsarTestContext 的 build 函數啟動 broker(服務端),而執(zhí)行單測也只需要使用 mvn test 就可以自動觸發(fā)這些單元測試。
只是每一個單測都需要啟停服務端,所以要把 Pulsar 的所有單測跑完通常需要 1~2 個小時。
以上就是日常編寫單測可能會碰到的場景,希望對大家有所幫助。