Pruebas y calidad de software

Tests de integración con dependencias reales: Testcontainers, WireMock y contratos

Intermedio10 min de lecturaActualizado

Esta página se tradujo del inglés con ayuda de IA y se cotejó con el original. Leer la versión en inglés

Un test de integración justifica su coste cuando ejercita algo que un test unitario no puede: un dialecto SQL real, un serializador real, un broker real. Esta guía, independiente de cualquier framework, explica cómo elegir la frontera que se prueba, levantar dependencias con Testcontainers, mantener el estado limpio entre tests, simular las llamadas HTTP salientes con WireMock y fijar las API con contratos guiados por el consumidor.

Lo que aprenderás

  • Identificar con precisión qué frontera ejercita un test y qué significaría su fallo
  • Levantar bases de datos, brokers y otras dependencias reales desde un test con Testcontainers
  • Elegir una estrategia de reinicio del estado que mantenga los tests aislados sin volverlos lentos
  • Simular llamadas HTTP salientes con WireMock, incluidos los escenarios de fallo y de latencia
  • Verificar la compatibilidad de las API con tests de contrato guiados por el consumidor en lugar de suites end-to-end compartidas

Un test unitario demuestra que tu lógica es coherente consigo misma. No puede decirte si el SQL es válido para el motor sobre el que despliegas, si el JSON que envía tu cliente coincide con lo que espera el proveedor, si la migración se ejecuta, si el mensaje que publicas lo puede deserializar el consumidor ni si la frontera transaccional que trazaste hace commit de verdad. Esas son preguntas de integración, y cada una de ellas ha provocado un incidente en producción en algún sitio esta misma semana.

Los tests de integración son también la capa donde las suites fallan de la forma más cara: lentas, dependientes del orden, ávidas de entorno y flaky. La diferencia entre una suite que se amortiza y otra que acaba borrada al cabo de un año se reduce a unas pocas decisiones: qué frontera cubre cada test, de dónde salen sus dependencias y cómo se reinicia el estado. Esta guía es independiente del framework; para la mecánica específica de Spring, consulta la guía de testing con Spring Boot y la guía de testing con Spring Framework.

Primero, ponle nombre a la frontera

«Test de integración» abarca tests cuyo alcance va desde dos clases hasta un despliegue completo. Antes de escribir uno, responde a una sola pregunta: si este test se pone en rojo, ¿qué he aprendido? Un test cuyo fallo puede significar once cosas distintas es una alarma de incendios sin dirección.

Las fronteras que merece la pena probar por separado suelen ser estas:

  • Persistencia. Tu código de repositorio contra el motor de base de datos real, con el esquema real que generan las migraciones reales. Detecta SQL inválido, errores de mapeo, índices que faltan en una restricción de unicidad y migraciones que fallan sobre una tabla con datos.
  • Serialización. Un grafo de objetos que pasa por el serializador real, de ida y vuelta. Detecta formatos de fecha, manejo de null, el comportamiento ante propiedades desconocidas y el campo que alguien renombró.
  • HTTP saliente. Tu cliente contra un stub que controlas. Detecta la construcción de URL, las cabeceras, la autenticación, el comportamiento de timeouts y reintentos, y el mapeo de errores.
  • Mensajería. Publicar y consumir a través de un broker real. Detecta la serialización, las cabeceras, el particionado, la semántica de confirmación y la reentrega.
  • La aplicación completa. El servicio arrancado tal como se desplegará, ejercitado a través de su API pública. Detecta problemas de cableado, de configuración y de aspectos transversales.

Cada una examina algo distinto, y su fallo significa algo distinto. Meterlas todas en un único «test de integración» que arranca todo y hace clic en un navegador te da la suma de sus costes y nada de su valor de diagnóstico.

¿Contrato o integración?

Hay una división adicional que ahorra muchos disgustos en un sistema con varios servicios. El test de integración pregunta si dos componentes funcionan juntos ahora mismo, en un mismo proceso o en un mismo entorno. El test de contrato pregunta si el acuerdo entre ellos sigue vigente, y lo hace sin que ambos componentes se ejecuten nunca a la vez.

