StackPractices
intermediate Por Mathias Paulenko

Tests de Integración con Dependencias Reales

Cómo probar múltiples componentes trabajando juntos usando bases de datos reales, clientes HTTP y colas de mensajes en Python, JavaScript y Java.

Temas: testing

Visión general

Los tests unitarios comprueban una función cada vez con todo lo que la rodea simulado. El código de producción, en cambio, vive cableado a bases de datos, servicios HTTP, colas y sistemas de archivos, y el cableado es donde se esconden los bugs. Los tests de integración comprueban que estos componentes conectan bien: que un repositorio puede realmente leer de una base de datos, que un cliente de API sobrevive al comportamiento real de la red, que los eventos se propagan por un broker.

Los tests de integración son más lentos y complejos que los unitarios, pero capturan una clase distinta de errores: connection strings incorrectas, discrepancias de esquema, problemas de serialización y manejo de timeouts de red. Una suite sana mantiene ambos tipos, y esta receta cubre la capa de integración en Python, JavaScript y Java.

Cuándo usarla

Usa esta receta cuando:

  • Compruebas repositorios y migraciones contra un motor de base de datos real. Connection Pooling cubre la capa de conexiones.
  • Pruebas endpoints de API HTTP con ciclos reales de request/response. Call REST API cubre el lado cliente.
  • Confirmas que productores y consumidores de colas de mensajes se integran bien. Consulta Kafka Event Streaming para la configuración de mensajería.
  • Validas el comportamiento de SDKs o APIs de terceros. Handle Errors cubre los caminos de fallo.
  • Compruebas que la configuración y el entorno están correctos. Consulta Environment Variables para patrones de configuración.
  • Ejecutas smoke tests pre-despliegue en un pipeline de CI/CD.

Sáltala cuando el riesgo vive dentro de una sola clase: la lógica pura, las transformaciones y las reglas de validación salen más baratas y precisas con un test unitario con el límite mockeado. Los tests de integración solo compensan cuando el bug se escondería en el cableado entre dos componentes reales. Para ver cómo encaja esta capa en la suite completa — proporciones de la pirámide, metas de cobertura, gates de CI — consulta la guía de estrategia de testing.

Solución

Python (pytest + Testcontainers)

import pytest
from testcontainers.postgres import PostgresContainer
from sqlalchemy import create_engine, text

@pytest.fixture(scope="module")
def db_engine():
    with PostgresContainer("postgres:16") as postgres:
        engine = create_engine(postgres.get_connection_url())
        yield engine

def test_user_repository(db_engine):
    with db_engine.connect() as conn:
        conn.execute(text("CREATE TABLE users (id SERIAL PRIMARY KEY, name TEXT)"))
        conn.execute(
            text("INSERT INTO users (name) VALUES (:name)"),
            {"name": "Alice"}
        )
        conn.commit()

        result = conn.execute(text("SELECT * FROM users"))
        users = result.fetchall()
        assert len(users) == 1
        assert users[0][1] == "Alice"

El fixture con scope="module" levanta un solo contenedor PostgreSQL por módulo de tests en lugar de uno por test, que es la mayor ganancia de velocidad en cuanto tienes más de un puñado de tests.

JavaScript (Jest + Supertest)

const request = require('supertest');
const app = require('./app'); // app Express

describe('POST /api/users', () => {
  afterAll(async () => {
    await app.db.close(); // cierra la base de datos de test
  });

  test('crea un usuario y devuelve 201', async () => {
    const response = await request(app)
      .post('/api/users')
      .send({ name: 'Alice', email: 'alice@example.com' })
      .set('Accept', 'application/json');

    expect(response.status).toBe(201);
    expect(response.body).toHaveProperty('id');
    expect(response.body.email).toBe('alice@example.com');
  });

  test('devuelve 400 para email inválido', async () => {
    const response = await request(app)
      .post('/api/users')
      .send({ name: 'Alice', email: 'not-an-email' });

    expect(response.status).toBe(400);
  });
});

Java (JUnit + Testcontainers)

import org.junit.jupiter.api.*;
import org.testcontainers.containers.PostgreSQLContainer;
import org.testcontainers.junit.jupiter.Container;
import org.testcontainers.junit.jupiter.Testcontainers;

@Testcontainers
public class UserRepositoryTest {

    @Container
    static PostgreSQLContainer<?> postgres =
        new PostgreSQLContainer<>("postgres:16");

