Quarkus TDD 工作流程
針對 Quarkus 3.x 服務(單元測試 + 整合測試)的 TDD 指導方針,目標達到 80% 以上的程式碼涵蓋率。特別針對使用 Apache Camel 的 event-driven 架構進行了最佳化。
使用時機
- 新增功能或 REST 端點
- 修復錯誤或重構
- 新增資料存取邏輯、安全規則或 reactive streams
- 測試 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("應持久化訂單並發布 fulfillment 事件")
void givenValidCommand_whenCreateOrder_thenPersistsAndPublishes() {
// ARRANGE
doNothing().when(orderRepository).persist(any(Order.class));
// ACT
OrderReceipt receipt = orderService.createOrder(validCommand);
// ASSERT
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() {
// ARRANGE
CreateOrderCommand invalid = new CreateOrderCommand("", validCommand.lines());
// ACT & ASSERT
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() {
// ARRANGE
doThrow(new PersistenceException("資料庫無法使用"))
.when(orderRepository).persist(any(Order.class));
// ACT & ASSERT
PersistenceException exception = assertThrows(
PersistenceException.class,
() -> orderService.createOrder(validCommand)
);
assertThat(exception.getMessage()).contains("資料庫無法使用");
verify(eventService).createErrorEvent(
eq(validCommand),
eq("ORDER_CREATE_FAILED"),
contains("資料庫無法使用")
);
verify(fulfillmentPublisher, never()).publishAsync(any());
}
@Test
@DisplayName("應拒絕 null 命令")
void givenNullCommand_whenCreateOrder_thenThrowsNullPointerException() {
// ACT & ASSERT
assertThrows(
NullPointerException.class,
() -> orderService.createOrder(null)
);
verify(orderRepository, never()).persist(any(Order.class));
}
}
}
關鍵測試模式
- @Nested 類別:根據被測試的方法對測試進行分組
- @DisplayName:為測試報告提供可讀的測試描述
- 命名慣例:使用
givenX_whenY_thenZ以提升清晰度 - AAA 模式:明確標註
// ARRANGE、// ACT、// ASSERT註解 - @BeforeEach:設定通用的測試資料以減少重複
- assertDoesNotThrow:測試成功場景而無需捕獲例外
- assertThrows:搭配 AssertJ 進行附帶訊息驗證的例外場景測試
- 全面涵蓋:測試快樂路徑、null 輸入、邊界案例、例外情況
- 驗證互動:使用 Mockito
verify()確保方法被正確呼叫 - Never 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() {
// ARRANGE - 測試資料
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 {
// ARRANGE
MockEndpoint mockRabbitMQ = camelContext.getEndpoint("mock:rabbitmq", MockEndpoint.class);
mockRabbitMQ.expectedMessageCount(1);
// 為了測試而將真實端點替換為 mock
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");
// ACT
producerTemplate.sendBody("direct:business-rules-publisher", testPayload);
// ASSERT — 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 {
// ARRANGE
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");
// ACT
producerTemplate.sendBody("direct:business-rules-publisher", testPayload);
// ASSERT
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 {
// ARRANGE
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");
// ACT
producerTemplate.sendBodyAndHeader("direct:process-document",
testPayload, "documentType", "INVOICE");
// ASSERT
mockInvoice.assertIsSatisfied(5000);
}
@Test
@DisplayName("應優雅地處理驗證錯誤")
void givenValidationError_whenProcess_thenRoutesToErrorHandler() throws Exception {
// ARRANGE
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 validator bean 拋出例外
when(documentValidator.validate(any())).thenThrow(new ValidationException(\