Workflow TDD Quarkus
Directives TDD pour les services Quarkus 3.x avec une couverture de 80%+ (unitaire + intégration). Optimisé pour les architectures événementielles avec Apache Camel.
Quand Utiliser
- Nouvelles fonctionnalités ou points de terminaison REST
- Corrections de bugs ou remaniements
- Ajout de logique d'accès aux données, règles de sécurité ou flux réactifs
- Test des routes Camel et des gestionnaires d'événements
- Test des services événementiels avec RabbitMQ
- Test de la logique de flux conditionnel
- Validation des opérations asynchrones CompletableFuture
- Test de la propagation LogContext
Workflow
- Écrire d'abord les tests (ils doivent échouer)
- Implémenter le code minimal pour les passer
- Remanier avec les tests au vert
- Appliquer la couverture avec JaCoCo (objectif 80%+)
Tests Unitaires avec Organisation @Nested
Suivez cette approche structurée pour des tests complets et lisibles :
@ExtendWith(MockitoExtension.class)
@DisplayName("Tests unitaires OrderService")
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 pour createOrder")
class CreateOrder {
@Test
@DisplayName("Devrait persister la commande et publier l'événement de livraison")
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("Devrait rejeter un identifiant client manquant")
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("Devrait enregistrer un événement d'erreur en cas d'échec de persistance")
void givenPersistenceFailure_whenCreateOrder_thenRecordsErrorEvent() {
// ARRANGE
doThrow(new PersistenceException("base de données indisponible"))
.when(orderRepository).persist(any(Order.class));
// ACT & ASSERT
PersistenceException exception = assertThrows(
PersistenceException.class,
() -> orderService.createOrder(validCommand)
);
assertThat(exception.getMessage()).contains("base de données indisponible");
verify(eventService).createErrorEvent(
eq(validCommand),
eq("ORDER_CREATE_FAILED"),
contains("base de données indisponible")
);
verify(fulfillmentPublisher, never()).publishAsync(any());
}
@Test
@DisplayName("Devrait rejeter les commandes nulles")
void givenNullCommand_whenCreateOrder_thenThrowsNullPointerException() {
// ACT & ASSERT
assertThrows(
NullPointerException.class,
() -> orderService.createOrder(null)
);
verify(orderRepository, never()).persist(any(Order.class));
}
}
}
Modèles de Test Clés
- Classes @Nested : Grouper les tests par méthode testée
- @DisplayName : Fournir des descriptions de test lisibles pour les rapports de test
- Convention de nommage :
givenX_whenY_thenZpour plus de clarté - Modèle AAA : Commentaires explicites
// ARRANGE,// ACT,// ASSERT - @BeforeEach : Configurer des données de test communes pour réduire la duplication
- assertDoesNotThrow : Tester les scénarios de succès sans intercepter les exceptions
- assertThrows : Tester les scénarios d'exception avec validation de message en utilisant AssertJ
- Couverture complète : Tester les chemins heureux, les entrées nulles, les cas limites, les exceptions
- Vérifier les interactions : Utiliser Mockito
verify()pour s'assurer que les méthodes sont appelées correctement - Jamais vérifier : Utiliser
never()pour s'assurer que les méthodes NE sont PAS appelées dans les scénarios d'erreur
Test des Routes Camel
@QuarkusTest
@DisplayName("Tests des routes de règles métier Camel")
class BusinessRulesRouteTest {
@Inject
CamelContext camelContext;
@Inject
ProducerTemplate producerTemplate;
@InjectMock
EventService eventService;
@InjectMock
DocumentValidator documentValidator;
private BusinessRulesPayload testPayload;
@BeforeEach
void setUp() {
// ARRANGE - Données de test
testPayload = new BusinessRulesPayload();
testPayload.setDocumentId(1L);
testPayload.setFlowProfile(FlowProfile.BASIC);
}
@Nested
@DisplayName("Tests pour la route business-rules-publisher")
class BusinessRulesPublisher {
@Test
@DisplayName("Devrait publier avec succès un message vers RabbitMQ")
void givenValidPayload_whenPublish_thenMessageSentToQueue() throws Exception {
// ARRANGE
MockEndpoint mockRabbitMQ = camelContext.getEndpoint("mock:rabbitmq", MockEndpoint.class);
mockRabbitMQ.expectedMessageCount(1);
// Remplacer le point de terminaison réel par un mock pour les 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 — le corps est une chaîne JSON après .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("Devrait gérer le marshalling en 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 pour la route document-processing")
class DocumentProcessing {
@Test
@DisplayName("Devrait router une facture vers le bon processeur")
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("Devrait gérer les erreurs de validation avec élégance")
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");
// Simuler le bean validateur pour qu'il lance une exception
when(documentValidator.validate(any())).thenThrow(new ValidationException("Document invalide"));
// 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("Document invalide");
}
}
}
Test des Services d'Événements
@ExtendWith(MockitoExtension.class)
@DisplayName("Tests unitaires EventService")
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 pour createSuccessEvent")
class CreateSuccessEvent {
@Test
@DisplayName("Devrait créer un événement de succès avec les bons attributs")
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("Devrait lever une exception lorsque la charge utile est nulle")
void givenNullPayload_whenCreateSuccessEvent_thenThrowsException() {
// ARRANGE
Object nullPayload = null;
// ACT & ASSERT
NullPointerException exception = assertThrows(
NullPointerException.class,
() -> eventService.createSuccessEvent(nullPayload, "EVENT_TYPE")
);
assertThat(exception.getMessage()).isEqualTo("La charge utile ne peut pas être nulle");
verify(eventRepository, never()).persist(any());
}
}
@Nested
@DisplayName("Tests pour createErrorEvent")
class CreateErrorEvent {
@Test
@DisplayName("Devrait créer un événement d'erreur avec un message d'erreur")
void givenError_whenCreateErrorEvent_thenEventPersistedWithMessage() throws Exception {
// ARRANGE
String errorMessage = "Échec du traitement";
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("Devrait rejeter les messages d'erreur invalides")
@ValueSource(strings = {"", " "})
void givenBlankErrorMessage_whenCreateErrorEvent_thenThrowsException(String blankMessage) {
// ACT & ASSERT
IllegalArgumentException exception = assertThrows(
IllegalArgumentException.class,
() -> eventService.createErrorEvent(testPayload, "ERROR", blankMessage)
);
assertThat(exception.getMessage()).contains("Le message d'erreur ne peut pas être vide");
}
}
}
Test de CompletableFuture
@ExtendWith(MockitoExtension.class)
@DisplayName("Tests unitaires FileStorageService")
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("contenu de test".getBytes());
testLogContext = new LogContext();
testLogContext.put("traceId", "trace-123");
}
@Nested
@DisplayName("Tests pour uploadOriginalFile")
class UploadOriginalFile {
@Test
@DisplayName("Devrait télécharger avec succès le fichier et retourner les informations du document")
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
…
[original truncated for translation]
Test REST avec REST Assured
@QuarkusIntegrationTest
@TestHTTPEndpoint(OrderResource.class)
@DisplayName("Tests d'intégration REST OrderResource")
class OrderResourceIT {
@Test
@DisplayName("Devrait créer une commande et retourner le reçu")
void givenCreateOrderCommand_whenPost_thenReturnsOrderReceipt() {
CreateOrderCommand command = new CreateOrderCommand(
"customer-123",
List.of(new OrderLine("SKU-001", 2))
);
given()
.contentType(ContentType.JSON)
.body(command)
.when().post()
.then()
.statusCode(201)
.body("customerId", equalTo("customer-123"))
.body("orderLines", hasSize(1));
}
@Test
@DisplayName("Devrait retourner 400 pour des données invalides")
void givenInvalidCommand_whenPost_thenReturns400() {
CreateOrderCommand invalid = new CreateOrderCommand(
"",
List.of(new OrderLine("SKU-001", -1))
);
given()
.contentType(ContentType.JSON)
.body(invalid)
.when().post()
.then()
.statusCode(400);
}
@Test
@DisplayName("Devrait retourner 404 pour une commande inexistante")
void givenNonExistentOrderId_whenGet_thenReturns404() {
given()
.when().get("/99999")
.then()
.statusCode(404);
}
}
Configuration JaCoCo
<plugin>
<groupId>org.jacoco</groupId>
<artifactId>jacoco-maven-plugin</artifactId>
<version>0.8.12</version>
<executions>
<execution>
<goals>
<goal>prepare-agent</goal>
</goals>
</execution>
<execution>
<id>report</id>
<phase>verify</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>INSTRUCTION</counter>
<value>COVEREDRATIO</value>
<minimum>0.80</minimum>
</limit>
</limits>
</rule>
</rules>
</configuration>
</execution>
</executions>
</plugin>
Résumé des Modèles
| Catégorie | Outils Clés | Objectif de Couverture |
|---|---|---|
| Tests Unitaires | JUnit 5, Mockito, AssertJ | 90%+ lignes |
| Tests d'Intégration | @QuarkusTest, REST Assured, TestContainers | 80%+ instructions |
| Tests de Routes | AdviceWith, MockEndpoint, @InjectMock | 90%+ routes |
| Tests de Services | CompletableFuture, LogContext, @ParameterizedTest | 100% cas limites |
Meilleures Pratiques
- Diviser vos tests en sections @Nested - chaque classe interne teste une méthode spécifique
- Utiliser des noms de test descriptifs -
givenX_whenY_thenZpour une documentation automatique - Tester les scénarios d'erreur en premier - ils définissent les limites de votre système
- Utiliser assertThrows avec AssertJ - obtenir les deux avantages : la vérification du type d'exception et une assertion fluide
- Vérifier les interactions avec verify - assurez-vous que les dépendances sont correctement utilisées
- Mock à la frontière - mockez uniquement les services externes, pas la logique métier
- Écrire des tests pour les échecs asynchrones - CompletableFuture peut échouer silencieusement
- Tester la propagation LogContext - assurez-vous que le contexte de journalisation traverse les limites asynchrones
- Exécuter JaCoCo vérification pendant l'étape verify - ne laissez pas la couverture se dégrader
- Exécuter les tests en continu - utilisez
mvn testou les configurations de votre IDE pour un retour immédiat