    @Test
    void shouldInsertAndRetrieveUser() {
        UserRepository repo = new UserRepository(
            postgres.getJdbcUrl(),
            postgres.getUsername(),
            postgres.getPassword()
        );

        User user = new User("Alice", "alice@example.com");
        repo.save(user);

        User found = repo.findByEmail("alice@example.com");
        assertEquals("Alice", found.getName());
    }
}

getJdbcUrl() devuelve el puerto mapeado, así que el test funciona aunque Docker asigne un puerto aleatorio del host. Nunca hardcodees localhost:5432.

Explicación

El diagrama muestra el límite que cubre un test de integración: la app habla con infraestructura real (base de datos, broker) y con un stub para todo lo que no controlas.

flowchart diagram: Test de integración
  • Testcontainers levanta instancias Docker desechables de los servicios reales, como PostgreSQL, Kafka o Redis, con el alcance de tu ejecución de tests. Obtienes cobertura de integración genuina sin contaminar una base de datos de desarrollo compartida, y un contenedor de limpieza (Ryuk) lo elimina todo aunque un test falle. La primera ejecución paga la descarga de la imagen; después, el arranque son unos pocos segundos.
  • Motor real vs en memoria: un sustituto como H2 o SQLite diverge de PostgreSQL o MySQL en aislamiento de transacciones, coerción de tipos y soporte JSON. Tests que pasan contra el motor falso pueden fallar en producción, lo que anula el propósito. Usa el mismo motor que despliegas; Testcontainers existe justamente para eso.
  • Supertest envía peticiones HTTP reales a una app Express sin vincularse a un puerto de red. Ejercitas el ciclo completo de la petición: middleware, routing, validación y serialización.
  • Spring Boot Test con @SpringBootTest(webEnvironment = RANDOM_PORT) arranca todo el contexto de la aplicación en un puerto aleatorio, así puedes llamar a endpoints @RestController con TestRestTemplate o WebTestClient.

Variantes

HerramientaLenguajeTipo de dependenciaMejor para
TestcontainersJava/Python/Go/NodeContenedores Docker realesBases de datos, Kafka, Redis
SupertestJavaScriptHTTP en procesoAPIs Express/Fastify
pytest-djangoPythonBase de datos de test realIntegración con ORM de Django
Spring Boot TestJavaContexto completo de la appMicroservicios Spring
WireMock / MockServerJava/JSRespuestas HTTP con stubAPIs de terceros que no controlas

Mockea solo los sistemas que no son tuyos. Un stub del gateway de pago mantiene el test determinista; un stub de la base de datos elimina justo lo que el test debería verificar.

Organización de la suite

Mantén los tests de integración separados físicamente de los unitarios para que el bucle rápido siga siendo rápido:

  • Python: tests/integration/ más un marker, para seleccionar o excluir los tests lentos explícitamente.
# pytest.ini
[pytest]
markers =
    integration: necesita contenedores Docker (excluir con -m "not integration")
  • JavaScript: dale a los tests de integración su propia config de Jest (o una convención de nombres de archivo) y scripts npm separados.
{
  "scripts": {
    "test:unit": "jest --testPathIgnorePatterns integration",
    "test:integration": "jest --testMatch '**/integration/**/*.test.js'"
  }
}
  • Java: nombra las clases *IT.java y vincúlalas al plugin Maven Failsafe (mvn verify) en lugar de Surefire, así mvn test sigue siendo solo unitario.

En CI, ejecuta los tests unitarios en cada commit y los de integración en pull requests o antes del merge. Para los datos de prueba, elige una estrategia de aislamiento por suite: truncar tablas entre tests, envolver cada test en una transacción que se revierte, o recrear el esquema por clase. Mezclar estrategias dentro de una misma suite es cómo empiezan los bugs de orden flaky.

Lo que funciona

  • Usa dependencias reales, no mocks: todo el punto del testing de integración es verificar interacciones reales. Mockea solo sistemas externos que no controlas (gateways de pago, APIs de terceros).
  • Restablece el estado entre tests: trunca las tablas, vacía las colas o recrea el contenedor. Si no, los restos del test anterior deciden si el siguiente pasa.
  • Mantén los tests de integración en un directorio separado: tests/integration/ o src/test/integration/ deja claro que son más lentos y más exhaustivos.
  • Pásalos por CI, no por tu editor: separa los comandos del runner (npm run test:unit vs npm run test:integration) para que el bucle rápido siga rápido.
  • Usa puertos aleatorios y bases de datos aisladas: nunca ejecutes tests de integración contra tu base de datos de desarrollo o producción.
  • Limita el alcance: un punto de integración por test. Un test que toca la base de datos, la capa HTTP y una cola a la vez te dice que algo se rompió, pero no dónde.

