Testing por capas: menos mocks, más contratos y entornos reales
Prueba lo que te puede romper en producción, no solo lo que es fácil de testear. El 80% de los bugs vienen de integración, configuración, datos y concurrencia — exactamente lo que el unit test aislado no cubre. Una guía práctica de testing por capas.
Hay una regla que debería estar escrita en la pared de toda sala de arquitectura:
Prueba lo que te puede romper en producción, no solo lo que es fácil de testear.
Parece obvio. Y sin embargo, la mayoría de los proyectos terminan con suites de pruebas que dan una falsa sensación de seguridad: cientos de unit tests verdes, cobertura del 80%, y aun así los bugs más costosos siguen apareciendo en producción.
¿Por qué? Porque el 80% de los bugs no vienen de lógica pura. Vienen de integración, configuración, datos reales, concurrencia, serialización, migraciones de base de datos, contratos rotos entre servicios. Justo lo que el unit test aislado con mocks no cubre.
El problema con los mocks
Los mocks son una herramienta legítima. El problema no es usarlos — es abusar de ellos hasta convertir las pruebas en una simulación de una simulación.
Cuando mockeas el repositorio, estás probando que tu servicio llama al repositorio correctamente. No estás probando que la consulta SQL funciona, que la transacción hace rollback si falla algo, que el índice existe, que la migración no introdujo una columna con nombre incorrecto, o que la serialización del JSON es la que espera el consumidor.
Cuando mockeas el cliente HTTP, estás probando que tu código construye el request. No estás probando que el endpoint real responde con el schema esperado, que el timeout está bien configurado, o que el contrato no cambió silenciosamente en el último deploy del proveedor.
Los mocks te dan velocidad. Lo que también te dan — si no tienes cuidado — es confianza falsa.
Testing por capas: la estrategia
La clave es tener pruebas en los niveles correctos, con el tipo correcto de aislamiento en cada nivel. No se trata de eliminar los unit tests — se trata de darle a cada capa lo que necesita.
Capa 1: Unit tests para reglas puras (cero I/O)
Los unit tests brillan cuando tienes lógica de negocio pura: cálculos, transformaciones, validaciones, decisiones. Código que toma datos como entrada y produce datos como salida sin tocar ningún recurso externo.
Estos tests deben ser rápidos, deterministas y sin efectos secundarios. Si necesitas un mock para que el test pase, es probable que la función esté mezclando lógica con I/O — y eso es una señal de diseño, no solo un problema de testing.
class DiscountCalculatorTest {
@Test
void apply_percentage_discount_to_eligible_order() {
var order = new Order(List.of(
new Item("SKU-1", Money.of(100, "USD")),
new Item("SKU-2", Money.of(50, "USD"))
));
var policy = new PercentageDiscountPolicy(10);
var result = policy.apply(order);
assertThat(result.totalAmount()).isEqualByComparingTo(Money.of(135, "USD"));
}
@Test
void does_not_apply_discount_below_minimum_order() {
var order = new Order(List.of(
new Item("SKU-1", Money.of(20, "USD"))
));
var policy = new PercentageDiscountPolicy(10, Money.of(50, "USD"));
var result = policy.apply(order);
assertThat(result.totalAmount()).isEqualByComparingTo(Money.of(20, "USD"));
}
}
Sin mocks, sin contexto de Spring, sin base de datos. Rápidos como el rayo. Deben ser el componente más numeroso de tu suite, pero no el único.
Capa 2: Integration tests con base de datos real (Testcontainers)
Aquí está el corazón del problema que los mocks no resuelven. Los repositorios, las transacciones, las migraciones, las queries — todo esto necesita probarse contra una base de datos real.
Testcontainers resuelve el problema clásico de “¿cómo levanto una DB en CI sin depender de infraestructura externa?”: arranca un contenedor Docker de la base de datos durante la ejecución del test, corre las pruebas, y lo destruye al terminar.
@SpringBootTest
@Testcontainers
class OrderRepositoryIntegrationTest {
@Container
static PostgreSQLContainer<?> postgres = new PostgreSQLContainer<>("postgres:16")
.withDatabaseName("orders_test")
.withUsername("test")
.withPassword("test");
@DynamicPropertySource
static void configureProperties(DynamicPropertyRegistry registry) {
registry.add("spring.datasource.url", postgres::getJdbcUrl);
registry.add("spring.datasource.username", postgres::getUsername);
registry.add("spring.datasource.password", postgres::getPassword);
}
@Autowired
private OrderRepository orderRepository;
@Test
void saves_and_retrieves_order_with_all_items() {
var order = new Order(
CustomerId.of("cust-42"),
List.of(new OrderItem("SKU-1", 2, Money.of(100, "USD")))
);
var saved = orderRepository.save(order);
var found = orderRepository.findById(saved.id()).orElseThrow();
assertThat(found.customerId()).isEqualTo(order.customerId());
assertThat(found.items()).hasSize(1);
assertThat(found.items().get(0).sku()).isEqualTo("SKU-1");
}
@Test
void rollsback_transaction_when_second_operation_fails() {
assertThatThrownBy(() -> orderRepository.saveAndPublishEvent(
buildValidOrder(),
buildEventThatWillFail()
)).isInstanceOf(DataIntegrityViolationException.class);
assertThat(orderRepository.count()).isZero();
}
}
Esto prueba cosas que los mocks nunca podrían verificar: que el schema de la DB es el correcto después de las migraciones, que la transacción hace rollback ante un fallo, que el mapeo de tipos entre Java y PostgreSQL funciona, que los índices están donde se esperan.
¿Son lentos? Sí, comparados con unit tests. ¿Vale la pena? Absolutamente — son exactamente los bugs que más duelen en producción.
Capa 3: Contract tests para APIs y mensajería
Cuando tienes múltiples consumidores de una API o de un topic de mensajería, los integration tests tradicionales no son suficientes. El problema clásico: el proveedor cambia el schema del response, los consumidores siguen pasando sus tests (porque mockean al proveedor), y el bug aparece en producción.
Consumer-Driven Contract Testing (popularizado por Pact) invierte la dinámica: el consumidor define los contratos que espera, y el proveedor debe satisfacerlos.
// En el servicio consumidor: define el contrato que espera
@ExtendWith(PactConsumerTestExt.class)
@PactTestFor(providerName = "inventory-service")
class InventoryClientContractTest {
@Pact(consumer = "order-service")
public RequestResponsePact getProductAvailability(PactDslWithProvider builder) {
return builder
.given("product SKU-1 is available with stock 10")
.uponReceiving("a request for product availability")
.path("/products/SKU-1/availability")
.method("GET")
.willRespondWith()
.status(200)
.body(new PactDslJsonBody()
.stringType("sku", "SKU-1")
.integerType("availableStock", 10)
.booleanType("available", true))
.toPact();
}
@Test
@PactTestFor(pactMethod = "getProductAvailability")
void client_maps_availability_response_correctly(MockServer mockServer) {
var client = new InventoryClient(mockServer.getUrl());
var availability = client.getAvailability("SKU-1");
assertThat(availability.isAvailable()).isTrue();
assertThat(availability.stock()).isEqualTo(10);
}
}
El contrato generado por el consumidor se publica en un Pact Broker. El proveedor, en su pipeline de CI, ejecuta los contratos de todos sus consumidores antes de cada deploy. Si el proveedor rompe un contrato, el pipeline falla — antes de que llegue a producción.
Para mensajería (Kafka, RabbitMQ, SNS/SQS), el mismo patrón aplica: el consumidor define el schema de mensaje que espera procesar, y el proveedor verifica que el mensaje que publica cumple ese contrato.
¿Cuándo es imprescindible? Cuando tienes más de un consumidor de una API o topic. Cuando el proveedor y los consumidores son equipos distintos. Cuando un cambio de schema puede romper silenciosamente a múltiples servicios.
Capa 4: Smoke tests en despliegue
Los smoke tests son la última línea de verificación después de un deploy. No reemplazan a las capas anteriores — verifican que el sistema, ya desplegado en el entorno real, responde de manera básicamente correcta.
Son intencionalmente simples: un puñado de requests a los endpoints más críticos, verificando que responden con el status code correcto y que el payload mínimo esperado está presente.
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.NONE)
@Tag("smoke")
class OrderServiceSmokeTest {
@Value("${app.base-url}")
private String baseUrl;
private RestTemplate restTemplate = new RestTemplate();
@Test
void health_endpoint_is_up() {
var response = restTemplate.getForEntity(baseUrl + "/actuator/health", Map.class);
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK);
assertThat(response.getBody()).containsEntry("status", "UP");
}
@Test
void can_create_and_retrieve_a_draft_order() {
var createRequest = Map.of(
"customerId", "smoke-test-customer",
"items", List.of(Map.of("sku", "SKU-SMOKE", "quantity", 1))
);
var created = restTemplate.postForEntity(baseUrl + "/orders", createRequest, Map.class);
assertThat(created.getStatusCode()).isEqualTo(HttpStatus.CREATED);
var orderId = (String) created.getBody().get("id");
var retrieved = restTemplate.getForEntity(baseUrl + "/orders/" + orderId, Map.class);
assertThat(retrieved.getStatusCode()).isEqualTo(HttpStatus.OK);
}
}
Se ejecutan como parte del pipeline de deploy, después de que el servicio está corriendo pero antes de que el tráfico se dirija al nuevo nodo. Si fallan, el deploy se revierte automáticamente.
El anti-patrón que más duele
500 unit tests con mocks y cero pruebas de DB o migraciones.
Este anti-patrón es más común de lo que parece, y tiene un mecanismo de crecimiento propio: el equipo agrega unit tests porque son rápidos de escribir y la cobertura sube rápido. La suite se vuelve verde. El equipo se siente seguro.
Mientras tanto:
- Las migraciones de base de datos no se prueban. Una columna renombrada llega a producción y rompe 3 servicios.
- Los repositorios mockean la DB. El query que funciona en el mock falla con el plan de ejecución real porque el índice no existe.
- Los contratos de API se verifican manualmente en staging. El proveedor cambia el tipo de un campo de
stringanumber, los mocks del consumidor siguen pasando, y el bug llega a producción. - Las transacciones se mockean. Un fallo a mitad de un proceso de negocio deja datos inconsistentes porque el rollback nunca se verificó.
La cobertura del 80% no dice nada sobre qué tan resistente es el sistema a los fallos reales.
Cómo estructurar la suite
Una distribución razonable en un proyecto backend típico:
| Tipo | Proporción | Cuándo corren |
|---|---|---|
| Unit tests (lógica pura) | 60-70% | En cada commit, en segundos |
| Integration tests (DB real, Testcontainers) | 20-30% | En cada PR, en minutos |
| Contract tests (Pact) | 5-10% | En cada PR del proveedor y consumidor |
| Smoke tests | Pocos (5-10) | En cada deploy, post-provisioning |
Esta no es una pirámide rígida. Es una guía de proporciones. Lo que importa es que cada capa pruebe lo que las demás no pueden.
Por dónde empezar
Si tienes una suite de unit tests con mocks abundantes y poca cobertura de integración real, el camino no es reescribir todo. Es agregar la siguiente capa de valor de forma incremental:
- Identifica los 3 repositorios más críticos del sistema. Agrega integration tests con Testcontainers para esos tres primero.
- Agrega tests de migración. Flyway y Liquibase tienen soporte para verificar que las migraciones aplican limpiamente y que el schema resultante es el esperado.
- Si tienes más de un consumidor de tu API, evalúa Pact. El setup inicial tiene fricción — vale la pena cuando el costo de romper un contrato silenciosamente supera el costo de configurarlo.
- Define 5 smoke tests para los flujos más críticos del negocio. Ejecútalos en cada deploy.
No hace falta hacer todo a la vez. Hace falta hacer lo correcto en el orden correcto.
Cierre
Una suite de pruebas que solo verifica lógica pura te da confianza en el código. Una suite que también verifica integración, contratos y despliegue te da confianza en el sistema.
La diferencia entre las dos no es académica. Es la diferencia entre descubrir el bug en el CI y descubrirlo a las 2am con un incidente en producción.
Prueba lo que te puede romper. No solo lo que es fácil de mockear.