quarkus-tdd
affaan-m/ECC
JUnit 5、Mockito、REST Assured、Camel テスト、および JaCoCo を使用した Quarkus 3.x LTS 向けのテスト駆動開発。機能の追加、バグの修正、またはイベント駆動型サービスのリファクタリングを行う際に活用してください。
...すべて拡張しますQuarkus の TDD ワークフロー
Quarkus 3.x サービス向けの TDD ガイドライン(カバレッジ 80% 以上:ユニットテスト+統合テスト)。Apache Camel を用いたイベント駆動型アーキテクチャ向けに最適化されています。
使用すべき場面
- 新機能やRESTエンドポイント
- バグ修正やリファクタリング
- データアクセスロジック、セキュリティルール、またはリアクティブストリームの追加
- Apache Camelのルートおよびイベントハンドラのテスト
- RabbitMQ を使用したイベント駆動型サービスのテスト
- 条件付きフローロジックのテスト
- CompletableFutureによる非同期操作の検証
- LogContextの伝播のテスト
ワークフロー
- まずテストを書く(最初は失敗するはず)
- テストが通るよう最小限のコードを実装する
- テストが成功した状態でリファクタリング
- JaCoCo を使用してカバレッジを確保する(目標:80%以上)
@Nested を使用したユニットテストの構成
包括的で読みやすいテストを作成するには、以下の構造化されたアプローチに従ってください:
@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("Tests for createOrder")
class CreateOrder {
@Test
@DisplayName("Should persist order and publish fulfillment event")
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("Should reject missing customer 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("Should record error event when persistence fails")
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("Should reject null commands")
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()エラーシナリオにおいてメソッドが呼び出されないことを確認する
Camelルートのテスト
@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 - Test data
testPayload = new BusinessRulesPayload();
testPayload.setDocumentId(1L);
testPayload.setFlowProfile(FlowProfile.BASIC);
}
@Nested
@DisplayName("Tests for business-rules-publisher route")
class BusinessRulesPublisher {
@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);
// Replace real endpoint with mock for testing
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 is a JSON String after .marshal().json(JsonLibrary.Jackson)
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("Should handle marshalling to 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("Tests for document-processing route")
class DocumentProcessing {
@Test
@DisplayName("Should route invoice to correct processor")
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("Should handle validation errors gracefully")
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 to throw exception
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");
}
}
}
イベントサービスのテスト
@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("Tests for createSuccessEvent")
class CreateSuccessEvent {
@Test
@DisplayName("Should create success event with correct attributes")
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("Should throw exception when payload is 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("Tests for createErrorEvent")
class CreateErrorEvent {
@Test
@DisplayName("Should create error event with error message")
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("Should reject invalid error messages")
@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");
}
}
}
CompletableFutureのテスト
@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("Tests for uploadOriginalFile")
class UploadOriginalFile {
@Test
@DisplayName("Should successfully upload file and return document info")
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("Should handle S3 upload failure")
void givenS3Failure_whenUpload_thenCompletableFutureFails() {
// ARRANGE — run synchronously so exception propagates through the 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("Should propagate LogContext to async operation")
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");
}
}
}
リソース層のテスト (REST Assured)
@QuarkusTest
@DisplayName("DocumentResource API Tests")
class DocumentResourceTest {
@InjectMock
DocumentService documentService;
@Nested
@DisplayName("Tests for GET /api/documents")
class ListDocuments {
@Test
@DisplayName("Should return list of documents")
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("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)
.header("Location", containsString("/api/documents/1"))
.body("referenceNumber", equalTo("DOC-001"));
}
@Test
@DisplayName("Should return 400 for invalid input")
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;
}
}
実データベースを用いた統合テスト
@QuarkusTest
@TestProfile(IntegrationTestProfile.class)
@DisplayName("Document Integration Tests")
class DocumentIntegrationTest {
@Test
@Transactional
@DisplayName("Should create and retrieve document via API")
void givenNewDocument_whenCreateAndRetrieve_thenSuccessful() {
// ACT - Create via 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 - Retrieve via API
given()
.when().get("/api/documents/" + id)
.then()
.statusCode(200)
.body("referenceNumber", equalTo("INT-001"));
}
}
JaCoCo によるカバレッジ測定
Mavenの設定(完全版)
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
カバレッジを伴うテストの実行:
mvn clean test
mvn jacoco:report
mvn jacoco:check
# Report at: target/site/jacoco/index.html
テストの依存関係
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
ベストプラクティス
テストの構成
-
@Nestedクラスを使用して、テスト対象のメソッドごとにテストをグループ化する - 使用
@DisplayNameを使用し、レポート上で視認性の高いテストの説明文を作成する - テストメソッドの命名規則に従う
givenX_whenY_thenZテストメソッドの命名規則に従う - 共通のテストデータ設定の重複を減らすために
@BeforeEach共通のテストデータ設定にこれを使用し、重複を減らす
テスト構造
- AAAパターンに従い、明確なコメントを付ける(
// ARRANGE,// ACT,// ASSERT) - 成功シナリオには
assertDoesNotThrow成功シナリオに使用する - 例外シナリオには
assertThrowsメッセージの妥当性検証を伴う例外シナリオでは - AssertJを使用して、例外メッセージが期待値と一致することを検証する
contains()またはisEqualTo()
テストカバレッジ
- すべてのパブリックメソッドについて、正常な処理経路をテストする
- null入力の処理をテストする
- エッジケース(空のコレクション、境界値、負のID、空白の文字列)をテストする
- 例外シナリオを網羅的にテストする
- すべての外部依存関係(リポジトリ、サービス、Camelエンドポイント)をモック化する
- 80%以上の行カバレッジ、70%以上の分岐カバレッジを目指す
アサーション
- 値のチェックには、JUnitのアサーションよりもAssertJ(
assertThat)を優先する - 可読性を高めるため、AssertJのフluent APIを使用する:
assertThat(list).hasSize(3).contains(item) - 例外については、JUnit
assertThrowsで捕捉し、その後 AssertJ でメッセージを検証する - 例外を投げない成功パスについては、JUnitを使用する
assertDoesNotThrow - コレクションについては:
extracting(),filteredOn(),containsExactly()
統合テスト
- 使用
@QuarkusTestを使用する - Quarkusテストで
@InjectMockを使用して、Quarkusテスト内の依存関係をモック化する - APIテストにはREST Assuredを推奨
- 使用
@TestProfileテスト固有の設定には
イベント駆動型テスト
- Camelルートは
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開発サービスを活用する
- 代わりに
@InjectMockの代わりに@MockBean(Quarkus固有の)
検証のベストプラクティス
- モック化された依存関係との相互作用は常に検証してください
- 以下を使用する
verify(mock, never())を使用して、エラー発生時にメソッドが呼び出されないようにする - `
argThat()を使用して、複雑な引数の照合を行う - 重要な場合は呼び出し順序を検証します:
InOrderMockitoより
---
name: quarkus-tdd
description: Test-driven development for Quarkus 3.x LTS using JUnit 5, Mockito, REST Assured, Camel testing, and JaCoCo. Use when adding features, fixing bugs, or refactoring event-driven services.
---
# Quarkus TDD Workflow
TDD guidance for Quarkus 3.x services with 80%+ coverage (unit + integration). Optimized for event-driven architectures with Apache Camel.
## When to Use
- New features or REST endpoints
- Bug fixes or refactors
- Adding data access logic, security rules, or reactive streams
- Testing Apache Camel routes and event handlers
- Testing event-driven services with RabbitMQ
- Testing conditional flow logic
- Validating CompletableFuture async operations
- Testing LogContext propagation
## Workflow
1. Write tests first (they should fail)
2. Implement minimal code to pass
3. Refactor with tests green
4. Enforce coverage with JaCoCo (80%+ target)
## Unit Tests with @Nested Organization
Follow this structured approach for comprehensive, readable tests:
```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("Tests for createOrder")
class CreateOrder {
@Test
@DisplayName("Should persist order and publish fulfillment event")
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("Should reject missing customer 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("Should record error event when persistence fails")
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("Should reject null commands")
void givenNullCommand_whenCreateOrder_thenThrowsNullPointerException() {
// ACT & ASSERT
assertThrows(
NullPointerException.class,
() -> orderService.createOrder(null)
);
verify(orderRepository, never()).persist(any(Order.class));
}
}
}
```
### Key Testing Patterns
1. **@Nested Classes**: Group tests by method being tested
2. **@DisplayName**: Provide readable test descriptions for test reports
3. **Naming Convention**: `givenX_whenY_thenZ` for clarity
4. **AAA Pattern**: Explicit `// ARRANGE`, `// ACT`, `// ASSERT` comments
5. **@BeforeEach**: Setup common test data to reduce duplication
6. **assertDoesNotThrow**: Test success scenarios without catching exceptions
7. **assertThrows**: Test exception scenarios with message validation using AssertJ
8. **Comprehensive Coverage**: Test happy paths, null inputs, edge cases, exceptions
9. **Verify Interactions**: Use Mockito `verify()` to ensure methods are called correctly
10. **Never Verify**: Use `never()` to ensure methods are NOT called in error scenarios
## 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 - Test data
testPayload = new BusinessRulesPayload();
testPayload.setDocumentId(1L);
testPayload.setFlowProfile(FlowProfile.BASIC);
}
@Nested
@DisplayName("Tests for business-rules-publisher route")
class BusinessRulesPublisher {
@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);
// Replace real endpoint with mock for testing
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 is a JSON String after .marshal().json(JsonLibrary.Jackson)
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("Should handle marshalling to 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("Tests for document-processing route")
class DocumentProcessing {
@Test
@DisplayName("Should route invoice to correct processor")
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("Should handle validation errors gracefully")
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 to throw exception
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("Tests for createSuccessEvent")
class CreateSuccessEvent {
@Test
@DisplayName("Should create success event with correct attributes")
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("Should throw exception when payload is 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("Tests for createErrorEvent")
class CreateErrorEvent {
@Test
@DisplayName("Should create error event with error message")
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("Should reject invalid error messages")
@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("Tests for uploadOriginalFile")
class UploadOriginalFile {
@Test
@DisplayName("Should successfully upload file and return document info")
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<StoredDocumentInfo> 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("Should handle S3 upload failure")
void givenS3Failure_whenUpload_thenCompletableFutureFails() {
// ARRANGE — run synchronously so exception propagates through the 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<StoredDocumentInfo> future =
fileStorageService.uploadOriginalFile(testInputStream, 1024L,
testLogContext, InvoiceFormat.UBL);
// ASSERT
assertThatThrownBy(() -> future.join())
.isInstanceOf(CompletionException.class)
.hasCauseInstanceOf(StorageException.class)
.hasMessageContaining("S3 unavailable");
}
@Test
@DisplayName("Should propagate LogContext to async operation")
void givenLogContext_whenUpload_thenContextPropagated() throws Exception {
// ARRANGE
AtomicReference<LogContext> 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("Tests for GET /api/documents")
class ListDocuments {
@Test
@DisplayName("Should return list of documents")
void givenDocumentsExist_whenList_thenReturnsOk() {
// ARRANGE
List<Document> 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("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)
.header("Location", containsString("/api/documents/1"))
.body("referenceNumber", equalTo("DOC-001"));
}
@Test
@DisplayName("Should return 400 for invalid input")
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("Should create and retrieve document via API")
void givenNewDocument_whenCreateAndRetrieve_thenSuccessful() {
// ACT - Create via 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 - Retrieve via API
given()
.when().get("/api/documents/" + id)
.then()
.statusCode(200)
.body("referenceNumber", equalTo("INT-001"));
}
}
```
## Coverage with JaCoCo
### Maven Configuration (Complete)
```xml
<plugin>
<groupId>org.jacoco</groupId>
<artifactId>jacoco-maven-plugin</artifactId>
<version>0.8.13</version>
<executions>
<!-- Prepare agent for test execution -->
<execution>
<id>prepare-agent</id>
<goals>
<goal>prepare-agent</goal>
</goals>
</execution>
<!-- Generate coverage report -->
<execution>
<id>report</id>
<phase>verify</phase>
<goals>
<goal>report</goal>
</goals>
</execution>
<!-- Enforce coverage thresholds -->
<execution>
<id>check</id>
<goals>
<goal>check</goal>
</goals>
<configuration>
<rules>
<rule>
<element>BUNDLE</element>
<limits>
<limit>
<counter>LINE</counter>
<value>COVEREDRATIO</value>
<minimum>0.80</minimum>
</limit>
<limit>
<counter>BRANCH</counter>
<value>COVEREDRATIO</value>
<minimum>0.70</minimum>
</limit>
</limits>
</rule>
</rules>
</configuration>
</execution>
</executions>
</plugin>
```
Run tests with coverage:
```bash
mvn clean test
mvn jacoco:report
mvn jacoco:check
# Report at: target/site/jacoco/index.html
```
## Test Dependencies
```xml
<dependencies>
<!-- Quarkus Testing -->
<dependency>
<groupId>io.quarkus</groupId>
<artifactId>quarkus-junit5</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>io.quarkus</groupId>
<artifactId>quarkus-junit5-mockito</artifactId>
<scope>test</scope>
</dependency>
<!-- Mockito -->
<dependency>
<groupId>org.mockito</groupId>
<artifactId>mockito-core</artifactId>
<scope>test</scope>
</dependency>
<!-- AssertJ (preferred over JUnit assertions) -->
<dependency>
<groupId>org.assertj</groupId>
<artifactId>assertj-core</artifactId>
<version>3.24.2</version>
<scope>test</scope>
</dependency>
<!-- REST Assured -->
<dependency>
<groupId>io.rest-assured</groupId>
<artifactId>rest-assured</artifactId>
<scope>test</scope>
</dependency>
<!-- Camel Testing -->
<dependency>
<groupId>org.apache.camel.quarkus</groupId>
<artifactId>camel-quarkus-junit5</artifactId>
<scope>test</scope>
</dependency>
</dependencies>
```
## Best Practices
### Test Organization
- Use `@Nested` classes to group tests by method being tested
- Use `@DisplayName` for readable test descriptions visible in reports
- Follow `givenX_whenY_thenZ` naming convention for test methods
- Use `@BeforeEach` for common test data setup to reduce duplication
### Test Structure
- Follow AAA pattern with explicit comments (`// ARRANGE`, `// ACT`, `// ASSERT`)
- Use `assertDoesNotThrow` for success scenarios
- Use `assertThrows` for exception scenarios with message validation
- Verify exception messages match expected values using AssertJ `contains()` or `isEqualTo()`
### Test Coverage
- Test happy paths for all public methods
- Test null input handling
- Test edge cases (empty collections, boundary values, negative IDs, blank strings)
- Test exception scenarios comprehensively
- Mock all external dependencies (repositories, services, Camel endpoints)
- Aim for 80%+ line coverage, 70%+ branch coverage
### Assertions
- **Prefer AssertJ** (`assertThat`) over JUnit assertions for value checks
- Use fluent AssertJ API for readability: `assertThat(list).hasSize(3).contains(item)`
- For exceptions: use JUnit `assertThrows` to capture, then AssertJ to validate the message
- For non-throwing success paths: use JUnit `assertDoesNotThrow`
- For collections: `extracting()`, `filteredOn()`, `containsExactly()`
### Testing Integration
- Use `@QuarkusTest` for integration tests
- Use `@InjectMock` to mock dependencies in Quarkus tests
- Prefer REST Assured for API testing
- Use `@TestProfile` for test-specific configuration
### Event-Driven Testing
- Test Camel routes with `AdviceWith` and `MockEndpoint`
- Use `@CamelQuarkusTest` annotation (if using standalone Camel tests)
- Verify message content, headers, and routing logic
- Test error handling routes separately
- Mock external systems (RabbitMQ, S3, databases) in unit tests
### Camel Route Testing
- Use `MockEndpoint` for asserting message flow
- Use `AdviceWith` to modify routes for testing (replace endpoints with mocks)
- Test message transformation and marshalling
- Test exception handling and dead letter queues
### Testing Async Operations
- Test CompletableFuture success and failure scenarios
- Use `.join()` in tests to wait for async completion
- Test exception propagation from CompletableFuture
- Verify LogContext propagation to async operations
### Performance
- Keep tests fast and isolated
- Run tests in continuous mode: `mvn quarkus:test`
- Use parameterized tests (`@ParameterizedTest`) for input variations
- Build reusable test data builders or factory methods
### Quarkus-Specific
- Stay on latest LTS version (Quarkus 3.x)
- Test native compilation compatibility periodically
- Use Quarkus test profiles for different scenarios
- Leverage Quarkus dev services for local testing
- Use `@InjectMock` instead of `@MockBean` (Quarkus-specific)
### Verification Best Practices
- Always verify interactions on mocked dependencies
- Use `verify(mock, never())` to ensure methods are NOT called in error scenarios
- Use `argThat()` for complex argument matching
- Verify the order of calls when it matters: `InOrder` from Mockito
すべてのファイル
1件のファイルquarkus-tddをインストール
スキルファイルをダウンロードし、.claude/skills/ ディレクトリに解凍してください。
ZIPをダウンロードリポジトリをクローンし、スキルファイルをプロジェクトにコピーしてください。
git clone https://github.com/affaan-m/ECC/tree/main/skills/quarkus-tdd # Copy SKILL.md to your .claude/skills/ directory
コピー





家