La distinción importa porque los tests de integración entre servicios no escalan. Un entorno compartido en el que cada equipo despliega antes de lanzar los tests end-to-end se convierte en una cola, después en un cuello de botella y al final en una fuente de fallos de los que nadie se hace cargo. Los tests de contrato sustituyen la mayor parte de eso por dos suites que se ejecutan de forma independiente y un fichero que pasa de una a otra.

Dependencias reales con Testcontainers

Testcontainers arranca contenedores Docker desde JUnit y los detiene al terminar, lo que elimina la última excusa para probar contra sustitutos.

@Testcontainers
class OrderRepositoryIT {

    @Container
    static final PostgreSQLContainer<?> POSTGRES =
            new PostgreSQLContainer<>("postgres:17-alpine")
                    .withDatabaseName("shop")
                    .withReuse(true);

    static DataSource dataSource;

    @BeforeAll
    static void migrate() {
        HikariConfig config = new HikariConfig();
        config.setJdbcUrl(POSTGRES.getJdbcUrl());
        config.setUsername(POSTGRES.getUsername());
        config.setPassword(POSTGRES.getPassword());
        dataSource = new HikariDataSource(config);

        Flyway.configure().dataSource(dataSource).load().migrate();
    }

    @Test
    void findsOrdersPlacedInAWindowInPlacedOrder() {
        OrderRepository repository = new JdbcOrderRepository(dataSource);
        repository.save(anOrder().placedAt("2026-01-05T10:00:00Z").build());
        repository.save(anOrder().placedAt("2026-01-06T10:00:00Z").build());
        repository.save(anOrder().placedAt("2026-02-01T10:00:00Z").build());

        List<Order> found = repository.placedBetween(
                Instant.parse("2026-01-01T00:00:00Z"), Instant.parse("2026-01-31T23:59:59Z"));

        assertThat(found).hasSize(2)
                .extracting(Order::placedAt)
                .isSorted();
    }
}

Tres detalles hacen la mayor parte del trabajo. Declarar el contenedor static hace que arranque una vez para toda la clase en lugar de una vez por método de test. Ejecutar las migraciones reales significa que el test también las valida. Y fijar una etiqueta de imagen exacta —postgres:17-alpine, no postgres:latest— mantiene la suite reproducible cuando alguien reconstruya la imagen el mes que viene.

Para una suite entera, un contenedor singleton que se arranca una vez en un inicializador estático y no se detiene nunca es todavía más rápido: la JVM termina y el reaper de Testcontainers elimina el contenedor. withReuse(true), combinado con el flag de reutilización del fichero local testcontainers.properties, mantiene vivo un contenedor entre ejecuciones locales, lo que recorta el coste de arranque durante el desarrollo mientras la CI sigue empezando desde cero.

Más allá de las bases de datos hay módulos para Kafka, RabbitMQ, Redis, Elasticsearch y LocalStack, además de un contenedor genérico que ejecuta cualquier imagen con la estrategia de espera que elijas:

@Container
static final GenericContainer<?> LEGACY_PRICING =
        new GenericContainer<>("registry.example.com/pricing:3.4")
                .withExposedPorts(8080)
                .withEnv("PRICING_MODE", "test")
                .waitingFor(Wait.forHttp("/health").forStatusCode(200));

Las estrategias de espera importan más de lo que parece. Un contenedor con el puerto abierto pero cuya aplicación aún no ha terminado de arrancar produce justo el tipo de fallo intermitente que mina la confianza en una suite.

Evitar que los tests se contaminen entre sí

En cuanto los tests comparten una base de datos real, el aislamiento deja de ser gratis. Tres estrategias, con sus contrapartidas honestas:

Rollback transaccional. Envuelve cada test en una transacción y deshazla al final. Es la opción más rápida y no necesita código de limpieza, pero oculta todo lo que ocurre en el commit —restricciones diferidas, hooks de commit, triggers— y no funciona cuando el código bajo prueba abre su propia transacción o la petición cruza una frontera de proceso.

Estado limpio entre tests. Vacía las tablas afectadas (o restaura una base de datos plantilla) antes de cada test. Es más lento, pero el test ve exactamente lo que vería producción. Un único TRUNCATE ... RESTART IDENTITY CASCADE sobre el puñado de tablas implicadas suele ser lo bastante rápido.

