Quarkus TDD 工作流程
面向 Quarkus 3.x 服务的 TDD 指导,覆盖率达 80% 以上(单元测试 + 集成测试)。专为使用 Apache Camel 的事件驱动架构优化。
使用时机
- 新功能或 REST 端点
- Bug 修复或重构
- 添加数据访问逻辑、安全规则或响应式流
- 测试 Apache Camel 路由和事件处理器
- 使用 RabbitMQ 测试事件驱动服务
- 测试条件流逻辑
- 验证 CompletableFuture 异步操作
- 测试 LogContext 传播
工作流程
- 先编写测试(它们应该失败)
- 实现最小化代码以通过测试
- 在测试通过后重构
- 使用 JaCoCo 强制执行覆盖率(80% 以上目标)
使用 @Nested 组织进行单元测试
遵循以下结构化的方法,编写全面且可读的测试:
@ExtendWith(MockitoExtension.class)
@DisplayName("OrderService 单元测试")
class OrderServiceTest {
@Mock
private OrderRepository orderRepository;
@Mock
private EventService eventService;
@Mock
private FulfillmentPublisher fulfillmentPublisher;
@InjectMocks
private OrderService orderService;
private CreateOrderCommand validCommand;
@BeforeEach
void setUp() {
validCommand = new CreateOrderCommand(
"customer-123",
List.of(new OrderLine("sku-123", 2))
);
}
@Nested
@DisplayName("createOrder 测试")
class CreateOrder {
@Test
@DisplayName("应持久化订单并发布履约事件")
void givenValidCommand_whenCreateOrder_thenPersistsAndPublishes() {
// 准备
doNothing().when(orderRepository).persist(any(Order.class));
// 执行
OrderReceipt receipt = orderService.createOrder(validCommand);
// 断言
assertThat(receipt).isNotNull();
assertThat(receipt.customerId()).isEqualTo("customer-123");
verify(orderRepository).persist(any(Order.class));
verify(fulfillmentPublisher).publishAsync(receipt);
verify(eventService).createSuccessEvent(receipt, "ORDER_CREATED");
}
@Test
@DisplayName("应拒绝缺少客户 ID 的请求")
void givenMissingCustomerId_whenCreateOrder_thenThrowsBadRequest() {
// 准备
CreateOrderCommand invalid = new CreateOrderCommand("", validCommand.lines());
// 执行与断言
WebApplicationException exception = assertThrows(
WebApplicationException.class,
() -> orderService.createOrder(invalid)
);
assertThat(exception.getResponse().getStatus()).isEqualTo(400);
verify(orderRepository, never()).persist(any(Order.class));
verify(fulfillmentPublisher, never()).publishAsync(any());
}
@Test
@DisplayName("持久化失败时应记录错误事件")
void givenPersistenceFailure_whenCreateOrder_thenRecordsErrorEvent() {
// 准备
doThrow(new PersistenceException("database unavailable"))
.when(orderRepository).persist(any(Order.class));
// 执行与断言
PersistenceException exception = assertThrows(
PersistenceException.class,
() -> orderService.createOrder(validCommand)
);
assertThat(exception.getMessage()).contains("database unavailable");
verify(eventService).createErrorEvent(
eq(validCommand),
eq("ORDER_CREATE_FAILED"),
contains("database unavailable")
);
verify(fulfillmentPublisher, never()).publishAsync(any());
}
@Test
@DisplayName("应拒绝空命令")
void givenNullCommand_whenCreateOrder_thenThrowsNullPointerException() {
// 执行与断言
assertThrows(
NullPointerException.class,
() -> orderService.createOrder(null)
);
verify(orderRepository, never()).persist(any(Order.class));
}
}
}
关键测试模式
- @Nested 类:按测试方法分组
- @DisplayName:为测试报告提供可读的测试描述
- 命名约定:
givenX_whenY_thenZ确保清晰 - AAA 模式:显式的
// 准备、// 执行、// 断言注释 - @BeforeEach:设置通用测试数据以减少重复
- assertDoesNotThrow:测试成功场景,无需捕获异常
- assertThrows:使用 AssertJ 测试异常场景并验证消息
- 全面覆盖:测试快乐路径、空输入、边界情况、异常
- 验证交互:使用 Mockito
verify()确保方法被正确调用 - 绝不验证:使用
never()确保在错误场景中方法未被调用
测试 Camel 路由
@QuarkusTest
@DisplayName("业务规则 Camel 路由测试")
class BusinessRulesRouteTest {
@Inject
CamelContext camelContext;
@Inject
ProducerTemplate producerTemplate;
@InjectMock
EventService eventService;
@InjectMock
DocumentValidator documentValidator;
private BusinessRulesPayload testPayload;
@BeforeEach
void setUp() {
// 准备 - 测试数据
testPayload = new BusinessRulesPayload();
testPayload.setDocumentId(1L);
testPayload.setFlowProfile(FlowProfile.BASIC);
}
@Nested
@DisplayName("business-rules-publisher 路由测试")
class BusinessRulesPublisher {
@Test
@DisplayName("应成功将消息发布到 RabbitMQ")
void givenValidPayload_whenPublish_thenMessageSentToQueue() throws Exception {
// 准备
MockEndpoint mockRabbitMQ = camelContext.getEndpoint("mock:rabbitmq", MockEndpoint.class);
mockRabbitMQ.expectedMessageCount(1);
// 为测试替换真实端点
camelContext.getRouteController().stopRoute("business-rules-publisher");
AdviceWith.adviceWith(camelContext, "business-rules-publisher", advice -> {
advice.replaceFromWith("direct:business-rules-publisher");
advice.weaveByToString(".*spring-rabbitmq.*").replace().to("mock:rabbitmq");
});
camelContext.getRouteController().startRoute("business-rules-publisher");
// 执行
producerTemplate.sendBody("direct:business-rules-publisher", testPayload);
// 断言 — body 是 .marshal().json(JsonLibrary.Jackson) 后的 JSON 字符串
mockRabbitMQ.assertIsSatisfied(5000);
assertThat(mockRabbitMQ.getExchanges()).hasSize(1);
String body = mockRabbitMQ.getExchanges().get(0).getIn().getBody(String.class);
assertThat(body).contains("\"documentId\":1");
}
@Test
@DisplayName("应处理 JSON 序列化")
void givenPayload_whenPublish_thenMarshalledToJson() throws Exception {
// 准备
MockEndpoint mockMarshal = new MockEndpoint("mock:marshal");
camelContext.addEndpoint("mock:marshal", mockMarshal);
mockMarshal.expectedMessageCount(1);
camelContext.getRouteController().stopRoute("business-rules-publisher");
AdviceWith.adviceWith(camelContext, "business-rules-publisher", advice -> {
advice.weaveAddLast().to("mock:marshal");
});
camelContext.getRouteController().startRoute("business-rules-publisher");
// 执行
producerTemplate.sendBody("direct:business-rules-publisher", testPayload);
// 断言
mockMarshal.assertIsSatisfied(5000);
String body = mockMarshal.getExchanges().get(0).getIn().getBody(String.class);
assertThat(body).contains("\"documentId\":1");
assertThat(body).contains("\"flowProfile\":\"BASIC\"");
}
}
@Nested
@DisplayName("document-processing 路由测试")
class DocumentProcessing {
@Test
@DisplayName("应路由发票到正确的处理器")
void givenInvoiceType_whenProcess_thenRoutesToInvoiceProcessor() throws Exception {
// 准备
MockEndpoint mockInvoice = camelContext.getEndpoint("mock:invoice", MockEndpoint.class);
mockInvoice.expectedMessageCount(1);
camelContext.getRouteController().stopRoute("document-processing");
AdviceWith.adviceWith(camelContext, "document-processing", advice -> {
advice.weaveByToString(".*direct:process-invoice.*").replace().to("mock:invoice");
});
camelContext.getRouteController().startRoute("document-processing");
// 执行
producerTemplate.sendBodyAndHeader("direct:process-document",
testPayload, "documentType", "INVOICE");
// 断言
mockInvoice.assertIsSatisfied(5000);
}
@Test
@DisplayName("应优雅处理验证错误")
void givenValidationError_whenProcess_thenRoutesToErrorHandler() throws Exception {
// 准备
MockEndpoint mockError = camelContext.getEndpoint("mock:error", MockEndpoint.class);
mockError.expectedMessageCount(1);
camelContext.getRouteController().stopRoute("document-processing");
AdviceWith.adviceWith(camelContext, "document-processing", advice -> {
advice.weaveByToString(".*direct:validation-error-handler.*")
.replace().to("mock:error");
});
camelContext.getRouteController().startRoute("document-processing");
// Mock 验证器 bean 抛出异常
when(documentValidator.validate(any())).thenThrow(new ValidationException("Invalid document"));
// 执行
producerTemplate.sendBody("direct:process-document", testPayload);
// 断言
mockError.assertIsSatisfied(5000);
Exception exception = mockError.getExchanges().get(0).getException();
assertThat(exception).isInstanceOf(ValidationException.class);
assertThat(exception.getMessage()).contains("Invalid document");
}
}
}
测试事件服务
@ExtendWith(MockitoExtension.class)
@DisplayName("EventService 单元测试")
class EventServiceTest {
@Mock
private EventRepository eventRepository;
@Mock
private ObjectMapper objectMapper;
@InjectMocks
private EventService eventService;
private BusinessRulesPayload testPayload;
@BeforeEach
void setUp() {
// 准备
testPayload = new BusinessRulesPayload();
testPayload.setDocumentId(1L);
}
@Nested
@DisplayName("createSuccessEvent 测试")
class CreateSuccessEvent {
@Test
@DisplayName("应使用正确属性创建成功事件")
void givenValidPayload_whenCreateSuccessEvent_thenEventPersisted() throws Exception {
// 准备
when(objectMapper.writeValueAsString(testPayload)).thenReturn("{\"documentId\":1}");
// 执行
assertDoesNotThrow(() ->
eventService.createSuccessEvent(testPayload, "DOCUMENT_PROCESSED"));
// 断言
verify(eventRepository).persist(argThat(event ->
event.getType().equals("DOCUMENT_PROCESSED") &&
event.getStatus() == EventStatus.SUCCESS &&
event.getPayload().equals("{\"documentId\":1}") &&
event.getTimestamp() != null
));
}
@Test
@DisplayName("当 payload 为空时应抛出异常")
void givenNullPayload_whenCreateSuccessEvent_thenThrowsException() {
// 准备
Object nullPayload = null;
// 执行与断言
NullPointerException exception = assertThrows(
NullPointerException.class,
() -> eventService.createSuccessEvent(nullPayload, "EVENT_TYPE")
);
assertThat(exception.getMessage()).isEqualTo("Payload cannot be null");
verify(eventRepository, never()).persist(any());
}
}
@Nested
@DisplayName("createErrorEvent 测试")
class CreateErrorEvent {
@Test
@DisplayName("应创建包含错误消息的错误事件")
void givenError_whenCreateErrorEvent_thenEventPersistedWithMessage() throws Exception {
// 准备
String errorMessage = "Processing failed";
when(objectMapper.writeValueAsString(testPayload)).thenReturn("{\"documentId\":1}");
// 执行
assertDoesNotThrow(() ->
eventService.createErrorEvent(testPayload, "PROCESSING_ERROR", errorMessage));
// 断言
verify(eventRepository).persist(argThat(event ->
event.getType().equals("PROCESSING_ERROR") &&
event.getStatus() == EventStatus.ERROR &&
event.getErrorMessage().equals(errorMessage) &&
event.getPayload().equals("{\"documentId\":1}")
));
}
@ParameterizedTest
@DisplayName("应拒绝无效的错误消息")
@ValueSource(strings = {"", " "})
void givenBlankErrorMessage_whenCreateErrorEvent_thenThrowsException(String blankMessage) {
// 执行与断言
IllegalArgumentException exception = assertThrows(
IllegalArgumentException.class,
() -> eventService.createErrorEvent(testPayload, "ERROR", blankMessage)
);
assertThat(exception.getMessage()).contains("Error message cannot be blank");
}
}
}
测试 CompletableFuture
@ExtendWith(MockitoExtension.class)
@DisplayName("FileStorageService 单元测试")
class FileStorageServiceTest {
@Mock
private S3Client s3Client;
@Mock
private ExecutorService executorService;
@InjectMocks
private FileStorageService fileStorageService;
private InputStream testInputStream;
private LogContext testLogContext;
@BeforeEach
void setUp() {
// 准备
testInputStream = new ByteArrayInputStream("test content".getBytes());
testLogContext = new LogContext();
testLogContext.put("traceId", "trace-123");
}
@Nested
@DisplayName("uploadOriginalFile 测试")
class UploadOriginalFile {
@Test
@DisplayName("应成功上传文件并返回文档信息")
void givenValidFile_whenUpload_thenReturnsDocumentInfo() throws Exception {
// 准备
doAnswer(invocation -> {
((Runnable) invocation.getArgument(0)).run();
return null;
}).when(executorService).execute(any(Runnable.class));
when(s3Client.putObject(any(PutObjectRequest.class), any(RequestBody.class)))
.thenReturn(PutObjectResponse.builder().build());
// 执行
CompletableFuture<StoredDocumentInfo> future
…
[截断以便翻译]