--- name: quarkus-tdd description: Quarkus 3.x LTS向けテスト駆動開発。JUnit 5、Mockito、REST Assured、Camelテスト、JaCoCoを使用。機能追加、バグ修正、イベント駆動サービスのリファクタリングに使用。 origin: ECC --- # Quarkus TDDワークフロー 80%以上のカバレッジ(ユニット+統合)を目指すQuarkus 3.xサービスのTDDガイダンス。Apache Camelによるイベント駆動アーキテクチャに最適化。 ## いつ使用するか - 新機能またはRESTエンドポイント - バグ修正またはリファクタリング - データアクセスロジック、セキュリティルール、またはリアクティブストリームの追加 - Apache Camelルートとイベントハンドラーのテスト - RabbitMQによるイベント駆動サービスのテスト - 条件付きフローロジックのテスト - CompletableFuture非同期操作のバリデーション - LogContext伝播のテスト ## ワークフロー 1. まずテストを書く(失敗するはず) 2. テストを通過する最小限のコードを実装 3. テストがグリーンの状態でリファクタリング 4. JaCoCoでカバレッジを強制(80%以上が目標) ## @Nestedによるユニットテスト構成 包括的で読みやすいテストのための構造化アプローチ: ```java @ExtendWith(MockitoExtension.class) @DisplayName("As2ProcessingService Unit Tests") class As2ProcessingServiceTest { @Mock private InvoiceFlowValidator invoiceFlowValidator; @Mock private EventService eventService; @Mock private DocumentJobService documentJobService; @Mock private BusinessRulesPublisher businessRulesPublisher; @Mock private FileStorageService fileStorageService; @InjectMocks private As2ProcessingService as2ProcessingService; private Path testFilePath; private LogContext testLogContext; private InvoiceValidationResult validationResult; private StoredDocumentInfo documentInfo; @BeforeEach void setUp() { // ARRANGE - 共通テストデータ testFilePath = Path.of("/tmp/test-invoice.xml"); testLogContext = new LogContext(); testLogContext.put(As2Constants.STRUCTURE_ID, "STRUCT-001"); testLogContext.put(As2Constants.FILE_NAME, "invoice.xml"); testLogContext.put(As2Constants.AS2_FROM, "PARTNER-001"); validationResult = new InvoiceValidationResult(); validationResult.setValid(true); validationResult.setSize(1024L); validationResult.setDocumentHash("abc123"); documentInfo = new StoredDocumentInfo(); documentInfo.setPath("s3://bucket/path/invoice.xml"); documentInfo.setSize(1024L); } @Nested @DisplayName("Tests for processFile") class ProcessFile { @Test @DisplayName("Should successfully process non-CHORUS file with all validations") void givenNonChorusFile_whenProcessFile_thenAllValidationsApplied() throws Exception { // ARRANGE testLogContext.put(As2Constants.CHORUS_FLOW, "false"); CustomLog.setCurrentContext(testLogContext); when(invoiceFlowValidator.validateFlowWithConfig( eq(testFilePath), eq(ValidationFlowConfig.allValidations()), eq(EInvoiceSyntaxFormat.UBL), any(LogContext.class))) .thenReturn(validationResult); when(invoiceFlowValidator.computeFlowProfile(any(), any())) .thenReturn(FlowProfile.BASIC); when(fileStorageService.uploadOriginalFile(any(), anyLong(), any(), any())) .thenReturn(CompletableFuture.completedFuture(documentInfo)); when(documentJobService.createDocumentAndJobEntities(any(), any(), any(), any(), any())) .thenReturn(new BusinessRulesPayload()); // ACT assertDoesNotThrow(() -> as2ProcessingService.processFile(testFilePath)); // ASSERT verify(invoiceFlowValidator).validateFlowWithConfig( eq(testFilePath), eq(ValidationFlowConfig.allValidations()), eq(EInvoiceSyntaxFormat.UBL), any(LogContext.class)); verify(eventService).createSuccessEvent(any(StoredDocumentInfo.class), eq("PERSISTENCE_BLOB_EVENT_TYPE")); verify(businessRulesPublisher).publishAsync(any(BusinessRulesPayload.class)); } @Test @DisplayName("Should create error event when file upload fails") void givenUploadFailure_whenProcessFile_thenErrorEventCreated() throws Exception { // ARRANGE testLogContext.put(As2Constants.CHORUS_FLOW, "false"); CustomLog.setCurrentContext(testLogContext); when(invoiceFlowValidator.validateFlowWithConfig(any(), any(), any(), any())) .thenReturn(validationResult); when(invoiceFlowValidator.computeFlowProfile(any(), any())) .thenReturn(FlowProfile.BASIC); documentInfo.setPath(""); // 空パスでエラーをトリガー when(fileStorageService.uploadOriginalFile(any(), anyLong(), any(), any())) .thenReturn(CompletableFuture.completedFuture(documentInfo)); // ACT & ASSERT As2ServerProcessingException exception = assertThrows( As2ServerProcessingException.class, () -> as2ProcessingService.processFile(testFilePath) ); assertThat(exception.getMessage()) .contains("File path is empty after upload"); verify(businessRulesPublisher, never()).publishAsync(any()); } } } ``` ### 主要テストパターン 1. **@Nestedクラス**: テスト対象メソッドごとにテストをグループ化 2. **@DisplayName**: テストレポート用の読みやすい説明 3. **命名規則**: 明確さのために`givenX_whenY_thenZ` 4. **AAAパターン**: 明示的な`// ARRANGE`、`// ACT`、`// ASSERT`コメント 5. **@BeforeEach**: 重複削減のための共通テストデータセットアップ 6. **assertDoesNotThrow**: 例外をキャッチせずに成功シナリオをテスト 7. **assertThrows**: メッセージバリデーション付きの例外シナリオテスト 8. **包括的カバレッジ**: ハッピーパス、null入力、エッジケース、例外をテスト 9. **インタラクション検証**: Mockitoの`verify()`でメソッドが正しく呼ばれることを確認 10. **Never検証**: エラーシナリオでメソッドが呼ばれないことを`never()`で確認 ## Camelルートのテスト ```java @QuarkusTest @DisplayName("Business Rules Camel Route Tests") class BusinessRulesRouteTest { @Inject CamelContext camelContext; @Inject ProducerTemplate producerTemplate; @InjectMock EventService eventService; @Test @DisplayName("Should successfully publish message to 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 mockRabbitMQ.assertIsSatisfied(5000); } } ``` ## リソースレイヤーテスト(REST Assured) ```java @QuarkusTest @DisplayName("DocumentResource API Tests") class DocumentResourceTest { @InjectMock DocumentService documentService; @Nested @DisplayName("Tests for POST /api/documents") class CreateDocument { @Test @DisplayName("Should create document and return 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) .body("referenceNumber", equalTo("DOC-001")); } @Test @DisplayName("Should return 400 for invalid input") void givenInvalidRequest_whenCreate_thenReturns400() { given() .contentType(ContentType.JSON) .body(""" { "referenceNumber": "", "description": "Test" } """) .when().post("/api/documents") .then() .statusCode(400); } } } ``` ## JaCoCoカバレッジ ### Maven構成 ```xml org.jacoco jacoco-maven-plugin 0.8.13 prepare-agent prepare-agent report verify report check check BUNDLE LINE COVEREDRATIO 0.80 ``` カバレッジ付きテスト実行: ```bash mvn clean test mvn jacoco:report mvn jacoco:check # レポート: target/site/jacoco/index.html ``` ## テスト依存関係 ```xml io.quarkus quarkus-junit5 test io.quarkus quarkus-junit5-mockito test org.assertj assertj-core 3.24.2 test io.rest-assured rest-assured test org.apache.camel.quarkus camel-quarkus-junit5 test ``` ## ベストプラクティス ### テスト構成 - テスト対象メソッドごとに`@Nested`クラスでグループ化 - レポートに表示される読みやすい説明に`@DisplayName`を使用 - テストメソッドに`givenX_whenY_thenZ`命名規則を使用 ### テスト構造 - 明示的コメント付きAAAパターン(`// ARRANGE`、`// ACT`、`// ASSERT`) - 成功シナリオに`assertDoesNotThrow`を使用 - メッセージバリデーション付き例外シナリオに`assertThrows`を使用 ### アサーション - 値チェックにはJUnitアサーションより**AssertJを優先**(`assertThat`) - 読みやすさのためにAssertJのfluent APIを使用 - 例外: JUnitの`assertThrows`でキャプチャし、AssertJでメッセージを検証 - 成功パス: JUnitの`assertDoesNotThrow`を使用 ### イベント駆動テスト - `AdviceWith`と`MockEndpoint`でCamelルートをテスト - メッセージコンテンツ、ヘッダー、ルーティングロジックを検証 - エラーハンドリングルートを個別にテスト - ユニットテストで外部システム(RabbitMQ、S3、データベース)をモック ### Quarkus固有 - 最新のLTSバージョン(Quarkus 3.x)を維持 - ネイティブコンパイル互換性を定期的にテスト - 異なるシナリオにQuarkusテストプロファイルを使用 - `@MockBean`の代わりに`@InjectMock`を使用(Quarkus固有) **覚えておいてください**: テストは高速、分離、決定的に保ちます。実装の詳細ではなく動作をテストしてください。