Datos únicos por test. Da a cada test su propio tenant, cliente o prefijo de clave para que las colisiones sean imposibles y no haya nada que limpiar. Es la única estrategia que sobrevive a una ejecución en paralelo agresiva, y además obliga a que el código sea seguro frente a múltiples tenants, algo que a menudo es un requisito real.

Elijas la que elijas, aplícala de manera uniforme. Las suites en las que unas clases hacen rollback y otras vacían tablas fallan de formas que dependen del orden de ejecución, y diagnosticar eso cuesta más que el tiempo que cualquiera de las dos estrategias ahorraba.

Datos de prueba que se puedan leer

Los tests de integración necesitan más datos de preparación que los unitarios, y es en esa preparación donde se vuelven ilegibles. Los dos patrones que funcionan:

// Builder with meaningful defaults: the test states only what it cares about.
Order order = anOrder()
        .forCustomer(aCustomer().premium().build())
        .withLine("SKU-1", 2, "24.00")
        .placedAt("2026-01-05T10:00:00Z")
        .build();

Un test data builder proporciona valores por defecto válidos para cada campo y deja que cada test sobrescriba los dos o tres que le importan. Cuando se añade una columna obligatoria, cambia un builder en lugar de sesenta tests. Un object mother va más allá y da nombre a escenarios completos —Orders.shippedInternationalOrder()—, lo que encaja con casos que se repiten en muchos tests.

Evita la alternativa que siempre aparece primero: un fichero de fixtures SQL compartido que se carga antes de la suite y contiene cien filas de las que treinta tests dependen de formas no documentadas. Nadie puede borrar de ahí una fila, así que no deja de crecer, y leer un test no te dice nada sobre por qué pasa.

Simular el HTTP saliente

Cuando el sistema bajo prueba llama a la API de otro, esa dependencia tiene que estar bajo tu control. WireMock levanta un servidor HTTP que tú programas:

class PricingClientTest {

    @RegisterExtension
    static WireMockExtension wiremock = WireMockExtension.newInstance()
            .options(wireMockConfig().dynamicPort())
            .build();

    private PricingClient client;

    @BeforeEach
    void setUp() {
        client = new PricingClient(wiremock.baseUrl(), Duration.ofMillis(500));
    }

    @Test
    void readsThePriceFromTheResponseBody() {
        wiremock.stubFor(get(urlPathEqualTo("/v1/prices/SKU-1"))
                .withHeader("Accept", equalTo("application/json"))
                .willReturn(okJson("""
                        {"sku":"SKU-1","amount":"24.00","currency":"EUR"}
                        """)));

        assertThat(client.priceOf("SKU-1")).isEqualTo(Money.of("24.00", "EUR"));

        wiremock.verify(getRequestedFor(urlPathEqualTo("/v1/prices/SKU-1"))
                .withHeader("X-Request-Id", matching("[0-9a-f-]{36}")));
    }

    @Test
    void failsFastWhenTheProviderIsSlow() {
        wiremock.stubFor(get(anyUrl()).willReturn(ok().withFixedDelay(2_000)));

        assertThatThrownBy(() -> client.priceOf("SKU-1"))
                .isInstanceOf(PricingUnavailableException.class);
    }

    @Test
    void retriesOnceOnA503() {
        wiremock.stubFor(get(anyUrl()).inScenario("flaky")
                .whenScenarioStateIs(STARTED)
                .willReturn(serviceUnavailable())
                .willSetStateTo("recovered"));
        wiremock.stubFor(get(anyUrl()).inScenario("flaky")
                .whenScenarioStateIs("recovered")
                .willReturn(okJson("""
                        {"sku":"SKU-1","amount":"24.00","currency":"EUR"}
                        """)));

        assertThat(client.priceOf("SKU-1")).isEqualTo(Money.of("24.00", "EUR"));
        wiremock.verify(2, getRequestedFor(anyUrl()));
    }
}

Lo importante son los escenarios de fallo. Cualquier cliente funciona contra un stub que solo devuelve el camino feliz; lo que se rompe en producción es el timeout que nunca se configuró, el reintento que machacó a un proveedor que ya iba con problemas, el 500 que salió a la superficie como un NullPointerException. Los retardos, la inyección de fallos y la máquina de estados de escenarios de WireMock hacen que esos caminos se puedan probar. Inspeccionar el cuerpo de una petición capturada es mucho más fácil con una vista formateada: el formateador de JSON viene bien cuando un informe de discrepancia del stub ocupa una sola línea larguísima.

