Testcontainers: 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.
Introducción
Testcontainers levanta instancias reales de bases de datos, message brokers y otros servicios dentro de contenedores Docker para tu suite de tests. En vez de mockear PostgreSQL o Kafka, tus tests corren contra el servicio real — así capturan los bugs que los mocks esconden: diferencias de dialecto SQL, particularidades de drivers, errores de serialización, comportamiento del connection pool. Cuando la suite termina, los contenedores se destruyen automáticamente.
Esta guía cubre Testcontainers en Java, Python y Node.js, con ejemplos ejecutables para PostgreSQL, Kafka, Redis e imágenes personalizadas. También cubre lo que la mayoría de los tutoriales omite: el ciclo de vida del contenedor, networking entre contenedores, ejecución en CI y cuándo Testcontainers es la herramienta equivocada.
¿Por qué Testcontainers?
Los mocks y los fakes en memoria son rápidos pero se alejan del comportamiento real de la dependencia. Una base H2 acepta SQL que PostgreSQL rechaza. Un consumer de Kafka mockeado nunca pasa por un rebalance. La tabla resume el trade-off:
| Aspecto | Mocks / fakes en memoria | Testcontainers |
|---|---|---|
| Velocidad | Instantáneo | Segundos por arranque de contenedor |
| Fidelidad | Simulada — diverge del comportamiento real | Servicio real, drivers reales |
| Bugs que captura | Solo de lógica | SQL de dialecto, serialización, pooling, bugs de driver |
| Aislamiento de estado | Estado compartido en memoria | Contenedor fresco por suite o clase |
| Requisitos en CI | Ninguno | Daemon de Docker disponible |
Si tu servicio habla con PostgreSQL en producción, probar contra PostgreSQL real — aunque sea en un contenedor desechable — es la única forma de confiar en el test.
Cómo funciona Testcontainers
Por dentro, cada @Container o fixture sigue el mismo ciclo de vida:
Dos detalles importan en la práctica. Primero, el puerto mapeado es aleatorio: nunca hardcodees 5432; pregúntale al contenedor getMappedPort(5432) en runtime. Eso es lo que permite que ejecuciones paralelas coexistan sin colisiones de puertos. Segundo, un sidecar llamado Ryuk recoge todos los contenedores que la suite deje atrás, así que una JVM de test que crashea no deja contenedores colgados en tu máquina.
El tiempo de arranque es el costo real. Una imagen postgres:16 se descarga una vez y luego arranca en ~1-2 segundos por suite; Kafka tarda más. withReuse(true) mantiene el contenedor vivo entre corridas durante el desarrollo local — una mejora real de productividad — pero solo persiste mientras Ryuk está deshabilitado y debería quedar apagado en CI, donde conviene una pizarra limpia en cada ejecución.
Java con Testcontainers
Java es donde nació Testcontainers y donde está más pulido: módulos por servicio (postgresql, kafka, elasticsearch…) más integración con JUnit mediante @Testcontainers/@Container. Agrega el BOM de tu versión y anota.
Dependencias de Maven
<!-- pom.xml — Testcontainers for Java -->
<dependencies>
<dependency>
<groupId>org.testcontainers</groupId>
<artifactId>testcontainers</artifactId>
<version>1.20.0</version>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.testcontainers</groupId>
<artifactId>junit-jupiter</artifactId>
<version>1.20.0</version>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.testcontainers</groupId>
<artifactId>postgresql</artifactId>
<version>1.20.0</version>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.testcontainers</groupId>
<artifactId>kafka</artifactId>
<version>1.20.0</version>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.testcontainers</groupId>
<artifactId>redis</artifactId>
<version>1.20.0</version>
<scope>test</scope>
</dependency>
</dependencies>
Contenedor PostgreSQL
Declarar el contenedor como static lo comparte entre todos los tests de la clase — un solo costo de arranque, amortizado. Cada test luego limpia las tablas que toca, lo que es más rápido que pagar un arranque de contenedor por test.
// PostgresIntegrationTest.java — Database integration test
import org.junit.jupiter.api.*;
import org.testcontainers.containers.PostgreSQLContainer;
import org.testcontainers.junit.jupiter.Container;
import org.testcontainers.junit.jupiter.Testcontainers;
import static org.junit.jupiter.api.Assertions.*;
import java.sql.*;
@Testcontainers
class PostgresIntegrationTest {
@Container
static PostgreSQLContainer<?> postgres = new PostgreSQLContainer<>("postgres:16")
.withDatabaseName("testdb")
.withUsername("test")
.withPassword("test")
.withReuse(true); // Reuse across test runs for speed
private Connection connection;
@BeforeEach
void setUp() throws SQLException {
connection = DriverManager.getConnection(
postgres.getJdbcUrl(),
postgres.getUsername(),
postgres.getPassword()
);
try (Statement stmt = connection.createStatement()) {
stmt.execute("""
CREATE TABLE IF NOT EXISTS users (
id SERIAL PRIMARY KEY,
email VARCHAR(255) UNIQUE NOT NULL,
name VARCHAR(100) NOT NULL,
created_at TIMESTAMP DEFAULT NOW()
)
""");
}
}
@AfterEach
void tearDown() throws SQLException {
try (Statement stmt = connection.createStatement()) {
stmt.execute("DROP TABLE IF EXISTS users");
}
connection.close();
}
@Test
void testInsertAndQuery() throws SQLException {
try (PreparedStatement ps = connection.prepareStatement(
"INSERT INTO users (email, name) VALUES (?, ?) RETURNING id"
)) {
ps.setString(1, "alice@example.com");
ps.setString(2, "Alice");
ResultSet rs = ps.executeQuery();
assertTrue(rs.next());
int id = rs.getInt("id");
assertTrue(id > 0);
}
try (Statement stmt = connection.createStatement();
ResultSet rs = stmt.executeQuery("SELECT COUNT(*) FROM users")) {
assertTrue(rs.next());
assertEquals(1, rs.getInt(1));
}
}
@Test
void testUniqueConstraint() throws SQLException {
try (PreparedStatement ps = connection.prepareStatement(
"INSERT INTO users (email, name) VALUES (?, ?)"
)) {
ps.setString(1, "bob@example.com");
ps.setString(2, "Bob");
ps.executeUpdate();
}
// Duplicate email should fail
SQLException ex = assertThrows(SQLException.class, () -> {
try (PreparedStatement ps = connection.prepareStatement(
"INSERT INTO users (email, name) VALUES (?, ?)"
)) {
ps.setString(1, "bob@example.com");
ps.setString(2, "Bob 2");
ps.executeUpdate();
}
});
assertTrue(ex.getMessage().contains("unique constraint"));
}
}
Contenedor Kafka
Kafka ejercita lo que los mocks nunca pueden fingir: coordinación de consumer groups, reseteo de offsets y serialización real por la red. El KafkaContainer se encarga del broker y de su setup KRaft (ahora embebido) por ti.
// KafkaIntegrationTest.java — Kafka integration test
import org.junit.jupiter.api.*;
import org.testcontainers.containers.KafkaContainer;
import org.testcontainers.junit.jupiter.Container;
import org.testcontainers.junit.jupiter.Testcontainers;
import org.apache.kafka.clients.consumer.*;
import org.apache.kafka.clients.producer.*;
import org.apache.kafka.common.serialization.StringDeserializer;
import org.apache.kafka.common.serialization.StringSerializer;
import static org.junit.jupiter.api.Assertions.*;
import java.time.Duration;
import java.util.*;
@Testcontainers
class KafkaIntegrationTest {
@Container
static KafkaContainer kafka = new KafkaContainer("confluentinc/cp-kafka:7.6.0");
private Properties producerProps;
private Properties consumerProps;
@BeforeEach
void setUp() {
producerProps = new Properties();
producerProps.put(ProducerConfig.BOOTSTRAP_SERVERS_CONFIG, kafka.getBootstrapServers());
producerProps.put(ProducerConfig.KEY_SERIALIZER_CLASS_CONFIG, StringSerializer.class);
producerProps.put(ProducerConfig.VALUE_SERIALIZER_CLASS_CONFIG, StringSerializer.class);
consumerProps = new Properties();
consumerProps.put(ConsumerConfig.BOOTSTRAP_SERVERS_CONFIG, kafka.getBootstrapServers());
consumerProps.put(ConsumerConfig.GROUP_ID_CONFIG, "test-group");
consumerProps.put(ConsumerConfig.AUTO_OFFSET_RESET_CONFIG, "earliest");
consumerProps.put(ConsumerConfig.KEY_DESERIALIZER_CLASS_CONFIG, StringDeserializer.class);
consumerProps.put(ConsumerConfig.VALUE_DESERIALIZER_CLASS_CONFIG, StringDeserializer.class);
}
@Test
void testProduceAndConsume() {
String topic = "test-topic";
// Produce
try (KafkaProducer<String, String> producer = new KafkaProducer<>(producerProps)) {
ProducerRecord<String, String> record = new ProducerRecord<>(topic, "key1", "value1");
producer.send(record);
producer.flush();
}
// Consume
try (KafkaConsumer<String, String> consumer = new KafkaConsumer<>(consumerProps)) {
consumer.subscribe(List.of(topic));
ConsumerRecords<String, String> records = consumer.poll(Duration.ofSeconds(10));
assertEquals(1, records.count());
ConsumerRecord<String, String> record = records.iterator().next();
assertEquals("key1", record.key());
assertEquals("value1", record.value());
}
}
}
Contenedor Redis
Redis no tiene módulo dedicado en la librería de Java — usas GenericContainer y expones el puerto tú mismo. Ese es el patrón para cualquier servicio sin módulo: imagen, puerto, listo.
// RedisIntegrationTest.java — Redis integration test
import org.junit.jupiter.api.*;
import org.testcontainers.containers.GenericContainer;
import org.testcontainers.junit.jupiter.Container;
import org.testcontainers.junit.jupiter.Testcontainers;
import redis.clients.jedis.Jedis;
import static org.junit.jupiter.api.Assertions.*;
import java.util.*;
@Testcontainers
class RedisIntegrationTest {
@Container
static GenericContainer<?> redis = new GenericContainer<>("redis:7-alpine")
.withExposedPorts(6379);
private Jedis jedis;
@BeforeEach
void setUp() {
jedis = new Jedis(redis.getHost(), redis.getMappedPort(6379));
}
@AfterEach
void tearDown() {
jedis.flushAll();
jedis.close();
}
@Test
void testSetAndGet() {
jedis.set("key1", "value1");
assertEquals("value1", jedis.get("key1"));
}
@Test
void testHashOperations() {
jedis.hset("user:1", "name", "Alice");
jedis.hset("user:1", "email", "alice@example.com");
Map<String, String> hash = jedis.hgetAll("user:1");
assertEquals("Alice", hash.get("name"));
assertEquals("alice@example.com", hash.get("email"));
}
@Test
void testListOperations() {
jedis.lpush("queue", "item1", "item2", "item3");
assertEquals(3, jedis.llen("queue"));
assertEquals("item3", jedis.rpop("queue"));
assertEquals("item2", jedis.rpop("queue"));
}
}
Python con Testcontainers
El port de Python envuelve las mismas primitivas de Docker como fixtures de pytest. El ciclo de vida del contenedor mapea naturalmente al scope del fixture: scope="module" te da el equivalente del contenedor static de Java.
PostgreSQL
# tests/integration/test_postgres.py — Testcontainers in Python
import pytest
from testcontainers.postgres import PostgresContainer
from sqlalchemy import create_engine, text
@pytest.fixture(scope="module")
def postgres_container():
with PostgresContainer("postgres:16") as postgres:
yield postgres
@pytest.fixture(scope="module")
def db_engine(postgres_container):
engine = create_engine(postgres_container.get_connection_url())
return engine
@pytest.fixture(scope="function")
def db_session(db_engine):
with db_engine.connect() as conn:
# Setup
conn.execute(text("""
CREATE TABLE IF NOT EXISTS users (
id SERIAL PRIMARY KEY,
email VARCHAR(255) UNIQUE NOT NULL,
name VARCHAR(100) NOT NULL
)
"""))
conn.commit()
yield conn
# Cleanup
conn.execute(text("DROP TABLE IF EXISTS users"))
conn.commit()
def test_insert_user(db_session):
result = db_session.execute(text(
"INSERT INTO users (email, name) VALUES (:email, :name) RETURNING id"
), {"email": "alice@example.com", "name": "Alice"})
db_session.commit()
assert result.fetchone()[0] > 0
def test_unique_email_constraint(db_session):
db_session.execute(text(
"INSERT INTO users (email, name) VALUES ('bob@test.com', 'Bob')"
))
db_session.commit()
with pytest.raises(Exception) as exc_info:
db_session.execute(text(
"INSERT INTO users (email, name) VALUES ('bob@test.com', 'Bob2')"
))
db_session.commit()
assert "unique" in str(exc_info.value).lower()
Redis
# tests/integration/test_redis.py — Redis with Testcontainers
import pytest
from testcontainers.redis import RedisContainer
import redis
@pytest.fixture(scope="module")
def redis_container():
with RedisContainer("redis:7-alpine") as container:
yield container
@pytest.fixture
def redis_client(redis_container):
client = redis.Redis(
host=redis_container.get_container_host_ip(),
port=redis_container.get_exposed_port(6379),
decode_responses=True,
)
yield client
client.flushall()
def test_string_operations(redis_client):
redis_client.set("key1", "value1")
assert redis_client.get("key1") == "value1"
def test_hash_operations(redis_client):
redis_client.hset("user:1", mapping={"name": "Alice", "email": "alice@test.com"})
assert redis_client.hget("user:1", "name") == "Alice"
assert redis_client.hgetall("user:1") == {"name": "Alice", "email": "alice@test.com"}
def test_list_operations(redis_client):
redis_client.lpush("queue", "item1", "item2", "item3")
assert redis_client.llen("queue") == 3
assert redis_client.rpop("queue") == "item3"
Kafka
# tests/integration/test_kafka.py — Kafka with Testcontainers
import pytest
from testcontainers.kafka import KafkaContainer
from kafka import KafkaProducer, KafkaConsumer
from kafka.errors import KafkaError
import json
@pytest.fixture(scope="module")
def kafka_container():
with KafkaContainer() as kafka:
yield kafka
@pytest.fixture
def producer(kafka_container):
p = KafkaProducer(
bootstrap_servers=kafka_container.get_bootstrap_server(),
value_serializer=lambda v: json.dumps(v).encode("utf-8"),
key_serializer=lambda k: k.encode("utf-8") if k else None,
)
yield p
p.close()
@pytest.fixture
def consumer(kafka_container):
c = KafkaConsumer(
"test-topic",
bootstrap_servers=kafka_container.get_bootstrap_server(),
auto_offset_reset="earliest",
group_id="test-group",
value_deserializer=lambda v: json.loads(v.decode("utf-8")),
consumer_timeout_ms=10000,
)
yield c
c.close()
def test_produce_and_consume(producer, consumer):
producer.send("test-topic", key="key1", value={"message": "hello"})
producer.flush()
records = list(consumer)
assert len(records) >= 1
assert records[-1].key == "key1"
assert records[-1].value == {"message": "hello"}
Node.js con Testcontainers
El port de Node.js expone APIs async de contenedores — start() devuelve una promesa, y cada clase de contenedor vive en su propio paquete (@testcontainers/postgresql, @testcontainers/redis). Encaja bien con los hooks beforeAll/afterAll de vitest o jest.
Setup
npm install -D testcontainers @testcontainers/postgresql @testcontainers/redis
PostgreSQL
// tests/integration/postgres.test.ts — Testcontainers in Node.js
import { describe, it, expect, beforeAll, afterAll, beforeEach } from "vitest";
import { PostgreSqlContainer, type StartedPostgreSqlContainer } from "@testcontainers/postgresql";
import { Client } from "pg";
describe("PostgreSQL integration", () => {
let container: StartedPostgreSqlContainer;
let client: Client;
beforeAll(async () => {
container = await new PostgreSqlContainer("postgres:16")
.withDatabase("testdb")
.withUsername("test")
.withPassword("test")
.start();
client = new Client({
host: container.getHost(),
port: container.getMappedPort(5432),
database: container.getDatabase(),
user: container.getUsername(),
password: container.getPassword(),
});
await client.connect();
});
afterAll(async () => {
await client.end();
await container.stop();
});
beforeEach(async () => {
await client.query("DROP TABLE IF EXISTS users");
await client.query(`
CREATE TABLE users (
id SERIAL PRIMARY KEY,
email VARCHAR(255) UNIQUE NOT NULL,
name VARCHAR(100) NOT NULL
)
`);
});
it("inserts and retrieves a user", async () => {
const result = await client.query(
"INSERT INTO users (email, name) VALUES ($1, $2) RETURNING id",
["alice@example.com", "Alice"]
);
expect(result.rows[0].id).toBeGreaterThan(0);
const count = await client.query("SELECT COUNT(*) FROM users");
expect(parseInt(count.rows[0].count)).toBe(1);
});
it("enforces unique email constraint", async () => {
await client.query(
"INSERT INTO users (email, name) VALUES ($1, $2)",
["bob@example.com", "Bob"]
);
await expect(
client.query("INSERT INTO users (email, name) VALUES ($1, $2)", ["bob@example.com", "Bob2"])
).rejects.toThrow(/unique/i);
});
});
Redis
// tests/integration/redis.test.ts — Redis with Testcontainers
import { describe, it, expect, beforeAll, afterAll, beforeEach } from "vitest";
import { RedisContainer, type StartedRedisContainer } from "@testcontainers/redis";
import Redis from "ioredis";
describe("Redis integration", () => {
let container: StartedRedisContainer;
let redis: Redis;
beforeAll(async () => {
container = await new RedisContainer("redis:7-alpine").start();
redis = new Redis(container.getPort(), container.getHost());
});
afterAll(async () => {
await redis.quit();
await container.stop();
});
beforeEach(async () => {
await redis.flushall();
});
it("sets and gets a string", async () => {
await redis.set("key1", "value1");
expect(await redis.get("key1")).toBe("value1");
});
it("performs hash operations", async () => {
await redis.hset("user:1", "name", "Alice", "email", "alice@test.com");
expect(await redis.hget("user:1", "name")).toBe("Alice");
expect(await redis.hgetall("user:1")).toEqual({
name: "Alice",
email: "alice@test.com",
});
});
});
Contenedores personalizados
Cualquier imagen de Docker se convierte en dependencia de test mediante GenericContainer — el servidor de API mock de abajo es un caso de uso común. withClasspathResourceMapping copia archivos de configuración dentro del contenedor y withEnv define el entorno que espera.
// CustomContainerTest.java — Custom container for any Docker image
import org.junit.jupiter.api.*;
import org.testcontainers.containers.GenericContainer;
import org.testcontainers.containers.BindMode;
import org.testcontainers.junit.jupiter.Container;
import org.testcontainers.junit.jupiter.Testcontainers;
import static org.junit.jupiter.api.Assertions.*;
import java.net.http.*;
import java.net.URI;
@Testcontainers
class CustomContainerTest {
@Container
static GenericContainer<?> mockServer = new GenericContainer<>("mockserver/mockserver:5.15.0")
.withExposedPorts(1080)
.withEnv("MOCKSERVER_INITIALIZATION_JSON_PATH", "/config/expectations.json")
.withClasspathResourceMapping("config/expectations.json",
"/config/expectations.json",
BindMode.READ_ONLY);
@Test
void testMockServerResponse() throws Exception {
String baseUrl = String.format("http://%s:%d",
mockServer.getHost(),
mockServer.getMappedPort(1080));
HttpClient client = HttpClient.newHttpClient();
HttpRequest request = HttpRequest.newBuilder()
.uri(URI.create(baseUrl + "/api/users"))
.GET()
.build();
HttpResponse<String> response = client.send(request,
HttpResponse.BodyHandlers.ofString());
assertEquals(200, response.statusCode());
assertTrue(response.body().contains("users"));
}
}
Networking entre contenedores
Cuando tu test necesita que dos servicios hablen entre sí — un contenedor de app llamando a PostgreSQL, un worker consumiendo de Kafka — ponlos en una Network compartida y dale a cada uno un alias de red. Dentro de la red se resuelven mutuamente por alias en los puertos estándar; tu código de test sigue llegando por los puertos mapeados del host.
// NetworkExample — two containers that see each other
import org.testcontainers.containers.Network;
import org.testcontainers.containers.PostgreSQLContainer;
import org.testcontainers.containers.GenericContainer;
Network network = Network.newNetwork();
PostgreSQLContainer<?> postgres = new PostgreSQLContainer<>("postgres:16")
.withNetwork(network)
.withNetworkAliases("db");
GenericContainer<?> app = new GenericContainer<>("myapp:test")
.withNetwork(network)
.withEnv("DB_URL", "jdbc:postgresql://db:5432/testdb")
.dependsOn(postgres);
Fíjate en el dependsOn: el orden de arranque importa cuando un servicio necesita al otro listo primero. Para un control más fino, waitingFor(Wait.forLogMessage(...)) pausa la ejecución del test hasta que el servicio reporta estar listo — el remedio para los fallos flaky de “connection refused” con servicios de arranque lento como Kafka o Elasticsearch.
Ejecutar en CI
Todos los runners de CI importantes traen Docker disponible para el build, así que Testcontainers funciona sin configuración — sin bloque services:, sin puertos que cablear. Lo principal a vigilar es la memoria: varios contenedores pesados más una JVM pueden exceder el límite de un runner pequeño.
# .github/workflows/integration-tests.yml
name: Integration Tests
on: [push]
jobs:
integration:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-java@v4
with:
distribution: temurin
java-version: "21"
- name: Run integration tests
run: mvn verify -Pintegration-tests
Eso es todo el archivo — los runners de GitHub Actions ya tienen el daemon de Docker que Testcontainers necesita. Para GitLab CI el equivalente es image: docker:dind o un runner con /var/run/docker.sock montado. Si tus tests hacen timeout en CI pero pasan en local, el cuello de botella casi siempre es la descarga de imágenes: fíjalas y considera un mirror de registry o caché para las grandes.
Cuándo no usar Testcontainers
- Tests unitarios puros. La lógica de negocio sin dependencias externas no necesita Docker: mantenla rápida y sin mocks.
- Integraciones con contrato estable. Cuando una API de terceros es estable y solo te importa la forma request/response, un stub server como WireMock es más rápido y determinista.
- Entornos sin Docker. Algunos tiers de CI y dev containers restringen el socket de Docker; verifica antes de adoptarlo en todo.
- Loops de feedback locales muy ajustados. Si tu ciclo interno debe quedar bajo un segundo, mantén los tests de contenedor en una suite aparte y corre los unitarios continuamente.
El punto no es que los contenedores sean siempre mejores: es que la capa que toca infraestructura real debe probarse contra infraestructura real.
Mejores Prácticas
- Usa
withReuse(true)para contenedores que no cambian — acelera el desarrollo local. Desactívalo en CI para que cada ejecución empiece limpia. - Define el scope de los contenedores como
@Container(por test) ostatic(por clase) según las necesidades de aislamiento. - Limpia siempre el estado en
@BeforeEach— los contenedores persisten entre tests de la misma clase. - Fija versiones de imagen específicas —
postgres:16, nopostgres:latest, para reproducibilidad. - Define límites de recursos en CI —
withMemory()ywithCPUs()previenen el agotamiento de recursos de los contenedores. - Usa
WaitStrategypara contenedores de arranque lento —withStartupTimeout(Duration.ofMinutes(2)). - Mantén las imágenes pequeñas — las variantes Alpine arrancan más rápido.
- Corre los tests de integración separados de los unitarios —
mvn test -DexcludedGroups=integrationpara feedback rápido.
Errores Comunes
- Usar la etiqueta
latest: las imágenes cambian entre corridas y causan tests flaky. Fija versiones específicas. - No limpiar el estado entre tests: los datos de un test afectan al siguiente. Usa limpieza en
@BeforeEach. - Un contenedor por test: crear un contenedor nuevo para cada test es lento. Usa contenedores
staticcon limpieza. - Sin timeout de arranque: algunos contenedores tardan más que los 60s por defecto. Configura
withStartupTimeout. - No disponer los contenedores: se fugan si no se detienen correctamente. Usa try-with-resources o
@AfterAll.
Ver También
- Guía completa de JUnit 5 — el framework de test detrás de los ejemplos en Java.
- Pytest para producción — fixtures y markers detrás de los ejemplos en Python.
- CI/CD con GitHub Actions — para cablear jobs de test como el workflow de arriba.
- Documentación de Testcontainers — lista de módulos y guías de inicio por lenguaje.
Código companion: proyectos multi-lenguaje de Testcontainers: los tests de Java, Python y Node.js de esta guía como proyectos ejecutables, más el workflow de CI.
Preguntas frecuentes
¿Qué es Testcontainers?
Una librería que crea contenedores Docker para tests de integración. Soporta Java (JUnit, Spring), Python (pytest), Node.js, Go y .NET, con módulos para servicios comunes como PostgreSQL, Kafka, Redis y Elasticsearch.
¿En qué se diferencia Testcontainers del mocking?
El mocking simula el comportamiento en código. Testcontainers corre servicios reales en contenedores Docker, capturando problemas que los mocks esconden: diferencias de dialecto SQL, connection pooling, serialización y compatibilidad de drivers.
¿Testcontainers requiere Docker?
Sí. Docker debe estar instalado y corriendo en la máquina que ejecuta los tests. En CI, Docker suele venir preinstalado en GitHub Actions, GitLab CI y la mayoría de los runners.
¿Cómo acelero los tests de Testcontainers?
Usa withReuse(true) para mantener los contenedores vivos entre corridas de test. Usa contenedores static compartidos entre tests de una clase. Usa imágenes basadas en Alpine para un arranque más rápido. Corre los tests unitarios separados de los de integración.
¿Puedo usar Testcontainers con Spring Boot?
Sí. Agrega la dependencia spring-boot-testcontainers. Usa @ServiceConnection para autoconfigurar las fuentes de datos de Spring desde las propiedades del contenedor. Esto elimina la configuración manual de @DynamicPropertySource.
Recursos Relacionados
Desarrollo Guiado por Pruebas (TDD)
Aprende TDD paso a paso: escribe un test que falle, hazlo pasar, refactoriza. Red-Green-Refactor con ejemplos reales en Python, JavaScript y Java.
GuideGuía de Estrategia de Testing
Una guía práctica para construir una estrategia de testing en capas con unit, integration y end-to-end tests.
GuideGuía de Pytest en Producción
Dominá pytest para codebases de producción: fixtures avanzados, plugins, markers personalizados, tests parametrizados, ejecución paralela con pytest-xdist e integración con CI.
GuideJUnit 5: Extensions, Tests Parametrizados, Dynamic Tests
Dominá JUnit 5 para testing moderno en Java: modelo de extensions, tests parametrizados, dynamic tests, test interfaces, lifecycle, conditional execution y JUnit Platform.
GuideGuía completa de CI/CD con GitHub Actions
Construye pipelines CI/CD desde cero con GitHub Actions. Cubre workflows, runners, builds en matriz, caché, secretos, entornos, estrategias de despliegue y workflows reutilizables.
GuideChaos Engineering
Guia practica de chaos engineering: construye sistemas resilientes inyectando fallos intencionalmente. Aprende los cinco principios, Litmus, Gremlin y Chaos Mesh.