Errores comunes

  • Apuntar los tests de integración a bases de datos de producción: un solo test malo puede borrar o corromper datos reales, y según la industria también puede infringir normas de compliance.
  • No limpiar después de los tests: los datos residuales causan tests flaky que pasan aislados pero fallan en una suite.
  • Mockear todo en un test de integración: si mockeas la base de datos y la capa HTTP, estás escribiendo un test unitario elaborado, no un test de integración.
  • Usar puertos hardcodeados: los conflictos de puertos causan tests flaky. Deja que el runtime asigne los puertos: getMappedPort(5432) en Java o get_connection_url() en Python devuelven el puerto libre que Docker eligió.
  • Probar demasiado en un solo test: cuando un test de integración amplio falla, pasas más tiempo depurando qué capa se rompió que escribiendo la corrección.

Solución de problemas

  • El contenedor no arranca en CI: el runner necesita acceso a Docker. En GitHub Actions los runners hospedados lo incluyen; en runners self-hosted verifica DOCKER_HOST y que el daemon de Docker esté corriendo. Sin Docker, usa service containers (services: de GitHub Actions) como alternativa.
  • La primera ejecución es lenta: descargar postgres:16 lleva un minuto. Cachea la imagen en CI o mantén la suite lo bastante pequeña para que el arranque se amortice.
  • “Connection refused” justo después del arranque: el contenedor está arriba antes de que la base de datos acepte conexiones. Testcontainers lo resuelve con wait strategies; si gestionas los contenedores tú mismo, sondea un health check en lugar de dormir un tiempo fijo.
  • Los tests pasan en local y fallan en CI: normalmente son límites de recursos o estado residual. Comprueba que la limpieza se ejecuta por test y que los workers de CI tienen memoria suficiente para los contenedores.
  • Flakes dependientes del orden: filas o mensajes de cola de un test se filtran al siguiente. Truncar las tablas entre tests, o revertir una transacción por test, elimina ese estado compartido.

Ver también

Preguntas frecuentes

¿En qué se diferencian los tests de integración de los tests end-to-end?

Un test de integración ejercita dos o tres componentes reales a la vez, como tu repositorio contra una base de datos viva. Un test end-to-end conduce la aplicación completa a través de la UI o la API pública con una herramienta de navegador como Playwright o Cypress.

¿Debería usar una base de datos en memoria como H2 o SQLite para los tests de integración?

Solo si SQLite es lo que ejecutas en producción de todos modos. H2 y SQLite divergen de PostgreSQL o MySQL en niveles de aislamiento, coerción de tipos y soporte JSON, así que una suite en verde contra el motor falso puede seguir enviando un bug real a producción. Testcontainers con el motor de producción evita esa trampa.

¿En qué se diferencian los tests de integración de los contract tests?

Un test de integración ejecuta tu código contra dependencias reales. Los contract tests comprueban que dos servicios acuerdan un esquema de mensajes sin levantar ninguno de los dos. Usa contract tests para los límites entre servicios y tests de integración para tu propio stack.

¿Testcontainers o docker-compose para el entorno de test?

docker-compose funciona, pero gestionas el ciclo de vida y los puertos tú mismo. Testcontainers arranca, mapea puertos y destruye contenedores desde el propio test, lo que mantiene la configuración junto a los tests que la necesitan y funciona igual en un portátil que en CI.

¿Cómo mantengo rápidos los tests de integración?

Reutiliza contenedores entre tests (fixtures con scope de módulo o withReuse(true)), paraleliza la ejecución y limita cada test a un punto de integración. Hecho así, una suite de unas decenas de tests de integración termina en un minuto o dos.

¿Necesito tests de integración si tengo 100 % de cobertura de tests unitarios?

Claro que sí. La cobertura cuenta líneas tocadas, no si las uniones entre componentes funcionan. Los tests unitarios con dependencias mockeadas no ven errores de cableado, deriva de esquema ni timeouts de red reales, y por eso ambos tipos existen en una suite sana.