Mensajería y asincronía

Los tests de broker siguen el mismo patrón con una dificultad añadida: el resultado llega más tarde. Arranca el broker en un contenedor, publica con tu productor real y consulta el resultado con una espera acotada en lugar de dormir el hilo.

@Test
void publishesAnOrderPlacedEventConsumersCanRead() {
    orderService.place(anOrder().withLine("SKU-1", 1, "24.00").build());

    await().atMost(Duration.ofSeconds(5))
            .untilAsserted(() -> {
                List<OrderPlaced> received = testConsumer.drain();
                assertThat(received).hasSize(1);
                assertThat(received.getFirst().sku()).isEqualTo("SKU-1");
            });
}

Un Thread.sleep(3000) en el mismo sitio sería a la vez más lento en una máquina rápida y flaky en un runner de CI cargado. Prueba también los casos problemáticos que solo existen en mensajería: un mensaje envenenado que no se puede deserializar, una entrega duplicada que debe ser idempotente y un consumidor que falla a mitad de proceso y no debe confirmar el mensaje.

Contratos guiados por el consumidor

Para la compatibilidad entre servicios, los tests de contrato sustituyen al entorno end-to-end compartido. El consumidor declara lo que necesita, esa declaración se convierte en un fichero y el proveedor la verifica.

@ExtendWith(PactConsumerTestExt.class)
@PactTestFor(providerName = "pricing-service", pactVersion = PactSpecVersion.V4)
class PricingContractTest {

    @Pact(consumer = "checkout-service")
    RequestResponsePact priceExists(PactDslWithProvider builder) {
        return builder
                .given("a price exists for SKU-1")
                .uponReceiving("a request for the price of SKU-1")
                    .path("/v1/prices/SKU-1")
                    .method("GET")
                .willRespondWith()
                    .status(200)
                    .body(new PactDslJsonBody()
                            .stringType("sku", "SKU-1")
                            .decimalType("amount", 24.00)
                            .stringMatcher("currency", "[A-Z]{3}", "EUR"))
                .toPact();
    }

    @Test
    @PactTestFor(pactMethod = "priceExists")
    void theClientReadsThePrice(MockServer mockServer) {
        PricingClient client = new PricingClient(mockServer.getUrl(), Duration.ofSeconds(1));

        assertThat(client.priceOf("SKU-1")).isEqualTo(Money.of("24.00", "EUR"));
    }
}

Dos propiedades hacen que esto funcione. El consumidor especifica matchers, no valores literales —necesita un código de moneda de tres letras, no específicamente EUR—, de modo que el proveedor puede cambiar sus datos sin romper el contrato. Y la cláusula given nombra un provider state que el test de verificación del proveedor prepara antes de reproducir la petición. Los proveedores que añaden campos siguen siendo compatibles; los que eliminan o renombran un campo que lee el consumidor fallan la verificación antes del despliegue, que es justo cuando quieres enterarte. Las convenciones de diseño que hacen que una API sea fácil de probar con contratos son las mismas que recogen las buenas prácticas generales de diseño de API: nombres de campo estables, cambios aditivos y versionado explícito.

Mantener el coste bajo control

Las suites de integración se degradan minuto a minuto. Lo que de verdad ayuda:

  • Arrancar los contenedores una sola vez. Contenedores estáticos por clase, o un singleton para toda la suite. Los contenedores por método son, con diferencia, la causa más habitual de un build de veinte minutos.
  • Limitar las configuraciones distintas. Cada combinación única de propiedades, perfiles y beans sobrescritos crea un contexto de aplicación independiente que hay que construir desde cero.
  • Ejecutar en paralelo. Activa la ejecución en paralelo de JUnit y haz los tests lo bastante independientes como para aguantarla; el aislamiento con datos únicos y la ejecución en paralelo se refuerzan mutuamente.
  • Dividir la suite. Tests unitarios rápidos en cada commit, tests de integración en el merge y los recorridos end-to-end más lentos por la noche. Una convención de nombres (*Test frente a *IT) permite a la herramienta de build separarlos sin anotaciones.
  • Usar imágenes pequeñas. Las variantes Alpine y las imágenes precalentadas recortan segundos en cada arranque.
  • Borrar los tests que ya no compensan su tiempo de ejecución. Un test que en dos años nunca ha fallado por un motivo real y que duplica lo que ya cubren otros tres es un lastre.

