异步代码测试
异步代码(如多线程任务、回调函数、消息队列消费等)的单元测试面临时序不确定性和线程隔离等挑战。本章通过具体示例解析如何利用 Mockito 和辅助工具(如 Awaitility
)可靠地验证异步逻辑。
1. 基础异步验证:verify()
+ timeout()
Mockito 的 timeout()
方法允许在指定时间内等待异步调用完成。
示例场景
测试一个异步任务处理器 AsyncTaskProcessor
,其在后台线程中执行任务并回调结果:
public class AsyncTaskProcessor {
private final ExecutorService executor = Executors.newSingleThreadExecutor();
private final TaskCallback callback;
public AsyncTaskProcessor(TaskCallback callback) {
this.callback = callback;
}
public void processAsync(String taskData) {
executor.submit(() -> {
// 模拟耗时处理
try {
Thread.sleep(100);
callback.onSuccess("Processed: " + taskData);
} catch (InterruptedException e) {
callback.onFailure(e);
}
});
}
public interface TaskCallback {
void onSuccess(String result);
void onFailure(Throwable error);
}
}
测试代码
@ExtendWith(MockitoExtension.class)
class AsyncTaskProcessorTest {
@Mock
private AsyncTaskProcessor.TaskCallback mockCallback;
@InjectMocks
private AsyncTaskProcessor processor;
@Test
void processAsync_ShouldInvokeOnSuccess() {
// 触发异步任务
processor.processAsync("test-data");
// 验证:在 500ms 内 onSuccess 被调用,且参数匹配
verify(mockCallback, timeout(500))
.onSuccess(contains("Processed: test-data"));
// 验证 onFailure 未被调用
verify(mockCallback, never()).onFailure(any());
}
}
关键点解析:
timeout(500)
:等待最多 500ms,若期间onSuccess
被调用则通过,否则失败。- 精确断言:结合
contains()
匹配器验证结果内容。 - 清理资源:实际项目中需关闭线程池(示例省略)。
2. 高级异步控制:Awaitility 库
Awaitility
提供更灵活的等待条件,避免硬编码 Thread.sleep
,增强测试可读性。
示例场景
测试一个订单状态更新服务,异步修改数据库并发送消息:
public class OrderService {
private final OrderRepository repository;
private final MessageProducer messageProducer;
public void updateOrderStatusAsync(String orderId, String status) {
CompletableFuture.runAsync(() -> {
repository.updateStatus(orderId, status);
messageProducer.send("order.updated", orderId);
});
}
}
测试代码
@ExtendWith(MockitoExtension.class)
class OrderServiceTest {
@Mock
private OrderRepository mockRepo;
@Mock
private MessageProducer mockProducer;
@InjectMocks
private OrderService orderService;
@Test
void updateOrderStatusAsync_ShouldUpdateAndSendMessage() {
// 触发异步操作
orderService.updateOrderStatusAsync("ORDER_123", "SHIPPED");
// 使用 Awaitility 等待两个操作完成
await().atMost(1, TimeUnit.SECONDS).untilAsserted(() -> {
verify(mockRepo).updateStatus(eq("ORDER_123"), eq("SHIPPED"));
verify(mockProducer).send(eq("order.updated"), eq("ORDER_123"));
});
}
}
关键点解析:
- 依赖添加:需在
pom.xml
引入 Awaitility:<dependency> <groupId>org.awaitility</groupId> <artifactId>awaitility</artifactId> <version>4.2.0</version> <scope>test</scope> </dependency>
- 复合条件:
untilAsserted
内可包含多个断言,确保所有异步操作完成。 - 超时控制:
atMost(1, TimeUnit.SECONDS)
设置最大等待时间。
3. 回调参数捕获与验证
结合 ArgumentCaptor
和异步等待机制,深入验证回调参数。
示例场景
测试一个支付网关客户端,异步执行支付并回调结果:
public class PaymentGatewayClient {
public void payAsync(BigDecimal amount, PaymentCallback callback) {
new Thread(() -> {
boolean success = externalPaymentService.process(amount);
if (success) {
callback.onSuccess(UUID.randomUUID().toString());
} else {
callback.onFailure("Payment failed");
}
}).start();
}
public interface PaymentCallback {
void onSuccess(String transactionId);
void onFailure(String error);
}
}
测试代码
@ExtendWith(MockitoExtension.class)
class PaymentGatewayClientTest {
@Mock
private PaymentGatewayClient.PaymentCallback mockCallback;
private PaymentGatewayClient client = new PaymentGatewayClient();
@Captor
private ArgumentCaptor<String> transactionIdCaptor;
@Test
void payAsync_ShouldCallOnSuccessWithValidId() {
// 触发支付
client.payAsync(new BigDecimal("100.00"), mockCallback);
// 等待回调完成,捕获参数
verify(mockCallback, timeout(1000))
.onSuccess(transactionIdCaptor.capture());
// 验证交易ID格式
String transactionId = transactionIdCaptor.getValue();
assertThat(transactionId)
.isNotNull()
.matches("[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}");
}
}
关键点解析:
- 精确超时:
timeout(1000)
等待 1 秒,足够后台线程完成。 - 正则验证:使用 AssertJ 的
matches()
确保 UUID 格式正确。 - 线程安全:
ArgumentCaptor
本身线程安全,可直接用于异步场景。
4. 处理异步异常场景
模拟依赖服务异常,验证错误处理逻辑。
示例场景
测试当数据库访问超时时,服务是否重试:
public class RetryableService {
private final DatabaseClient dbClient;
private int retries = 3;
public void fetchDataWithRetry() {
for (int i = 0; i < retries; i++) {
try {
dbClient.fetchData();
return;
} catch (DatabaseTimeoutException e) {
if (i == retries - 1) throw e;
}
}
}
}
测试代码
@ExtendWith(MockitoExtension.class)
class RetryableServiceTest {
@Mock
private DatabaseClient mockDbClient;
@InjectMocks
private RetryableService retryableService;
@Test
void fetchDataWithRetry_ShouldRetry3TimesOnTimeout() {
// 模拟连续超时
doThrow(DatabaseTimeoutException.class)
.when(mockDbClient).fetchData();
// 执行并验证最终抛出异常
assertThrows(DatabaseTimeoutException.class,
() -> retryableService.fetchDataWithRetry());
// 验证重试次数
verify(mockDbClient, times(3)).fetchData();
}
}
关键点解析:
- 连续异常模拟:
doThrow()
默认每次调用都抛出异常。 - 精准重试验证:
times(3)
确保重试逻辑正确。 - 同步转异步:若
fetchDataWithRetry
为异步方法,需结合 Awaitility 等待。
总结
场景 | 工具 | 关键技巧 |
---|---|---|
简单异步回调 | verify() + timeout() | 明确超时时间,结合参数匹配器验证结果 |
复杂多步骤异步 | Awaitility | 使用 untilAsserted() 等待复合条件 |
异步参数验证 | ArgumentCaptor | 线程安全捕获参数,结合正则或复杂断言 |
异常重试逻辑 | doThrow() + times() | 模拟连续异常,验证重试次数和最终状态 |
通过合理选择工具链和验证策略,可有效应对异步代码的不可预测性,构建稳定可靠的单元测试。