--- name: quarkus-tdd description: JUnit 5、Mockito、REST Assured、Camelテスト、JaCoCoを使用したQuarkus 3.xのテスト駆動開発。機能追加、バグ修正、またはイベント駆動サービスのリファクタリング時に使用。 origin: ECC --- # Quarkus TDD Workflow 80%以上のカバレッジ(ユニット+統合)を備えたQuarkus 3.xサービスのTDD指導。Apache Camelを使用したイベント駆動アーキテクチャに最適化。 ## When to Use - 新機能またはRESTエンドポイント - バグ修正またはリファクタリング - データアクセスロジック、セキュリティルール、またはリアクティブストリーム追加 - Apache Camelルートとイベントハンドラーテスト - RabbitMQを使用したイベント駆動サービステスト - 条件フローロジック検証 - CompletableFuture非同期操作検証 - LogContextプロパゲーション テスト ## Workflow 1. テストを先に書く(失敗するはず) 2. 最小限のコードで合格実装 3. テストが緑の状態でリファクタリング 4. JaCoCoでカバレッジ実装(80%以上を目標) ## Unit Tests with @Nested Organization 包括的で読みやすいテストのため、以下の構造化されたアプローチに従います: ```java @ExtendWith(MockitoExtension.class) @DisplayName("OrderService Unit Tests") 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() { // 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が無い場合、BadRequestをスロー") 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("database unavailable")) .when(orderRepository).persist(any(Order.class)); // ACT & ASSERT 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コマンドが与えられた場合、NullPointerExceptionをスロー") void givenNullCommand_whenCreateOrder_thenThrowsNullPointerException() { // ACT & ASSERT assertThrows( NullPointerException.class, () -> orderService.createOrder(null) ); verify(orderRepository, never()).persist(any(Order.class)); } } } ``` ### Key Testing Patterns 1. **@Nested クラス**: テストするメソッド別にテストをグループ化 2. **@DisplayName**: テストレポート用の読みやすい説明提供 3. **命名規則**: 明確性のため `givenX_whenY_thenZ` 4. **AAA パターン**: 明示的な `// ARRANGE`, `// ACT`, `// ASSERT` コメント 5. **@BeforeEach**: 重複削減のためテストデータを共通設定 6. **assertDoesNotThrow**: 例外をキャッチせずに成功シナリオをテスト 7. **assertThrows**: AssertJを使用したメッセージ検証で例外シナリオをテスト 8. **包括的カバレッジ**: 正常系、null入力、エッジケース、例外をテスト 9. **相互作用検証**: Mockito `verify()` でメソッド呼び出しが正しく行われたか確認 10. **Never検証**: `never()` でエラーシナリオでメソッドが呼ばれていないことを確認 ## Testing Camel Routes ```java @QuarkusTest @DisplayName("Business Rules Camel Route Tests") 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); // テスト用の実エンドポイントをモックに置き換え 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 — .marshal().json(JsonLibrary.Jackson)の後、bodyは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"); // バリデータビーンをモック化して例外をスロー when(documentValidator.validate(any())).thenThrow(new ValidationException("Invalid document")); // ACT producerTemplate.sendBody("direct:process-document", testPayload); // ASSERT mockError.assertIsSatisfied(5000); Exception exception = mockError.getExchanges().get(0).getException(); assertThat(exception).isInstanceOf(ValidationException.class); assertThat(exception.getMessage()).contains("Invalid document"); } } } ``` ## Testing Event Services ```java @ExtendWith(MockitoExtension.class) @DisplayName("EventService Unit Tests") class EventServiceTest { @Mock private EventRepository eventRepository; @Mock private ObjectMapper objectMapper; @InjectMocks private EventService eventService; private BusinessRulesPayload testPayload; @BeforeEach void setUp() { // ARRANGE testPayload = new BusinessRulesPayload(); testPayload.setDocumentId(1L); } @Nested @DisplayName("createSuccessEvent のテスト") class CreateSuccessEvent { @Test @DisplayName("有効なペイロードが与えられた場合、正しい属性でサクセスイベント作成") void givenValidPayload_whenCreateSuccessEvent_thenEventPersisted() throws Exception { // ARRANGE when(objectMapper.writeValueAsString(testPayload)).thenReturn("{\"documentId\":1}"); // ACT assertDoesNotThrow(() -> eventService.createSuccessEvent(testPayload, "DOCUMENT_PROCESSED")); // ASSERT 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() { // ARRANGE Object nullPayload = null; // ACT & ASSERT 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 { // ARRANGE String errorMessage = "Processing failed"; when(objectMapper.writeValueAsString(testPayload)).thenReturn("{\"documentId\":1}"); // ACT assertDoesNotThrow(() -> eventService.createErrorEvent(testPayload, "PROCESSING_ERROR", errorMessage)); // ASSERT 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) { // ACT & ASSERT IllegalArgumentException exception = assertThrows( IllegalArgumentException.class, () -> eventService.createErrorEvent(testPayload, "ERROR", blankMessage) ); assertThat(exception.getMessage()).contains("Error message cannot be blank"); } } } ``` ## Testing CompletableFuture ```java @ExtendWith(MockitoExtension.class) @DisplayName("FileStorageService Unit Tests") class FileStorageServiceTest { @Mock private S3Client s3Client; @Mock private ExecutorService executorService; @InjectMocks private FileStorageService fileStorageService; private InputStream testInputStream; private LogContext testLogContext; @BeforeEach void setUp() { // ARRANGE 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 { // ARRANGE 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()); // ACT CompletableFuture future = fileStorageService.uploadOriginalFile(testInputStream, 1024L, testLogContext, InvoiceFormat.UBL); StoredDocumentInfo result = future.join(); // ASSERT assertThat(result).isNotNull(); assertThat(result.getPath()).isNotBlank(); assertThat(result.getSize()).isEqualTo(1024L); assertThat(result.getUploadedAt()).isNotNull(); verify(s3Client).putObject(any(PutObjectRequest.class), any(RequestBody.class)); } @Test @DisplayName("S3アップロード失敗が与えられた場合、CompletableFutureが失敗") void givenS3Failure_whenUpload_thenCompletableFutureFails() { // ARRANGE — 例外がfutureを通じてプロパゲートされるように同期実行 doAnswer(invocation -> { ((Runnable) invocation.getArgument(0)).run(); return null; }).when(executorService).execute(any(Runnable.class)); when(s3Client.putObject(any(PutObjectRequest.class), any(RequestBody.class))) .thenThrow(new StorageException("S3 unavailable")); // ACT CompletableFuture future = fileStorageService.uploadOriginalFile(testInputStream, 1024L, testLogContext, InvoiceFormat.UBL); // ASSERT assertThatThrownBy(() -> future.join()) .isInstanceOf(CompletionException.class) .hasCauseInstanceOf(StorageException.class) .hasMessageContaining("S3 unavailable"); } @Test @DisplayName("LogContextが与えられた場合、非同期操作にコンテキストをプロパゲート") void givenLogContext_whenUpload_thenContextPropagated() throws Exception { // ARRANGE AtomicReference capturedContext = new AtomicReference<>(); doAnswer(invocation -> { capturedContext.set(CustomLog.getCurrentContext()); ((Runnable) invocation.getArgument(0)).run(); return null; }).when(executorService).execute(any(Runnable.class)); // ACT fileStorageService.uploadOriginalFile(testInputStream, 1024L, testLogContext, InvoiceFormat.UBL).join(); // ASSERT assertThat(capturedContext.get()).isNotNull(); assertThat(capturedContext.get().get("traceId")).isEqualTo("trace-123"); } } } ``` ## Resource Layer Tests (REST Assured) ```java @QuarkusTest @DisplayName("DocumentResource API Tests") class DocumentResourceTest { @InjectMock DocumentService documentService; @Nested @DisplayName("GET /api/documents のテスト") class ListDocuments { @Test @DisplayName("ドキュメントが存在する場合、ドキュメント一覧を返す") void givenDocumentsExist_whenList_thenReturnsOk() { // ARRANGE List documents = List.of(createDocument(1L, "DOC-001")); when(documentService.list(0, 20)).thenReturn(documents); // ACT & ASSERT given() .when().get("/api/documents") .then() .statusCode(200) .body("$.size()", is(1)) .body("[0].referenceNumber", equalTo("DOC-001")); } } @Nested @DisplayName("POST /api/documents のテスト") class CreateDocument { @Test @DisplayName("有効なリクエストが与えられた場合、ドキュメント作成して201を返す") void givenValidRequest_whenCreate_thenReturns201() { // ARRANGE Document document = createDocument(1L, "DOC-001"); when(documentService.create(any())).thenReturn(document); // ACT & ASSERT given() .contentType(ContentType.JSON) .body(""" { "referenceNumber": "DOC-001", "description": "Test document", "validUntil": "2030-01-01T00:00:00Z", "categories": ["test"] } """) .when().post("/api/documents") .then() .statusCode(201) .header("Location", containsString("/api/documents/1")) .body("referenceNumber", equalTo("DOC-001")); } @Test @DisplayName("不正なリクエストが与えられた場合、400を返す") void givenInvalidRequest_whenCreate_thenReturns400() { // ACT & ASSERT given() .contentType(ContentType.JSON) .body(""" { "referenceNumber": "", "description": "Test" } """) .when().post("/api/documents") .then() .statusCode(400); } } private Document createDocument(Long id, String referenceNumber) { Document document = new Document(); document.setId(id); document.setReferenceNumber(referenceNumber); document.setStatus(DocumentStatus.PENDING); return document; } } ``` ## Integration Tests with Real Database ```java @QuarkusTest @TestProfile(IntegrationTestProfile.class) @DisplayName("Document Integration Tests") class DocumentIntegrationTest { @Test @Transactional @DisplayName("新規ドキュメントをAPIで作成・取得、成功する") void givenNewDocument_whenCreateAndRetrieve_thenSuccessful() { // ACT - APIで作成 Long id = given() .contentType(ContentType.JSON) .body(""" { "referenceNumber": "INT-001", "description": "Integration test", "validUntil": "2030-01-01T00:00:00Z", "categories": ["test"] } """) .when().post("/api/documents") .then() .statusCode(201) .extract().path("id"); // ASSERT - APIで取得 given() .when().get("/api/documents/" + id) .then() .statusCode(200) .body("referenceNumber", equalTo("INT-001")); } } ``` ## Coverage with JaCoCo ### Maven Configuration (Complete) ```xml org.jacoco jacoco-maven-plugin 0.8.13 prepare-agent prepare-agent report verify report check check BUNDLE LINE COVEREDRATIO 0.80 BRANCH COVEREDRATIO 0.70 ``` カバレッジ付きテスト実行: ```bash mvn clean test mvn jacoco:report mvn jacoco:check # レポート: target/site/jacoco/index.html ``` ## Test Dependencies ```xml io.quarkus quarkus-junit5 test io.quarkus quarkus-junit5-mockito test org.mockito mockito-core test org.assertj assertj-core 3.24.2 test io.rest-assured rest-assured test org.apache.camel.quarkus camel-quarkus-junit5 test ``` ## Best Practices ### テスト組織 - テストするメソッド別にグループ化するため`@Nested`クラス使用 - レポートで見やすいテスト説明のため`@DisplayName`使用 - テストメソッド命名は`givenX_whenY_thenZ`規則に従う - 重複削減のため@BeforeEachで共通テストデータ設定 ### テスト構造 - 明示的コメント(`// ARRANGE`, `// ACT`, `// ASSERT`)でAAAパターン従う - 成功シナリオでは`assertDoesNotThrow`使用 - 例外シナリオではメッセージ検証と共に`assertThrows`使用 - AssertJ `contains()`または`isEqualTo()`で例外メッセージ検証 ### テストカバレッジ - 全パブリックメソッドの正常系パスをテスト - null入力ハンドリングテスト - エッジケース(空のコレクション、境界値、負のID、空文字列)テスト - 例外シナリオを包括的にテスト - 外部依存関係(リポジトリ、サービス、Camelエンドポイント)をモック化 - 80%以上の行カバレッジ、70%以上のブランチカバレッジ目指す ### アサーション - **AssertJ推奨**(JUnitアサーション代わりに`assertThat`使用) - 読みやすさのため流暢なAssertJ API使用:`assertThat(list).hasSize(3).contains(item)` - 例外は、JUnit `assertThrows`でキャプチャ、AssertJでメッセージ検証 - 非スロー成功パスはJUnit `assertDoesNotThrow`使用 - コレクションには`extracting()`, `filteredOn()`, `containsExactly()`使用 ### 統合テスト - 統合テスト用に`@QuarkusTest`使用 - Quarkusテストの依存関係モック化に`@InjectMock`使用 - APIテストに REST Assured優先使用 - テスト固有設定に`@TestProfile`使用 ### イベント駆動テスト - `AdviceWith`と`MockEndpoint`でCamelルートテスト - 必要に応じて`@CamelQuarkusTest`注釈使用(スタンドアロンCamelテスト) - メッセージ内容、ヘッダー、ルーティングロジック検証 - エラーハンドリングルートを別個にテスト - ユニットテストで外部システム(RabbitMQ、S3、データベース)をモック化 ### Camel ルートテスト - メッセージフロー確認に`MockEndpoint`使用 - テスト用ルート変更にエンドポイントをモックに置き換える`AdviceWith`使用 - メッセージ変換と整形テスト - 例外処理とデッドレターキューテスト ### 非同期操作テスト - CompletableFutureの成功・失敗シナリオテスト - 非同期完了待機に`.join()`使用 - CompletableFutureから例外プロパゲーション検証 - 非同期操作へのLogContextプロパゲーション検証 ### パフォーマンス - テストを高速で分離した状態に保つ - 継続モードでテスト実行:`mvn quarkus:test` - 入力バリエーション用に パラメータ化テスト(`@ParameterizedTest`)使用 - 再利用可能なテストデータビルダーまたはファクトリメソッド構築 ### Quarkus固有 - 最新LTSバージョン(Quarkus 3.x)に留める - 定期的にネイティブコンパイル互換性テスト - 異なるシナリオでQuarkusテストプロファイル活用 - ローカルテストにQuarkus dev サービス活用 - `@MockBean`代わりに`@InjectMock`(Quarkus固有)使用 ### 検証ベストプラクティス - モック化された依存関係の相互作用は常に検証 - エラーシナリオでメソッドが呼ばれていないことを確認するに`verify(mock, never())`使用 - 複雑な引数マッチングに`argThat()`使用 - 呼び出し順序が重要な場合`InOrder`(Mockitoから)で検証