Quarkus TDD ワークフロー
Quarkus 3.x サービス向け、80%以上のカバレッジ(ユニット+統合)を目標としたTDDガイド。Apache Camelを使用したイベント駆動型アーキテクチャ向けに最適化されています。
使用するタイミング
- 新機能やRESTエンドポイントの追加時
- バグ修正やリファクタリング時
- データアクセスロジック、セキュリティルール、リアクティブストリームの追加時
- 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("nullコマンドは拒否する")
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を使用したメッセージ検証で例外シナリオをテストする
- 包括的カバレッジ: 正常系、null入力、エッジケース、例外をテストする
- 相互作用の検証: Mockito
verify()を使用してメソッドが正しく呼び出されることを確認する - Never検証:
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);
// 検証 — .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(" invoice を正しいプロセッサにルーティングする")
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");
// バリデータ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("ペイロードがnullの場合は例外をスローする")
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
…
[truncated for translation]