Cuando los tests de integración se vuelven flaky

Un test que falla de forma intermitente destruye más valor del que aporta, porque el equipo aprende a relanzarlo en lugar de investigar. En las suites de integración las causas son previsibles: estado compartido que dejó un test anterior, un puerto fijo que colisiona con la ejecución en paralelo, una espera que daba por hecho un timing, una dependencia de la fecha actual, una suposición sobre el orden de las filas que devuelve una consulta sin ORDER BY, o un contenedor que aceptaba conexiones antes de estar listo.

Trata cada caso como un defecto del test, no como ruido de fondo. Reprodúcelo ejecutando la suite repetidamente, en orden aleatorio y en una máquina cargada: las condiciones en las que falla son información sobre la causa. La política que evita que esto se descontrole se describe en la guía de estrategia de automatización de pruebas: cuarentena, ticket, corrección en un plazo fijo o eliminación.

Puntos clave

  • Decide qué frontera cubre cada test antes de escribirlo; un test cuyo fallo puede significar cualquier cosa no diagnostica nada.
  • Prueba la persistencia contra el motor real con las migraciones reales: Testcontainers eliminó el motivo para sustituirlo por una base de datos en memoria.
  • Elige una única estrategia de aislamiento —rollback, vaciado de tablas o datos únicos— y aplícala en todas partes; la de datos únicos es la que sobrevive a la ejecución en paralelo.
  • Construye los datos de preparación con builders y object mothers, no con un fichero de fixtures compartido que no hace más que crecer.
  • Simula el HTTP saliente con WireMock y dedica la mayor parte del esfuerzo a timeouts, reintentos y respuestas de error en lugar de al camino feliz.
  • Sustituye las suites end-to-end entre servicios por contratos guiados por el consumidor, que verifican la compatibilidad sin que ambos servicios se ejecuten a la vez.

Fuentes y lecturas recomendadas

Preguntas frecuentes

¿Por qué no usar H2 en lugar de un contenedor para los tests de base de datos?
Porque una base de datos en memoria es otro producto. Incluso en modo de compatibilidad, H2 se diferencia de PostgreSQL u Oracle en el manejo de tipos, el soporte de JSON, la cobertura de funciones de ventana, el comportamiento de los bloqueos, la asignación de secuencias, la sintaxis de upsert y los códigos de error. Una suite que pasa en H2 y falla en el motor real es peor que no tener suite, porque generó una confianza que no se había ganado. Los contenedores hicieron innecesaria esa sustitución.
¿Cómo evito que los tests de integración interfieran entre sí?
Elige una estrategia y aplícala de forma consistente. El rollback transaccional es lo más rápido, pero oculta el comportamiento en el commit y no funciona más allá de una frontera de proceso. Vaciar las tablas entre tests es más lento, pero honesto. Generar claves únicas por test para que los datos, sencillamente, no colisionen suele ofrecer lo mejor de ambos mundos y funciona con ejecución en paralelo. Lo que nunca funciona es suponer que el test anterior limpió lo que dejó.
¿Qué verifica realmente un test de contrato guiado por el consumidor?
Dos mitades. En el lado del consumidor, el test ejecuta tu cliente contra un servidor simulado construido a partir de las expectativas que declaraste y genera un fichero de contrato que registra las peticiones que haces y los campos de respuesta de los que dependes. En el lado del proveedor, un test independiente reproduce esas peticiones contra el proveedor real y comprueba que las respuestas siguen cumpliéndolas. Juntos demuestran la compatibilidad sin desplegar nunca ambos servicios en un mismo entorno.
¿Los tests de integración tienen que ser lentos?
Más lentos que los tests unitarios, sí; lentos en términos absolutos, no. Los costes dominantes son el arranque de los contenedores y la creación del contexto, y ambos tienen arreglo: reutiliza un contenedor estático en toda la suite, mantén bajo el número de configuraciones distintas, ejecuta las clases en paralelo y usa las imágenes más pequeñas que te sirvan. Una suite bien ajustada de un par de cientos de tests contra base de datos que termine en dos o tres minutos es un objetivo realista.