Pirámide de pruebas aplicada a microservicios
En microservicios, el objetivo de las pruebas automatizadas no es “probarlo todo en extremo a extremo” (costoso y frágil), sino construir una pirámide: muchas pruebas rápidas y deterministas en la base, menos pruebas de integración en el medio, y un conjunto pequeño pero crítico de pruebas de contrato para asegurar compatibilidad entre servicios. Una pirámide típica para Spring Boot puede quedar así:
- Unitarias (dominio y lógica de aplicación): sin Spring, sin red, sin base de datos. Ejecutan en milisegundos.
- Integración (persistencia y web): con Spring Test, base de datos efímera o contenedorizada, y capa web con MockMvc/WebTestClient.
- Contrato (APIs): verifican que productor y consumidor comparten expectativas (request/response, headers, códigos, errores).
La regla práctica: si una prueba falla, debe decirte qué se rompió y dónde, sin depender del orden de ejecución ni de servicios externos inestables.
Dependencias y herramientas recomendadas
Dependencias típicas (Gradle) para JUnit 5, Spring Test, Mockito y Testcontainers:
dependencies { testImplementation 'org.springframework.boot:spring-boot-starter-test' testImplementation 'org.testcontainers:junit-jupiter:1.19.7' testImplementation 'org.testcontainers:postgresql:1.19.7' // Contratos (ejemplo con Spring Cloud Contract) testImplementation 'org.springframework.cloud:spring-cloud-starter-contract-verifier'}En Maven, las coordenadas son equivalentes. Asegúrate de usar el BOM de Spring Cloud si incorporas Spring Cloud Contract.
1) Pruebas unitarias: dominio y casos de uso (sin Spring)
Qué probar aquí
- Reglas de negocio puras (cálculos, invariantes, estados).
- Casos de uso/servicios de aplicación con dependencias mockeadas (repositorios, clientes HTTP, colas).
- Mapeos y validaciones internas que no dependan de infraestructura.
Ejemplo: caso de uso con dependencias simuladas
Supón un servicio de aplicación que crea un pedido y consulta un servicio externo de inventario. La prueba debe ser determinista: inventario simulado, reloj controlado, IDs predecibles.
- Escuche el audio con la pantalla apagada.
- Obtenga un certificado al finalizar.
- ¡Más de 5000 cursos para que explores!
Descargar la aplicación
class OrderServiceTest { private final InventoryClient inventoryClient = Mockito.mock(InventoryClient.class); private final OrderRepository orderRepository = Mockito.mock(OrderRepository.class); private final Clock fixedClock = Clock.fixed(Instant.parse("2026-01-01T00:00:00Z"), ZoneOffset.UTC); private final OrderService service = new OrderService(inventoryClient, orderRepository, fixedClock); @Test void createsOrder_whenStockIsAvailable() { Mockito.when(inventoryClient.hasStock("SKU-1", 2)).thenReturn(true); Mockito.when(orderRepository.save(Mockito.any())).thenAnswer(inv -> inv.getArgument(0)); Order order = service.create("customer-1", "SKU-1", 2); assertEquals("customer-1", order.customerId()); assertEquals("SKU-1", order.sku()); assertEquals(2, order.quantity()); assertEquals(Instant.parse("2026-01-01T00:00:00Z"), order.createdAt()); Mockito.verify(inventoryClient).hasStock("SKU-1", 2); Mockito.verify(orderRepository).save(Mockito.any(Order.class)); } @Test void rejectsOrder_whenNoStock() { Mockito.when(inventoryClient.hasStock("SKU-1", 2)).thenReturn(false); assertThrows(OutOfStockException.class, () -> service.create("customer-1", "SKU-1", 2)); Mockito.verify(orderRepository, Mockito.never()).save(Mockito.any()); }}Claves anti-flakiness: usa Clock.fixed para fechas, evita aleatoriedad, y no dependas del orden de tests. Si necesitas IDs, inyecta un generador (por ejemplo, Supplier<UUID>) y fíltralo en pruebas.
2) Pruebas de integración: persistencia (repositorios) con DB efímera o contenedorizada
Opciones para la base de datos
- Efímera en memoria (p. ej. H2): rápida, pero puede diferir de tu motor real (tipos, SQL, constraints).
- Contenedorizada (Testcontainers con PostgreSQL/MySQL): más fiel al entorno real, ideal para evitar sorpresas.
Para microservicios, suele compensar usar Testcontainers en integración de persistencia.
Ejemplo con Testcontainers + Spring Boot
Prueba un repositorio JPA verificando constraints y consultas. Usa @DataJpaTest para levantar solo la capa de persistencia.
@Testcontainers @DataJpaTest class OrderRepositoryIT { @Container static PostgreSQLContainer<?> postgres = new PostgreSQLContainer<>("postgres:16-alpine") .withDatabaseName("testdb") .withUsername("test") .withPassword("test"); @DynamicPropertySource static void props(DynamicPropertyRegistry registry) { registry.add("spring.datasource.url", postgres::getJdbcUrl); registry.add("spring.datasource.username", postgres::getUsername); registry.add("spring.datasource.password", postgres::getPassword); registry.add("spring.jpa.hibernate.ddl-auto", () -> "create-drop"); } @Autowired OrderRepository repository; @Test void savesAndFindsByCustomerId() { OrderEntity e = new OrderEntity(); e.setId(UUID.fromString("00000000-0000-0000-0000-000000000001")); e.setCustomerId("customer-1"); e.setSku("SKU-1"); e.setQuantity(2); repository.saveAndFlush(e); List<OrderEntity> found = repository.findByCustomerId("customer-1"); assertEquals(1, found.size()); assertEquals("SKU-1", found.get(0).getSku()); }}Datos deterministas: fija UUIDs, usa valores constantes, y evita depender de autoincrementos si luego asertes exactos. Si necesitas autoincrementos, aserta propiedades funcionales (p. ej. “no es null”) en lugar de valores exactos.
Validación de errores de persistencia
También es útil probar que constraints se aplican (por ejemplo, NOT NULL o unicidad). En JPA, muchas violaciones aparecen al hacer flush:
@Test void failsOnNullSku() { OrderEntity e = new OrderEntity(); e.setId(UUID.fromString("00000000-0000-0000-0000-000000000002")); e.setCustomerId("customer-1"); e.setSku(null); e.setQuantity(1); repository.save(e); assertThrows(DataIntegrityViolationException.class, () -> repository.flush());}3) Pruebas de integración: capa web (controladores) con dependencias mockeadas
Cuándo usar @WebMvcTest
@WebMvcTest carga el contexto web (controladores, Jackson, validación, filtros MVC) sin levantar toda la aplicación. Es ideal para probar:
- Rutas, parámetros, headers, códigos HTTP.
- Serialización/deserialización JSON.
- Validaciones (
@Valid) y manejo de errores. - Interacción del controlador con su servicio (mock).
Ejemplo: prueba de controlador con MockMvc
Controlador que delega en un servicio. Se mockea el servicio con @MockBean.
@WebMvcTest(OrderController.class) class OrderControllerTest { @Autowired MockMvc mvc; @MockBean OrderService orderService; @Test void createsOrder_returns201AndBody() throws Exception { Order created = new Order("order-1", "customer-1", "SKU-1", 2); Mockito.when(orderService.create(Mockito.any())).thenReturn(created); mvc.perform(post("/orders") .contentType("application/json") .content("{\"customerId\":\"customer-1\",\"sku\":\"SKU-1\",\"quantity\":2}")) .andExpect(status().isCreated()) .andExpect(header().string("Location", "/orders/order-1")) .andExpect(jsonPath("$.id").value("order-1")) .andExpect(jsonPath("$.sku").value("SKU-1")); }}Validación de errores: 400 con detalles consistentes
Si el request DTO usa Bean Validation (por ejemplo @NotBlank, @Min), prueba que el API responde con 400 y un cuerpo estable. Para evitar tests frágiles, aserta campos clave en lugar del mensaje exacto (que puede variar por locale).
@Test void returns400_whenQuantityInvalid() throws Exception { mvc.perform(post("/orders") .contentType("application/json") .content("{\"customerId\":\"customer-1\",\"sku\":\"SKU-1\",\"quantity\":0}")) .andExpect(status().isBadRequest()) .andExpect(jsonPath("$.error").value("validation_error")) .andExpect(jsonPath("$.fields.quantity").exists());}Para que esto funcione, normalmente tendrás un @ControllerAdvice que transforme MethodArgumentNotValidException a un formato de error estable. La prueba del controlador verifica el contrato de errores del microservicio.
4) Pruebas de contrato para APIs: productor y consumidor
Las pruebas de contrato reducen roturas entre microservicios al formalizar expectativas. En lugar de depender de pruebas E2E, el consumidor define (o acuerda) un contrato, y el productor lo verifica automáticamente. Dos enfoques comunes:
- Consumer-driven contracts: el consumidor define el contrato; el productor lo valida.
- Contratos compartidos: ambos equipos acuerdan un repositorio/artefacto versionado.
Ejemplo conceptual con Spring Cloud Contract (productor)
Un contrato describe request/response. Spring Cloud Contract puede generar tests del lado productor. Ejemplo de contrato (Groovy/YAML según configuración):
// src/test/resources/contracts/orders/shouldReturnOrder.groovy Contract.make { request { method 'GET' urlPath('/orders/order-1') } response { status 200 headers { contentType(applicationJson()) } body( id: 'order-1', customerId: 'customer-1', sku: 'SKU-1', quantity: 2 ) }}Al ejecutar tests, se generan verificaciones que fallan si el endpoint cambia. Además, se puede publicar un stub para que el consumidor lo use en sus pruebas sin levantar el productor real.
Consumidor: pruebas contra stub determinista
El consumidor puede ejecutar pruebas de integración contra un stub local (generado desde el contrato). Esto elimina flakiness por red y disponibilidad del productor, manteniendo realismo en el payload.
5) Simular dependencias externas sin flakiness
Patrones recomendados
- Mock en unitarias: simula clientes HTTP/colas con Mockito y aserta interacciones.
- Stub HTTP en integración: usa un servidor stub (por ejemplo WireMock) para respuestas realistas y deterministas.
- Testcontainers para infraestructura: base de datos, broker, etc., cuando el comportamiento real importa.
- Evita sleeps: usa timeouts y esperas activas con límites (si hay asincronía), o mejor, diseña para testear sin temporizadores.
Ejemplo: stub HTTP con WireMock (integración)
Si tu microservicio llama a un servicio de inventario, puedes stubearlo:
@SpringBootTest @AutoConfigureWireMock(port = 0) class InventoryClientIT { @Autowired InventoryClient client; @DynamicPropertySource static void props(DynamicPropertyRegistry r) { r.add("inventory.base-url", () -> "http://localhost:" + System.getProperty("wiremock.server.port")); } @Test void returnsTrue_whenStubSaysInStock() { stubFor(get(urlEqualTo("/inventory/SKU-1?qty=2")) .willReturn(aResponse() .withHeader("Content-Type", "application/json") .withBody("{\"available\":true}"))); assertTrue(client.hasStock("SKU-1", 2)); }}Determinismo: el stub devuelve siempre el mismo body para el mismo request. Evita depender de timestamps o IDs generados aleatoriamente en el stub.
6) Guía práctica paso a paso para montar la estrategia
Paso 1: clasifica tests por tipo
*Testpara unitarias (sin Spring).*ITpara integración (Spring + DB/HTTP stub).*ContractTesto carpetacontracts/para contratos.
Paso 2: asegura datos y tiempo deterministas
- Inyecta
Clocky usaClock.fixeden tests. - Evita
UUID.randomUUID()en aserciones; inyecta generador. - Usa fixtures explícitas (builders) en lugar de datos aleatorios.
Paso 3: define un formato de error estable
Estándar interno: { error, message, fields, traceId } (o similar). Prueba al menos:
- 400 por validación.
- 404 por recurso inexistente.
- 409 por conflicto (por ejemplo, duplicados).
Paso 4: integra Testcontainers donde aporte valor
- Persistencia: PostgreSQL/MySQL reales.
- Si hay mensajería: contenedor del broker (cuando sea crítico).
Paso 5: añade contratos para endpoints críticos
- Endpoints consumidos por otros microservicios.
- Errores y códigos HTTP acordados.
- Headers relevantes (auth, idempotency, versionado).
7) Criterios de cobertura útiles (más allá del porcentaje)
| Criterio | Qué buscar | Dónde |
|---|---|---|
| Ramas de negocio | Casos válidos e inválidos, límites, reglas | Unitarias |
| Contrato de API | Payloads, códigos, errores, headers | Web + Contrato |
| Persistencia real | Constraints, queries, transacciones relevantes | Integración (DB) |
| Resiliencia ante fallos externos | Timeouts, respuestas erróneas, degradación | Integración con stubs |
| No flakiness | 0 dependencia de orden, tiempo, red inestable | Todos |
Como referencia práctica, suele ser más valioso exigir cobertura alta en dominio/casos de uso (donde vive la lógica) que perseguir 100% en controladores triviales. En web, prioriza pruebas de rutas, validación y formato de errores.
8) Integración en un pipeline de entrega
Separar fases por costo
Una estrategia común en CI:
- Fase 1 (rápida): unitarias + lint/format (segundos).
- Fase 2: integración web/persistencia con Testcontainers (minutos).
- Fase 3: verificación de contratos y publicación de stubs (si aplica).
Ejemplo de configuración (conceptual) con perfiles
Puedes etiquetar tests con JUnit 5 @Tag para ejecutarlos por etapa:
@Tag("unit") class OrderServiceTest { ... } @Tag("integration") class OrderRepositoryIT { ... }Y en CI ejecutar:
# etapa rápida ./gradlew test -Dgroups=unit # o usando tags según tu build tool # etapa integración ./gradlew test -Dgroups=integrationEn pipelines, habilita cache de dependencias, ejecuta tests en paralelo cuando sea posible, y publica reportes (JUnit XML, cobertura) como artefactos. Si usas contratos con stubs, versiona y publica el stub en un repositorio de artefactos para que los consumidores lo consuman en sus propias pipelines.