如何有效地對 CompletableFuture 進行單元測試
一、簡介
CompletableFuture
是 Java 非同步程式設計的強大工具。它提供了一種將非同步任務連結在一起並處理其結果的便捷方法。它通常用於需要執行非同步操作,並且需要在稍後階段使用或處理其結果的情況。
然而,由於CompletableFuture
的非同步特性,單元測試可能具有挑戰性。依賴順序執行的傳統測試方法通常無法捕捉非同步程式碼的細微差別。在本教程中,我們將討論如何使用兩種不同的方法有效地對CompletableFuture
進行單元測試:黑盒測試和基於狀態的測試。
2.測試非同步程式碼的挑戰
非同步程式碼由於**其非阻塞和並發執行而**帶來了挑戰,給傳統的測試方法帶來了困難。這些挑戰包括:
- 時序問題:非同步操作將時序依賴性引入程式碼中,導致難以控制執行流程並驗證程式碼在特定時間點的行為。依賴順序執行的傳統測試方法可能不適合非同步程式碼。
- 異常處理:非同步操作可能會引發異常,確保程式碼優雅地處理這些異常並且不會默默地失敗至關重要。單元測試應涵蓋各種場景以驗證異常處理機制。
- 競爭條件:非同步程式碼可能會導致競爭條件,即多個執行緒或程序嘗試同時存取或修改共享數據,可能會導致意外結果。
- 測試覆蓋率:由於互動的複雜性和潛在的不確定性結果,實現非同步程式碼的全面測試覆蓋率可能具有挑戰性。
3. 黑盒測試
黑盒測試著重於測試程式碼的外部行為,而不了解其內部實作。這種方法適合從使用者的角度驗證非同步程式碼行為。測試人員只知道程式碼的輸入和預期輸出。
在使用黑盒測試來測試CompletableFuture
時,我們優先考慮以下方面:
- 成功完成:驗證
CompletableFuture
是否成功完成,並回傳預期結果。 - 異常處理:驗證
CompletableFuture
是否正常處理異常,以防止靜默失敗。 - 超時:確保
CompletableFuture
在遇到超時時能如預期運作。
我們可以使用像 Mockito 這樣的模擬框架來模擬被測CompletableFuture
的依賴關係。這將使我們能夠隔離CompletableFuture
並在受控環境中測試其行為。
3.1.被測系統
我們將測試一個名為processAsync()
的方法,該方法封裝了非同步資料檢索和組合過程。此方法接受Microservice
物件清單作為輸入並傳回CompletableFuture<String>
。每個Microservice
物件代表一個能夠執行非同步檢索操作的微服務。
processAsync()
使用兩個輔助方法fetchDataAsync()
和combineResults()
來處理非同步資料擷取和組合任務:
CompletableFuture<String> processAsync(List<Microservice> microservices) {
List<CompletableFuture<String>> dataFetchFutures = fetchDataAsync(microservices);
return combineResults(dataFetchFutures);
}
fetchDataAsync()
方法流過微服務列表,為每個微服務呼叫retrieveAsync()
,並傳回CompletableFuture<String>
列表:
private List<CompletableFuture<String>> fetchDataAsync(List<Microservice> microservices) {
return microservices.stream()
.map(client -> client.retrieveAsync(""))
.collect(Collectors.toList());
}
combineResults()
方法使用CompletableFuture.allOf()
等待清單中的所有 future 完成。完成後,它會映射 future、連接結果並傳回一個字串:
private CompletableFuture<String> combineResults(List<CompletableFuture<String>> dataFetchFutures) {
return CompletableFuture.allOf(dataFetchFutures.toArray(new CompletableFuture[0]))
.thenApply(v -> dataFetchFutures.stream()
.map(future -> future.exceptionally(ex -> {
throw new CompletionException(ex);
})
.join())
.collect(Collectors.joining()));
}
3.2.測試案例:驗證資料檢索和組合是否成功
此測試案例驗證processAsync()
方法是否正確從多個微服務檢索資料並將結果組合到單一字串中:
@Test
public void givenAsyncTask_whenProcessingAsyncSucceed_thenReturnSuccess()
throws ExecutionException, InterruptedException {
Microservice mockMicroserviceA = mock(Microservice.class);
Microservice mockMicroserviceB = mock(Microservice.class);
when(mockMicroserviceA.retrieveAsync(any())).thenReturn(CompletableFuture.completedFuture("Hello"));
when(mockMicroserviceB.retrieveAsync(any())).thenReturn(CompletableFuture.completedFuture("World"));
CompletableFuture<String> resultFuture = processAsync(List.of(mockMicroserviceA, mockMicroserviceB));
String result = resultFuture.get();
assertEquals("HelloWorld", result);
}
3.3.測試案例:驗證微服務拋出異常時的異常處理
此測試案例驗證當其中一個微服務引發異常時, processAsync()
方法是否引發ExecutionException
。它還斷言異常訊息與微服務拋出的異常相同:
@Test
public void givenAsyncTask_whenProcessingAsyncWithException_thenReturnException()
throws ExecutionException, InterruptedException {
Microservice mockMicroserviceA = mock(Microservice.class);
Microservice mockMicroserviceB = mock(Microservice.class);
when(mockMicroserviceA.retrieveAsync(any())).thenReturn(CompletableFuture.completedFuture("Hello"));
when(mockMicroserviceB.retrieveAsync(any()))
.thenReturn(CompletableFuture.failedFuture(new RuntimeException("Simulated Exception")));
CompletableFuture<String> resultFuture = processAsync(List.of(mockMicroserviceA, mockMicroserviceB));
ExecutionException exception = assertThrows(ExecutionException.class, resultFuture::get);
assertEquals("Simulated Exception", exception.getCause().getMessage());
}
3.4.測試案例:驗證組合結果超過逾時時的逾時處理
此測試案例嘗試在指定的 300 毫秒逾時內從processAsync()
方法檢索組合結果。它斷言超過超時時拋出TimeoutException
:
@Test
public void givenAsyncTask_whenProcessingAsyncWithTimeout_thenHandleTimeoutException()
throws ExecutionException, InterruptedException {
Microservice mockMicroserviceA = mock(Microservice.class);
Microservice mockMicroserviceB = mock(Microservice.class);
Executor delayedExecutor = CompletableFuture.delayedExecutor(200, TimeUnit.MILLISECONDS);
when(mockMicroserviceA.retrieveAsync(any()))
.thenReturn(CompletableFuture.supplyAsync(() -> "Hello", delayedExecutor));
Executor delayedExecutor2 = CompletableFuture.delayedExecutor(500, TimeUnit.MILLISECONDS);
when(mockMicroserviceB.retrieveAsync(any()))
.thenReturn(CompletableFuture.supplyAsync(() -> "World", delayedExecutor2));
CompletableFuture<String> resultFuture = processAsync(List.of(mockMicroserviceA, mockMicroserviceB));
assertThrows(TimeoutException.class, () -> resultFuture.get(300, TimeUnit.MILLISECONDS));
}
上面的程式碼使用CompletableFuture.delayedExecutor()
來建立執行器,這些執行器將分別延遲retrieveAsync()
呼叫的完成 200 和 500 毫秒。這模擬了微服務引起的延遲,並允許測試驗證processAsync()
方法是否正確處理逾時。
4. 基於狀態的測試
基於狀態的測試著重於驗證程式碼執行時的狀態轉換。這種方法對於測試非同步程式碼特別有用,因為它允許測試人員追蹤程式碼在不同狀態下的進度並確保其正確轉換。
例如,我們可以驗證當非同步任務成功完成時, CompletableFuture
是否轉換為已完成狀態。否則,當發生異常時,會轉變為失敗狀態,或任務因中斷而被取消。
4.1.測試案例:成功完成後驗證狀態
此測試案例驗證當CompletableFuture
實例的所有組成部分均已成功完成時, CompletableFuture
實例是否會轉換為完成狀態:
@Test
public void givenCompletableFuture_whenCompleted_thenStateIsDone() {
Executor delayedExecutor = CompletableFuture.delayedExecutor(200, TimeUnit.MILLISECONDS);
CompletableFuture<String> cf1 = CompletableFuture.supplyAsync(() -> "Hello", delayedExecutor);
CompletableFuture<String> cf2 = CompletableFuture.supplyAsync(() -> " World");
CompletableFuture<String> cf3 = CompletableFuture.supplyAsync(() -> "!");
CompletableFuture<String>[] cfs = new CompletableFuture[] { cf1, cf2, cf3 };
CompletableFuture<Void> allCf = CompletableFuture.allOf(cfs);
assertFalse(allCf.isDone());
allCf.join();
String result = Arrays.stream(cfs)
.map(CompletableFuture::join)
.collect(Collectors.joining());
assertFalse(allCf.isCancelled());
assertTrue(allCf.isDone());
assertFalse(allCf.isCompletedExceptionally());
}
4.2.測試案例:異常完成後驗證狀態
此測試案例驗證當組成CompletableFuture
實例cf2
異常完成時,並且allCf
CompletableFuture
轉換到異常狀態:
@Test
public void givenCompletableFuture_whenCompletedWithException_thenStateIsCompletedExceptionally()
throws ExecutionException, InterruptedException {
Executor delayedExecutor = CompletableFuture.delayedExecutor(200, TimeUnit.MILLISECONDS);
CompletableFuture<String> cf1 = CompletableFuture.supplyAsync(() -> "Hello", delayedExecutor);
CompletableFuture<String> cf2 = CompletableFuture.failedFuture(new RuntimeException("Simulated Exception"));
CompletableFuture<String> cf3 = CompletableFuture.supplyAsync(() -> "!");
CompletableFuture<String>[] cfs = new CompletableFuture[] { cf1, cf2, cf3 };
CompletableFuture<Void> allCf = CompletableFuture.allOf(cfs);
assertFalse(allCf.isDone());
assertFalse(allCf.isCompletedExceptionally());
assertThrows(CompletionException.class, allCf::join);
assertTrue(allCf.isCompletedExceptionally());
assertTrue(allCf.isDone());
assertFalse(allCf.isCancelled());
}
4.3.測試案例:任務取消後驗證狀態
此測試案例驗證當使用cancel(true)
方法取消allCf
CompletableFuture
時,它會轉換為已取消狀態:
@Test
public void givenCompletableFuture_whenCancelled_thenStateIsCancelled()
throws ExecutionException, InterruptedException {
Executor delayedExecutor = CompletableFuture.delayedExecutor(200, TimeUnit.MILLISECONDS);
CompletableFuture<String> cf1 = CompletableFuture.supplyAsync(() -> "Hello", delayedExecutor);
CompletableFuture<String> cf2 = CompletableFuture.supplyAsync(() -> " World");
CompletableFuture<String> cf3 = CompletableFuture.supplyAsync(() -> "!");
CompletableFuture<String>[] cfs = new CompletableFuture[] { cf1, cf2, cf3 };
CompletableFuture<Void> allCf = CompletableFuture.allOf(cfs);
assertFalse(allCf.isDone());
assertFalse(allCf.isCompletedExceptionally());
allCf.cancel(true);
assertTrue(allCf.isCancelled());
assertTrue(allCf.isDone());
}
5. 結論
總之,由於其非同步特性,單元測試CompletableFuture
可能具有挑戰性。然而,它是編寫健壯且可維護的非同步程式碼的重要組成部分。透過使用黑盒和基於狀態的測試方法,我們可以評估CompletableFuture
程式碼在各種條件下的行為,確保其能如預期運作並妥善處理潛在的異常。
與往常一樣,範例程式碼可以在 GitHub 上取得。