Flujo de Trabajo TDD en Quarkus
Orientación TDD para servicios Quarkus 3.x con 80%+ de cobertura (unit + integración). Optimizado para arquitecturas orientadas a eventos con Apache Camel.
Cuándo Usar
- Nuevas funcionalidades o endpoints REST
- Correcciones de bugs o refactorizaciones
- Agregar lógica de acceso a datos, reglas de seguridad o streams reactivos
- Probar rutas Apache Camel y manejadores de eventos
- Probar servicios orientados a eventos con RabbitMQ
- Probar lógica de flujo condicional
- Validar operaciones asíncronas con CompletableFuture
- Probar propagación de LogContext
Flujo de Trabajo
- Escribir pruebas primero (deben fallar)
- Implementar el código mínimo para que pasen
- Refactorizar con pruebas en verde
- Exigir cobertura con JaCoCo (objetivo 80%+)
Pruebas Unitarias con Organización @Nested
java@ExtendWith(MockitoExtension.class) @DisplayName("Pruebas Unitarias de 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("Pruebas para createOrder") class CreateOrder { @Test @DisplayName("Debe persistir orden y publicar evento de fulfillment") 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("Debe rechazar customer id vacío") 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("Debe registrar evento de error cuando falla la persistencia") void givenPersistenceFailure_whenCreateOrder_thenRecordsErrorEvent() { // ARRANGE doThrow(new PersistenceException("base de datos no disponible")) .when(orderRepository).persist(any(Order.class)); // ACT & ASSERT PersistenceException exception = assertThrows( PersistenceException.class, () -> orderService.createOrder(validCommand) ); assertThat(exception.getMessage()).contains("base de datos no disponible"); verify(eventService).createErrorEvent( eq(validCommand), eq("ORDER_CREATE_FAILED"), contains("base de datos no disponible") ); verify(fulfillmentPublisher, never()).publishAsync(any()); } } }
Patrones Clave de Prueba
- Clases @Nested: Agrupar pruebas por método bajo prueba
- @DisplayName: Proporcionar descripciones legibles para reportes
- Convención de nombres:
givenX_whenY_thenZpara claridad - Patrón AAA: Comentarios explícitos
// ARRANGE,// ACT,// ASSERT - @BeforeEach: Configurar datos de prueba comunes para reducir duplicación
- assertDoesNotThrow: Probar escenarios exitosos sin capturar excepciones
- assertThrows: Probar escenarios de excepción con validación de mensajes
- verify(): Asegurar que los métodos sean llamados correctamente
- never(): Asegurar que los métodos NO sean llamados en escenarios de error
Pruebas de Rutas Camel
java@QuarkusTest @DisplayName("Pruebas de Ruta Camel Business Rules") class BusinessRulesRouteTest { @Inject CamelContext camelContext; @Inject ProducerTemplate producerTemplate; @InjectMock EventService eventService; @InjectMock DocumentValidator documentValidator; private BusinessRulesPayload testPayload; @BeforeEach void setUp() { testPayload = new BusinessRulesPayload(); testPayload.setDocumentId(1L); testPayload.setFlowProfile(FlowProfile.BASIC); } @Nested @DisplayName("Pruebas para ruta business-rules-publisher") class BusinessRulesPublisher { @Test @DisplayName("Debe publicar mensaje exitosamente en RabbitMQ") void givenValidPayload_whenPublish_thenMessageSentToQueue() throws Exception { // ARRANGE MockEndpoint mockRabbitMQ = camelContext.getEndpoint("mock:rabbitmq", MockEndpoint.class); mockRabbitMQ.expectedMessageCount(1); camelContext.getRouteController().stopRoute("business-rules-publisher"); AdviceWith.adviceWith(camelContext, "business-rules-publisher", advice -> { advice.replaceFromWith("direct:business-rules-publisher"); advice.weaveByToString(".*spring-rabbitmq.*").replace().to("mock:rabbitmq"); }); camelContext.getRouteController().startRoute("business-rules-publisher"); // ACT producerTemplate.sendBody("direct:business-rules-publisher", testPayload); // ASSERT mockRabbitMQ.assertIsSatisfied(5000); assertThat(mockRabbitMQ.getExchanges()).hasSize(1); String body = mockRabbitMQ.getExchanges().get(0).getIn().getBody(String.class); assertThat(body).contains("\"documentId\":1"); } } }
Pruebas de Servicios de Eventos
java@ExtendWith(MockitoExtension.class) @DisplayName("Pruebas Unitarias de EventService") class EventServiceTest { @Mock private EventRepository eventRepository; @Mock private ObjectMapper objectMapper; @InjectMocks private EventService eventService; @Nested @DisplayName("Pruebas para createSuccessEvent") class CreateSuccessEvent { @Test @DisplayName("Debe crear evento de éxito con atributos correctos") void givenValidPayload_whenCreateSuccessEvent_thenEventPersisted() throws Exception { // ARRANGE BusinessRulesPayload testPayload = new BusinessRulesPayload(); testPayload.setDocumentId(1L); 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.getTimestamp() != null )); } @Test @DisplayName("Debe lanzar excepción cuando el payload es 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("Pruebas para createErrorEvent") class CreateErrorEvent { @ParameterizedTest @DisplayName("Debe rechazar mensajes de error inválidos") @ValueSource(strings = {"", " "}) void givenBlankErrorMessage_whenCreateErrorEvent_thenThrowsException(String blankMessage) { // ARRANGE BusinessRulesPayload testPayload = new BusinessRulesPayload(); // ACT & ASSERT IllegalArgumentException exception = assertThrows( IllegalArgumentException.class, () -> eventService.createErrorEvent(testPayload, "ERROR", blankMessage) ); assertThat(exception.getMessage()).contains("Error message cannot be blank"); } } }
Pruebas de CompletableFuture
java@ExtendWith(MockitoExtension.class) class FileStorageServiceTest { @Mock private S3Client s3Client; @Mock private ExecutorService executorService; @InjectMocks private FileStorageService fileStorageService; @Test @DisplayName("Debe manejar fallo de S3") void givenS3Failure_whenUpload_thenCompletableFutureFails() { // ARRANGE — ejecutar sincrónicamente para que la excepción se propague 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 no disponible")); // ACT CompletableFuture<StoredDocumentInfo> future = fileStorageService.uploadOriginalFile(testInputStream, 1024L, testLogContext, InvoiceFormat.UBL); // ASSERT assertThatThrownBy(() -> future.join()) .isInstanceOf(CompletionException.class) .hasCauseInstanceOf(StorageException.class) .hasMessageContaining("S3 no disponible"); } }
Pruebas de Capa de Recurso (REST Assured)
java@QuarkusTest @DisplayName("Pruebas de API DocumentResource") class DocumentResourceTest { @InjectMock DocumentService documentService; @Test @DisplayName("Debe crear documento y retornar 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": "Documento de prueba", "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("Debe retornar 400 para entrada inválida") void givenInvalidRequest_whenCreate_thenReturns400() { given() .contentType(ContentType.JSON) .body(""" { "referenceNumber": "", "description": "Test" } """) .when().post("/api/documents") .then() .statusCode(400); } }
Cobertura con JaCoCo
Configuración Maven (Completa)
xml<plugin> <groupId>org.jacoco</groupId> <artifactId>jacoco-maven-plugin</artifactId> <version>0.8.13</version> <executions> <execution> <id>prepare-agent</id> <goals> <goal>prepare-agent</goal> </goals> </execution> <execution> <id>report</id> <phase>verify</phase> <goals> <goal>report</goal> </goals> </execution> <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>
Ejecutar pruebas con cobertura:
bashmvn clean test mvn jacoco:report mvn jacoco:check # Reporte en: target/site/jacoco/index.html
Dependencias de Prueba
xml<dependencies> <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> <dependency> <groupId>org.assertj</groupId> <artifactId>assertj-core</artifactId> <version>3.24.2</version> <scope>test</scope> </dependency> <dependency> <groupId>io.rest-assured</groupId> <artifactId>rest-assured</artifactId> <scope>test</scope> </dependency> <dependency> <groupId>org.apache.camel.quarkus</groupId> <artifactId>camel-quarkus-junit5</artifactId> <scope>test</scope> </dependency> </dependencies>
Buenas Prácticas
Organización de Pruebas
- Usar clases
@Nestedpara agrupar pruebas por método bajo prueba - Usar
@DisplayNamepara descripciones legibles en reportes - Seguir la convención de nombres
givenX_whenY_thenZ - Usar
@BeforeEachpara configuración de datos comunes
Cobertura de Pruebas
- Probar rutas felices para todos los métodos públicos
- Probar manejo de entradas null
- Probar casos borde (colecciones vacías, valores de frontera)
- Probar escenarios de excepción de forma comprensiva
- Apuntar a 80%+ de cobertura de líneas, 70%+ de ramas
Aserciones
- Preferir AssertJ (
assertThat) sobre aserciones JUnit para verificar valores - Para excepciones: usar JUnit
assertThrowspara capturar, luego AssertJ para validar - Para escenarios exitosos sin excepción: usar JUnit
assertDoesNotThrow
Pruebas de Integración
- Usar
@QuarkusTestpara pruebas de integración - Usar
@InjectMockpara mockear dependencias en pruebas Quarkus - Preferir REST Assured para pruebas de API
- Usar
@TestProfilepara configuración específica de prueba

