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.
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.
- 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@RestControllerconTestRestTemplateoWebTestClient.
Variantes
| Herramienta | Lenguaje | Tipo de dependencia | Mejor para |
|---|---|---|---|
| Testcontainers | Java/Python/Go/Node | Contenedores Docker reales | Bases de datos, Kafka, Redis |
| Supertest | JavaScript | HTTP en proceso | APIs Express/Fastify |
| pytest-django | Python | Base de datos de test real | Integración con ORM de Django |
| Spring Boot Test | Java | Contexto completo de la app | Microservicios Spring |
| WireMock / MockServer | Java/JS | Respuestas HTTP con stub | APIs 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.javay vincúlalas al plugin Maven Failsafe (mvn verify) en lugar de Surefire, asímvn testsigue 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/osrc/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:unitvsnpm 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 oget_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_HOSTy 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:16lleva 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
- Documentación de Testcontainers
- Supertest en GitHub
- Guía de usuario de JUnit 5
- Martin Fowler — The Practical Test Pyramid
- Plantilla de Estrategia de Pruebas — donde se documentan estos gates de CI y objetivos de cobertura
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.
Recursos Relacionados
Pruebas Unitarias
Cómo escribir pruebas unitarias rápidas y deterministas con mocks y assertions en Python, JavaScript y Java.
RecipeEscribir Unit Tests con Mocks y Stubs
Cómo aislar código bajo test usando objetos mock, stubs y spies para reemplazar dependencias externas como bases de datos, APIs y sistemas de archivos.
RecipePruebas de Contratos de API con Consumer-Driven Contracts
Cómo prevenir cambios que rompen la compatibilidad entre microservicios con contract testing consumer-driven usando Pact y validadores de OpenAPI.
RecipeTestcontainers para Tests de Integración en Java
Cómo usar Testcontainers en JUnit5 para levantar contenedores reales de Postgres, Redis y Kafka para tests de integración confiables y reproducibles.
RecipeTestear APIs Express con supertest
Cómo testear endpoints REST de Express.js end-to-end usando supertest, incluyendo códigos de estado, bodies JSON, headers, autenticación y manejo de errores.
GuideTestcontainers: dependencias reales en tests de integración
Domina Testcontainers para pruebas de integración con bases de datos, message brokers y APIs reales. Cubre Java, Python y Node.js con fixtures basados en Docker.