quarkus-tdd
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
- Write tests first (they should fail)
- Implement minimal code to pass
- Refactor with tests green
- Enforce coverage with JaCoCo (80%+ target)
Unit Tests with @Nested Organization
Follow this structured approach for comprehensive, readable tests:
@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
- @Nested Classes: Group tests by method being tested
- @DisplayName: Provide readable test descriptions for test reports
- Naming Convention:
givenX_whenY_thenZfor clarity - AAA Pattern: Explicit
// ARRANGE,// ACT,// ASSERTcomments - @BeforeEach: Setup common test data to reduce duplication
- assertDoesNotThrow: Test success scenarios without catching exceptions
- assertThrows: Test exception scenarios with message validation using AssertJ
- Comprehensive Coverage: Test happy paths, null inputs, edge cases, exceptions
- Verify Interactions: Use Mockito
verify()to ensure methods are called correctly - Never Verify: Use
never()to ensure methods are NOT called in error scenarios
Testing Camel Routes
@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
@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
@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)
@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
@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)
<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:
mvn clean testmvn jacoco:reportmvn jacoco:check
# Report at: target/site/jacoco/index.htmlTest Dependencies
<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
@Nestedclasses to group tests by method being tested - Use
@DisplayNamefor readable test descriptions visible in reports - Follow
givenX_whenY_thenZnaming convention for test methods - Use
@BeforeEachfor common test data setup to reduce duplication
Test Structure
- Follow AAA pattern with explicit comments (
// ARRANGE,// ACT,// ASSERT) - Use
assertDoesNotThrowfor success scenarios - Use
assertThrowsfor exception scenarios with message validation - Verify exception messages match expected values using AssertJ
contains()orisEqualTo()
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
assertThrowsto capture, then AssertJ to validate the message - For non-throwing success paths: use JUnit
assertDoesNotThrow - For collections:
extracting(),filteredOn(),containsExactly()
Testing Integration
- Use
@QuarkusTestfor integration tests - Use
@InjectMockto mock dependencies in Quarkus tests - Prefer REST Assured for API testing
- Use
@TestProfilefor test-specific configuration
Event-Driven Testing
- Test Camel routes with
AdviceWithandMockEndpoint - Use
@CamelQuarkusTestannotation (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
MockEndpointfor asserting message flow - Use
AdviceWithto 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
@InjectMockinstead 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:
InOrderfrom Mockito