Quarkus TDD Workflow
TDD-Anleitung für Quarkus 3.x-Dienste mit 80 %+ Abdeckung (Unit + Integration). Optimiert für ereignisgesteuerte Architekturen mit Apache Camel.
Wann verwenden
- Neue Features oder REST-Endpunkte
- Bugfixes oder Refactoring
- Hinzufügen von Datenzugriffslogik, Sicherheitsregeln oder reaktiven Streams
- Testen von Apache Camel-Routen und Event-Handlern
- Testen von ereignisgesteuerten Diensten mit RabbitMQ
- Testen von bedingter Ablauflogik
- Validieren von asynchronen CompletableFuture-Operationen
- Testen der LogContext-Propagation
Workflow
- Schreibe zuerst Tests (sie sollten fehlschlagen)
- Implementiere minimalen Code, um die Tests zu bestehen
- Refaktoriere bei grünen Tests
- Erzwinge Abdeckung mit JaCoCo (80 %+ Ziel)
Unit-Tests mit @Nested-Organisation
Befolge diesen strukturierten Ansatz für umfassende, lesbare 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 für createOrder")
class CreateOrder {
@Test
@DisplayName("Sollte Bestellung speichern und Fulfillment-Ereignis veröffentlichen")
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("Sollte fehlende Kunden-ID ablehnen")
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("Sollte Fehlerereignis bei Persistenzfehler aufzeichnen")
void givenPersistenceFailure_whenCreateOrder_thenRecordsErrorEvent() {
// ARRANGE
doThrow(new PersistenceException("Datenbank nicht verfügbar"))
.when(orderRepository).persist(any(Order.class));
// ACT & ASSERT
PersistenceException exception = assertThrows(
PersistenceException.class,
() -> orderService.createOrder(validCommand)
);
assertThat(exception.getMessage()).contains("Datenbank nicht verfügbar");
verify(eventService).createErrorEvent(
eq(validCommand),
eq("ORDER_CREATE_FAILED"),
contains("Datenbank nicht verfügbar")
);
verify(fulfillmentPublisher, never()).publishAsync(any());
}
@Test
@DisplayName("Sollte null-Befehle ablehnen")
void givenNullCommand_whenCreateOrder_thenThrowsNullPointerException() {
// ACT & ASSERT
assertThrows(
NullPointerException.class,
() -> orderService.createOrder(null)
);
verify(orderRepository, never()).persist(any(Order.class));
}
}
}
Wichtige Testmuster
- @Nested-Klassen: Gruppiere Tests nach der getesteten Methode
- @DisplayName: Gib lesbare Testbeschreibungen für Testberichte an
- Namenskonvention:
givenX_whenY_thenZfür Klarheit - AAA-Muster: Explizite
// ARRANGE,// ACT,// ASSERT-Kommentare - @BeforeEach: Richte gemeinsame Testdaten ein, um Redundanz zu reduzieren
- assertDoesNotThrow: Teste Erfolgsszenarien ohne Ausnahmen zu fangen
- assertThrows: Teste Ausnahmeszenarien mit Nachrichtenvalidierung mittels AssertJ
- Umfassende Abdeckung: Teste Happy Paths, null-Eingaben, Randfälle, Ausnahmen
- Interaktionen überprüfen: Verwende Mockito
verify(), um sicherzustellen, dass Methoden korrekt aufgerufen werden - Nie verifizieren: Verwende
never(), um sicherzustellen, dass Methoden in Fehlerszenarien NICHT aufgerufen werden
Testen von Camel-Routen
@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 - Testdaten
testPayload = new BusinessRulesPayload();
testPayload.setDocumentId(1L);
testPayload.setFlowProfile(FlowProfile.BASIC);
}
@Nested
@DisplayName("Tests für business-rules-publisher-Route")
class BusinessRulesPublisher {
@Test
@DisplayName("Sollte Nachricht erfolgreich an RabbitMQ veröffentlichen")
void givenValidPayload_whenPublish_thenMessageSentToQueue() throws Exception {
// ARRANGE
MockEndpoint mockRabbitMQ = camelContext.getEndpoint("mock:rabbitmq", MockEndpoint.class);
mockRabbitMQ.expectedMessageCount(1);
// Ersetze echten Endpunkt durch Mock für Tests
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 ist ein JSON-String nach .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("Sollte Marshalling zu JSON handhaben")
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 für document-processing-Route")
class DocumentProcessing {
@Test
@DisplayName("Sollte Rechnung an korrekten Prozessor weiterleiten")
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("Sollte Validierungsfehler elegant handhaben")
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");
// Mocke Validator-Bean, um eine Ausnahme zu werfen
when(documentValidator.validate(any())).thenThrow(new ValidationException("Ungültiges Dokument"));
// 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("Ungültiges Dokument");
}
}
}
Testen von 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 für createSuccessEvent")
class CreateSuccessEvent {
@Test
@DisplayName("Sollte Erfolgsereignis mit korrekten Attributen erstellen")
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("Sollte Ausnahme werfen, wenn Payload null ist")
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 darf nicht null sein");
verify(eventRepository, never()).persist(any());
}
}
@Nested
@DisplayName("Tests für createErrorEvent")
class CreateErrorEvent {
@Test
@DisplayName("Sollte Fehlerereignis mit Fehlermeldung erstellen")
void givenError_whenCreateErrorEvent_thenEventPersistedWithMessage() throws Exception {
// ARRANGE
String errorMessage = "Verarbeitung fehlgeschlagen";
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("Sollte ungültige Fehlermeldungen ablehnen")
@ValueSource(strings = {"", " "})
void givenBlankErrorMessage_whenCreateErrorEvent_thenThrowsException(String blankMessage) {
// ACT & ASSERT
IllegalArgumentException exception = assertThrows(
IllegalArgumentException.class,
() -> eventService.createErrorEvent(testPayload, "ERROR", blankMessage)
);
assertThat(exception.getMessage()).contains("Fehlermeldung darf nicht leer sein");
}
}
}
Testen von 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("Testinhalt".getBytes());
testLogContext = new LogContext();
testLogContext.put("traceId", "trace-123");
}
@Nested
@DisplayName("Tests für uploadOriginalFile")
class UploadOriginalFile {
@Test
@DisplayName("Sollte Datei erfolgreich hochladen und Dokumentinformationen zurückgeben")
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, "test.pdf", testLogContext);
// ASSERT
StoredDocumentInfo result = future.get(5, TimeUnit.SECONDS);
assertThat(result).isNotNull();
assertThat(result.getFileName()).isEqualTo("test.pdf");
verify(executorService).execute(any(Runnable.class));
verify(s3Client).putObject(any(PutObjectRequest.class), any(RequestBody.class));
}
@Test
@DisplayName("Sollte Ausnahme bei S3-Fehler behandeln")
void givenS3Failure_whenUpload_thenFutureCompletesExceptionally() 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)))
.thenThrow(new S3Exception("Service nicht verfügbar"));
// ACT
CompletableFuture<StoredDocumentInfo> future = fileStorageService.uploadOriginalFile(
testInputStream, "test.pdf", testLogContext);
// ASSERT
assertThrows(ExecutionException.class, () -> future.get(5, TimeUnit.SECONDS));
}
@Test
@DisplayName("Sollte Ablaufverfolgungs-ID im LogContext propagieren")
void givenLogContext_whenUpload_thenTraceIdPropagated() throws Exception {
// ARRANGE
doAnswer(invocation -> {
Runnable task = invocation.getArgument(0);
assertThat(MDC.get("traceId")).isEqualTo("trace-123");
task.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, "test.pdf", testLogContext);
// ASSERT
future.get(5, TimeUnit.SECONDS);
verify(executorService).execute(any(Runnable.class));
}
}
}
JaCoCo-Konfiguration
<plugin>
<groupId>org.jacoco</groupId>
<artifactId>jacoco-maven-plugin</artifactId>
<version>0.8.11</version>
<executions>
<execution>
<goals>
<goal>prepare-agent</goal>
</goals>
</execution>
<execution>
<id>report</id>
<phase>test</phase>
<goals>
<goal>report</goal>
</goals>
</execution>
<execution>
<id>check</id>
<phase>verify</phase>
<goals>
<goal>check</goal>
</goals>
<configuration>
<rules>
<rule>
<element>BUNDLE</element>
<limits>
<limit>
<counter>LINE</counter>
<value>COVEREDRATIO</value>
<minimum>0.80</minimum>
</limit>
</limits>
</rule>
</rules>
</configuration>
</execution>
</executions>
</plugin>
Abhängigkeiten (pom.xml)
<!-- Test-Abhängigkeiten -->
<dependency>
<groupId>io.quarkus</groupId>
<artifactId>quarkus-junit5</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.mockito</groupId>
<artifactId>mockito-core</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.mockito</groupId>
<artifactId>mockito-junit-jupiter</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>io.rest-assured</groupId>
<artifactId>rest-assured</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.assertj</groupId>
<artifactId>assertj-core</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.jacoco</groupId>
<artifactId>jacoco-maven-plugin</artifactId>
<version>0.8.11</version>
<scope>test</scope>
</dependency>
<!-- Camel-Testabhängigkeit für Quarkus -->
<dependency>
<groupId>org.apache.camel.quarkus</groupId>
<artifactId>camel-quarkus-junit5</artifactId>
<scope>test</scope>
</dependency>
Quarkus Test-Ressourcen
application.properties
# Testkonfiguration
quarkus.test.profile=test
%test.quarkus.log.level=DEBUG
%test.mp.logging.loggers.org.apache.camel=DEBUG
# RabbitMQ-Testkonfiguration
%test.quarkus.rabbitmq.host=localhost
%test.quarkus.rabbitmq.port=5672
%test.quarkus.rabbitmq.username=guest
%test.quarkus.rabbitmq.password=guest
# Mock-Datenbank für Tests
%test.quarkus.hibernate-orm.database.generation=drop-and-create
%test.quarkus.hibernate-orm.log.sql=false
Best Practices
- Teste früh, teste oft: Schreibe Tests vor der Implementierung, um das Design zu steuern
- Halte Tests unabhängig: Jeder Test sollte isoliert ausgeführt werden können
- Verwende aussagekräftige Namen:
givenX_whenY_thenZmacht die Absicht klar - Moke externe Abhängigkeiten: Isoliere die zu testende Einheit mit Mocks
- Teste Grenzfälle: Leere Listen, null-Werte, große Payloads
- Teste Fehlerfälle: Netzwerkfehler, Datenbankausfälle, Validierungsfehler
- Überwache Abdeckung: Verwende JaCoCo, um Abdeckungslücken zu identifizieren
- Führe Tests in CI aus: Automatisiere Tests bei jedem Commit
- Überprüfe Protokollierung: Stelle sicher, dass LogContext ordnungsgemäß propagiert wird
- Dokumentiere Testmuster: Verwende @DisplayName für klare Testberichte