La contenedorización ha revolucionado el testing de software al proporcionar entornos de prueba consistentes, aislados y reproducibles. Esta guía completa explora cómo aprovechar Docker (como se discute en Continuous Testing in DevOps: Quality Gates and CI/CD Integration), Kubernetes y Testcontainers para construir una infraestructura de testing robusta que escala según tus necesidades.
¿Por Qué Contenedorización para Testing?
Antes de profundizar en los detalles de implementación, entendamos los beneficios clave:
- Consistencia de Entorno: “Funciona en mi máquina” queda en el pasado
- Aislamiento de Tests: Cada test se ejecuta en un entorno limpio y aislado
- Eficiencia de Recursos: Los contenedores son ligeros comparados con máquinas virtuales
- Aprovisionamiento Rápido: Levanta entornos de prueba completos en segundos
- Ejecución Paralela: Ejecuta tests concurrentemente sin interferencias
- Control de Versiones: Entornos de prueba definidos como código en tu repositorio
Docker para Entornos de Prueba
Docker es la base de la contenedorización moderna. Entender cómo crear imágenes Docker efectivas para testing es crucial.
Creando Imágenes de Entorno de Prueba
Un Dockerfile bien diseñado para testing equilibra el tamaño de imagen, velocidad de construcción y funcionalidad.
Dockerfile Básico para Test Runner:
# Construcción multi-etapa para tamaño óptimo
FROM node:18-alpine AS builder
# Instalar dependencias de construcción
WORKDIR /app
COPY package*.json ./
RUN npm ci --only=production
# Etapa de runtime
FROM node:18-alpine
# Instalar herramientas de testing
RUN apk add --no-cache \
curl \
wget \
chromium \
chromium-chromedriver
# Configurar usuario sin privilegios de root para seguridad
RUN addgroup -g 1001 tester && \
adduser -D -u 1001 -G tester tester
WORKDIR /app
COPY --from=builder /app/node_modules ./node_modules
COPY . .
# Cambiar propietario
RUN chown -R tester:tester /app
USER tester
# Variables de entorno para testing
ENV NODE_ENV=test
ENV CHROME_BIN=/usr/bin/chromium-browser
ENV CHROME_PATH=/usr/lib/chromium/
# Health check
HEALTHCHECK --interval=30s --timeout=10s --start-period=5s --retries=3 \
CMD node healthcheck.js || exit 1
CMD ["npm", "test"]
Entorno de Testing en Python:
FROM python:3.11-slim
# Instalar dependencias del sistema
RUN apt-get update && apt-get install -y \
gcc \
postgresql-client \
curl \
&& rm -rf /var/lib/apt/lists/*
# Configurar directorio de trabajo
WORKDIR /tests
# Instalar dependencias de Python
COPY requirements.txt requirements-test.txt ./
RUN pip install --no-cache-dir -r requirements.txt -r requirements-test.txt
# Copiar código de tests
COPY tests/ ./tests/
COPY conftest.py pytest.ini ./
# Crear directorio de resultados
RUN mkdir -p /tests/results && \
chmod 777 /tests/results
# Ejecutar tests con pytest
CMD (como se discute en [IDE and Extensions for Testers: Complete Tooling Guide for QA Engineers](/blog/ide-extensions-for-testers)) ["pytest", "-v", "--junitxml=/tests/results/junit.xml", \
"--html=/tests/results/report.html", "--self-contained-html"]
Docker Compose para Orquestación de Servicios
Docker Compose sobresale en gestionar entornos de prueba multi-contenedor con dependencias complejas.
Configuración Completa de Testing de Integración:
version: '3.8'
services:
# Aplicación bajo prueba
app:
build:
context: .
dockerfile: Dockerfile
ports:
- "3000:3000"
environment:
- DATABASE_URL=postgresql://testuser:testpass@postgres:5432/testdb
- REDIS_URL=redis://redis:6379
- NODE_ENV=test
depends_on:
postgres:
condition: service_healthy
redis:
condition: service_started
networks:
- test-network
volumes:
- ./coverage:/app/coverage
# Base de datos PostgreSQL
postgres:
image: postgres:15-alpine
environment:
POSTGRES_USER: testuser
POSTGRES_PASSWORD: testpass
POSTGRES_DB: testdb
ports:
- "5432:5432"
volumes:
- postgres-data:/var/lib/postgresql/data
- ./init-scripts:/docker-entrypoint-initdb.d
healthcheck:
test: ["CMD-SHELL", "pg_isready -U testuser -d testdb"]
interval: 10s
timeout: 5s
retries: 5
networks:
- test-network
# Caché Redis
redis:
image: redis:7-alpine
ports:
- "6379:6379"
networks:
- test-network
healthcheck:
test: ["CMD", "redis-cli", "ping"]
interval: 10s
timeout: 3s
retries: 5
# Selenium Grid Hub
selenium-hub:
image: selenium/hub:4.15.0
ports:
- "4444:4444"
- "4442:4442"
- "4443:4443"
environment:
- SE_SESSION_REQUEST_TIMEOUT=300
- SE_SESSION_RETRY_INTERVAL=5
- SE_NODE_MAX_SESSIONS=5
networks:
- test-network
# Nodo Chrome
chrome:
image: selenium/node-chrome:4.15.0
shm_size: 2gb
depends_on:
- selenium-hub
environment:
- SE_EVENT_BUS_HOST=selenium-hub
- SE_EVENT_BUS_PUBLISH_PORT=4442
- SE_EVENT_BUS_SUBSCRIBE_PORT=4443
- SE_NODE_MAX_SESSIONS=5
- SE_NODE_SESSION_TIMEOUT=300
networks:
- test-network
# Nodo Firefox
firefox:
image: selenium/node-firefox:4.15.0
shm_size: 2gb
depends_on:
- selenium-hub
environment:
- SE_EVENT_BUS_HOST=selenium-hub
- SE_EVENT_BUS_PUBLISH_PORT=4442
- SE_EVENT_BUS_SUBSCRIBE_PORT=4443
- SE_NODE_MAX_SESSIONS=5
networks:
- test-network
# Ejecutor de tests de integración
tests:
build:
context: .
dockerfile: Dockerfile.test
depends_on:
app:
condition: service_started
selenium-hub:
condition: service_started
environment:
- APP_URL=http://app:3000
- SELENIUM_URL=http://selenium-hub:4444/wd/hub
- DATABASE_URL=postgresql://testuser:testpass@postgres:5432/testdb
volumes:
- ./tests:/tests
- ./test-results:/test-results
networks:
- test-network
command: ["./wait-for-it.sh", "app:3000", "--", "pytest", "-v"]
networks:
test-network:
driver: bridge
volumes:
postgres-data:
Patrones Avanzados de Docker Compose
Estrategias de Espera para Dependencias:
# Usando healthchecks con depends_on
services:
app:
depends_on:
database:
condition: service_healthy
cache:
condition: service_started
# La app solo inicia después de que la base de datos esté saludable
database:
healthcheck:
test: ["CMD", "mysqladmin", "ping", "-h", "localhost"]
timeout: 20s
retries: 10
Script Personalizado wait-for-it:
#!/bin/bash
# wait-for-it.sh - Esperar disponibilidad del servicio
set -e
host="$1"
shift
port="$1"
shift
cmd="$@"
until nc -z "$host" "$port"; do
>&2 echo "Servicio $host:$port no disponible - durmiendo"
sleep 1
done
>&2 echo "Servicio $host:$port está activo - ejecutando comando"
exec $cmd
Redes Docker para Tests
Entender las redes Docker es crucial para escenarios de prueba complejos.
Configuración de Red Personalizada:
networks:
frontend:
driver: bridge
ipam:
config:
- subnet: 172.20.0.0/16
backend:
driver: bridge
internal: true # Sin acceso externo
services:
web:
networks:
- frontend
- backend
database:
networks:
- backend # Solo accesible desde red backend
Límites de Recursos y Rendimiento
La asignación adecuada de recursos previene interferencias entre tests y asegura estabilidad.
services:
test-runner:
image: test-runner:latest
deploy:
resources:
limits:
cpus: '2.0'
memory: 2G
reservations:
cpus: '1.0'
memory: 1G
ulimits:
nofile:
soft: 65536
hard: 65536
Kubernetes para Escalar la Ejecución de Tests
Kubernetes lleva el testing contenedorizado al siguiente nivel, habilitando escala masiva y orquestación sofisticada.
Pod Básico de Ejecución de Tests
apiVersion: v1
kind: Pod
metadata:
name: test-runner
labels:
app: test-runner
type: integration-test
spec:
restartPolicy: Never
containers:
- name: test-runner
image: myregistry/test-runner:latest
command: ["pytest", "-v", "--junitxml=/results/junit.xml"]
resources:
requests:
memory: "1Gi"
cpu: "500m"
limits:
memory: "2Gi"
cpu: "1000m"
volumeMounts:
- name: test-results
mountPath: /results
env:
- name: DATABASE_URL
valueFrom:
secretKeyRef:
name: test-secrets
key: database-url
- name: TEST_ENVIRONMENT
value: "kubernetes"
volumes:
- name: test-results
persistentVolumeClaim:
claimName: test-results-pvc
Job de Tests para Integración CI/CD
apiVersion: batch/v1
kind: Job
metadata:
name: integration-tests
namespace: testing
spec:
# Ejecutar tests con 5 pods en paralelo
parallelism: 5
completions: 5
backoffLimit: 2
ttlSecondsAfterFinished: 3600 # Limpiar después de 1 hora
template:
metadata:
labels:
job: integration-tests
spec:
restartPolicy: Never
containers:
- name: test-runner
image: myregistry/integration-tests:v1.2.3
command:
- /bin/sh
- -c
- |
echo "Iniciando ejecución de tests en pod ${HOSTNAME}"
pytest tests/ \
--junitxml=/results/junit-${HOSTNAME}.xml \
--html=/results/report-${HOSTNAME}.html \
-n auto
resources:
requests:
memory: "2Gi"
cpu: "1000m"
limits:
memory: "4Gi"
cpu: "2000m"
volumeMounts:
- name: shared-results
mountPath: /results
env:
- name: POD_NAME
valueFrom:
fieldRef:
fieldPath: metadata.name
- name: API_ENDPOINT
valueFrom:
configMapKeyRef:
name: test-config
key: api-endpoint
volumes:
- name: shared-results
persistentVolumeClaim:
claimName: test-results-pvc
Deployment para Entorno de Prueba Persistente
apiVersion: apps/v1
kind: Deployment
metadata:
name: test-environment
namespace: testing
spec:
replicas: 3
selector:
matchLabels:
app: test-app
template:
metadata:
labels:
app: test-app
version: v1
spec:
containers:
- name: app
image: myapp:test
ports:
- containerPort: 8080
env:
- name: DATABASE_HOST
value: postgres-service
- name: REDIS_HOST
value: redis-service
livenessProbe:
httpGet:
path: /health
port: 8080
initialDelaySeconds: 30
periodSeconds: 10
readinessProbe:
httpGet:
path: /ready
port: 8080
initialDelaySeconds: 5
periodSeconds: 5
---
apiVersion: v1
kind: Service
metadata:
name: test-app-service
namespace: testing
spec:
selector:
app: test-app
ports:
- protocol: TCP
port: 80
targetPort: 8080
type: ClusterIP
ConfigMap y Secrets para Configuración de Tests
apiVersion: v1
kind: ConfigMap
metadata:
name: test-config
namespace: testing
data:
api-endpoint: "http://test-app-service"
test-timeout: "300"
parallel-workers: "10"
pytest.ini: |
[pytest]
testpaths = tests
python_files = test_*.py
python_functions = test_*
markers =
smoke: Tests de humo rápidos
integration: Tests de integración
slow: Tests de ejecución lenta
---
apiVersion: v1
kind: Secret
metadata:
name: test-secrets
namespace: testing
type: Opaque
stringData:
database-url: "postgresql://user:password@postgres:5432/testdb"
api-key: "test-api-key-12345"
auth-token: "Bearer test-token-xyz"
Autoescalado Horizontal de Pods para Cargas de Prueba
apiVersion: autoscaling/v2
kind: HorizontalPodAutoscaler
metadata:
name: test-runner-hpa
namespace: testing
spec:
scaleTargetRef:
apiVersion: apps/v1
kind: Deployment
name: test-environment
minReplicas: 2
maxReplicas: 20
metrics:
- type: Resource
resource:
name: cpu
target:
type: Utilization
averageUtilization: 70
- type: Resource
resource:
name: memory
target:
type: Utilization
averageUtilization: 80
behavior:
scaleUp:
stabilizationWindowSeconds: 60
policies:
- type: Percent
value: 100
periodSeconds: 60
scaleDown:
stabilizationWindowSeconds: 300
policies:
- type: Percent
value: 50
periodSeconds: 60
Volumen Persistente para Artefactos de Tests
apiVersion: v1
kind: PersistentVolumeClaim
metadata:
name: test-results-pvc
namespace: testing
spec:
accessModes:
- ReadWriteMany
resources:
requests:
storage: 10Gi
storageClassName: standard
---
apiVersion: v1
kind: Pod
metadata:
name: artifact-collector
namespace: testing
spec:
containers:
- name: collector
image: nginx:alpine
ports:
- containerPort: 80
volumeMounts:
- name: test-results
mountPath: /usr/share/nginx/html
readOnly: true
volumes:
- name: test-results
persistentVolumeClaim:
claimName: test-results-pvc
Testcontainers: Testing con Dependencias Reales
Testcontainers trae el poder de Docker a tu código de prueba, permitiéndote gestionar programáticamente el ciclo de vida de contenedores.
Testcontainers en Java
Testing Básico de Base de Datos:
import org.junit.jupiter.api.Test;
import org.testcontainers.containers.PostgreSQLContainer;
import org.testcontainers.junit.jupiter.Container;
import org.testcontainers.junit.jupiter.Testcontainers;
import java.sql.Connection;
import java.sql.DriverManager;
import java.sql.ResultSet;
import java.sql.Statement;
@Testcontainers
public class DatabaseIntegrationTest {
@Container
private static final PostgreSQLContainer<?> postgres =
new PostgreSQLContainer<>("postgres:15-alpine")
.withDatabaseName("testdb")
.withUsername("testuser")
.withPassword("testpass")
.withInitScript("init.sql");
@Test
void testDatabaseConnection() throws Exception {
String jdbcUrl = postgres.getJdbcUrl();
try (Connection conn = DriverManager.getConnection(
jdbcUrl,
postgres.getUsername(),
postgres.getPassword());
Statement stmt = conn.createStatement()) {
ResultSet rs = stmt.executeQuery("SELECT version()");
rs.next();
String version = rs.getString(1);
assertNotNull(version);
assertTrue(version.contains("PostgreSQL"));
}
}
@Test
void testUserRepository() {
UserRepository repository = new UserRepository(postgres.getJdbcUrl());
User user = new User("john@example.com", "John Doe");
repository.save(user);
Optional<User> found = repository.findByEmail("john@example.com");
assertTrue(found.isPresent());
assertEquals("John Doe", found.get().getName());
}
}
Configuración Avanzada Multi-Contenedor:
import org.testcontainers.containers.GenericContainer;
import org.testcontainers.containers.Network;
import org.testcontainers.containers.wait.strategy.Wait;
import org.testcontainers.utility.DockerImageName;
public class MicroserviceIntegrationTest {
private static final Network network = Network.newNetwork();
@Container
private static final PostgreSQLContainer<?> database =
new PostgreSQLContainer<>("postgres:15")
.withNetwork(network)
.withNetworkAliases("database");
@Container
private static final GenericContainer<?> redis =
new GenericContainer<>(DockerImageName.parse("redis:7-alpine"))
.withNetwork(network)
.withNetworkAliases("redis")
.withExposedPorts(6379);
@Container
private static final GenericContainer<?> app =
new GenericContainer<>(DockerImageName.parse("myapp:latest"))
.withNetwork(network)
.withExposedPorts(8080)
.withEnv("DATABASE_URL", "jdbc:postgresql://database:5432/testdb")
.withEnv("REDIS_URL", "redis://redis:6379")
.dependsOn(database, redis)
.waitingFor(Wait.forHttp("/health").forStatusCode(200));
@Test
void testFullStack() {
String baseUrl = String.format("http://%s:%d",
app.getHost(),
app.getMappedPort(8080));
// Hacer peticiones HTTP a la aplicación
RestAssured.baseURI = baseUrl;
given()
.contentType("application/json")
.body(new CreateUserRequest("test@example.com"))
.when()
.post("/api/users")
.then()
.statusCode(201)
.body("email", equalTo("test@example.com"));
}
}
Docker Compose con Testcontainers:
import org.testcontainers.containers.ComposeContainer;
import org.testcontainers.containers.wait.strategy.Wait;
import java.io.File;
import java.time.Duration;
public class ComposeIntegrationTest {
@Container
private static final ComposeContainer environment =
new ComposeContainer(new File("docker-compose.test.yml"))
.withExposedService("app", 8080,
Wait.forHttp("/health")
.forStatusCode(200)
.withStartupTimeout(Duration.ofMinutes(2)))
.withExposedService("postgres", 5432,
Wait.forListeningPort())
.withLocalCompose(true)
.withPull(false);
@Test
void testCompleteSystem() {
String appHost = environment.getServiceHost("app", 8080);
Integer appPort = environment.getServicePort("app", 8080);
String baseUrl = String.format("http://%s:%d", appHost, appPort);
// Ejecutar tests de integración contra el entorno completo
Response response = RestAssured.get(baseUrl + "/api/status");
assertEquals(200, response.getStatusCode());
}
}
Testcontainers en Python
Testing de Integración con PostgreSQL:
import pytest
from testcontainers.postgres import PostgresContainer
import psycopg2
@pytest.fixture(scope="module")
def postgres_container():
with PostgresContainer("postgres:15-alpine") as postgres:
yield postgres
def test_database_operations(postgres_container):
# Obtener parámetros de conexión
connection_url = postgres_container.get_connection_url()
# Conectar a la base de datos
conn = psycopg2.connect(connection_url)
cursor = conn.cursor()
# Crear tabla
cursor.execute("""
CREATE TABLE users (
id SERIAL PRIMARY KEY,
email VARCHAR(255) UNIQUE NOT NULL,
name VARCHAR(255) NOT NULL
)
""")
# Insertar datos
cursor.execute(
"INSERT INTO users (email, name) VALUES (%s, %s)",
("test@example.com", "Usuario de Prueba")
)
conn.commit()
# Consultar datos
cursor.execute("SELECT * FROM users WHERE email = %s", ("test@example.com",))
result = cursor.fetchone()
assert result is not None
assert result[1] == "test@example.com"
assert result[2] == "Usuario de Prueba"
cursor.close()
conn.close()
def test_sqlalchemy_integration(postgres_container):
from sqlalchemy import create_engine, Column, Integer, String
from sqlalchemy.ext.declarative import declarative_base
from sqlalchemy.orm import sessionmaker
Base = declarative_base()
class User(Base):
__tablename__ = 'users'
id = Column(Integer, primary_key=True)
email = Column(String, unique=True)
name = Column(String)
# Crear engine
engine = create_engine(postgres_container.get_connection_url())
Base.metadata.create_all(engine)
# Crear sesión
Session = sessionmaker(bind=engine)
session = Session()
# Agregar usuario
user = User(email="sqlalchemy@example.com", name="Usuario SQLAlchemy")
session.add(user)
session.commit()
# Consultar usuario
found = session.query(User).filter_by(email="sqlalchemy@example.com").first()
assert found is not None
assert found.name == "Usuario SQLAlchemy"
Testing con Redis:
from testcontainers.redis import RedisContainer
import redis
@pytest.fixture(scope="module")
def redis_container():
with RedisContainer("redis:7-alpine") as redis_cont:
yield redis_cont
def test_redis_operations(redis_container):
# Obtener detalles de conexión
host = redis_container.get_container_host_ip()
port = redis_container.get_exposed_port(6379)
# Conectar a Redis
client = redis.Redis(host=host, port=port, decode_responses=True)
# Establecer y obtener valor
client.set("test_key", "test_value")
value = client.get("test_key")
assert value == "test_value"
# Probar expiración
client.setex("temp_key", 1, "temporal")
assert client.get("temp_key") == "temporal"
import time
time.sleep(2)
assert client.get("temp_key") is None
Configuración Personalizada de Contenedor:
from testcontainers.core.container import DockerContainer
from testcontainers.core.waiting_strategies import wait_for_logs
import requests
@pytest.fixture(scope="module")
def custom_app_container():
container = (
DockerContainer("myapp:test")
.with_exposed_ports(8080)
.with_env("DATABASE_URL", "sqlite:///test.db")
.with_env("LOG_LEVEL", "DEBUG")
.with_volume_mapping("/tmp/test-data", "/app/data")
)
with container:
# Esperar a que la aplicación esté lista
wait_for_logs(container, "Application started", timeout=30)
yield container
def test_application_endpoint(custom_app_container):
host = custom_app_container.get_container_host_ip()
port = custom_app_container.get_exposed_port(8080)
url = f"http://{host}:{port}/api/health"
response = requests.get(url)
assert response.status_code == 200
assert response.json()["status"] == "healthy"
Testcontainers en Node.js
Testing de Integración con MongoDB:
const { MongoDBContainer } = require('@testcontainers/mongodb');
const { MongoClient } = require('mongodb');
describe('Tests de Integración MongoDB', () => {
let container;
let client;
let db;
beforeAll(async () => {
// Iniciar contenedor MongoDB
container = await new MongoDBContainer('mongo:7')
.withExposedPorts(27017)
.start();
// Conectar a MongoDB
const connectionString = container.getConnectionString();
client = await MongoClient.connect(connectionString);
db = client.db('testdb');
}, 60000);
afterAll(async () => {
await client?.close();
await container?.stop();
});
test('debería insertar y encontrar documentos', async () => {
const collection = db.collection('users');
// Insertar documento
const result = await collection.insertOne({
email: 'test@example.com',
name: 'Usuario de Prueba',
createdAt: new Date()
});
expect(result.acknowledged).toBe(true);
// Encontrar documento
const user = await collection.findOne({ email: 'test@example.com' });
expect(user).toBeDefined();
expect(user.name).toBe('Usuario de Prueba');
});
test('debería actualizar documentos', async () => {
const collection = db.collection('users');
await collection.updateOne(
{ email: 'test@example.com' },
{ $set: { name: 'Usuario Actualizado' } }
);
const user = await collection.findOne({ email: 'test@example.com' });
expect(user.name).toBe('Usuario Actualizado');
});
});
PostgreSQL con Configuración Personalizada:
const { PostgreSqlContainer } = require('@testcontainers/postgresql');
const { Client } = require('pg');
describe('Tests Avanzados PostgreSQL', () => {
let container;
let client;
beforeAll(async () => {
container = await new PostgreSqlContainer('postgres:15-alpine')
.withDatabase('testdb')
.withUsername('testuser')
.withPassword('testpass')
.withCopyFilesToContainer([{
source: './init.sql',
target: '/docker-entrypoint-initdb.d/init.sql'
}])
.withEnvironment({
'POSTGRES_INITDB_ARGS': '--encoding=UTF8 --locale=en_US.UTF-8'
})
.withTmpFs({ '/var/lib/postgresql/data': 'rw,noexec,nosuid,size=1024m' })
.start();
client = new Client({
host: container.getHost(),
port: container.getMappedPort(5432),
database: container.getDatabase(),
user: container.getUsername(),
password: container.getPassword()
});
await client.connect();
}, 60000);
afterAll(async () => {
await client?.end();
await container?.stop();
});
test('debería ejecutar consultas complejas', async () => {
const result = await client.query(`
SELECT
u.id,
u.name,
COUNT(o.id) as order_count
FROM users u
LEFT JOIN orders o ON u.id = o.user_id
GROUP BY u.id, u.name
HAVING COUNT(o.id) > 0
`);
expect(result.rows).toEqual(expect.arrayContaining([
expect.objectContaining({
id: expect.any(Number),
name: expect.any(String),
order_count: expect.any(String)
})
]));
});
});
Testing de Aplicación Multi-Contenedor:
const { GenericContainer, Network } = require('testcontainers');
const axios = require('axios');
describe('Integración de Microservicios', () => {
let network;
let dbContainer;
let redisContainer;
let appContainer;
let baseUrl;
beforeAll(async () => {
// Crear red compartida
network = await new Network().start();
// Iniciar PostgreSQL
dbContainer = await new GenericContainer('postgres:15-alpine')
.withNetwork(network)
.withNetworkAliases('database')
.withEnvironment({
POSTGRES_DB: 'appdb',
POSTGRES_USER: 'appuser',
POSTGRES_PASSWORD: 'apppass'
})
.withExposedPorts(5432)
.start();
// Iniciar Redis
redisContainer = await new GenericContainer('redis:7-alpine')
.withNetwork(network)
.withNetworkAliases('redis')
.withExposedPorts(6379)
.start();
// Iniciar aplicación
appContainer = await new GenericContainer('myapp:latest')
.withNetwork(network)
.withEnvironment({
DATABASE_URL: 'postgresql://appuser:apppass@database:5432/appdb',
REDIS_URL: 'redis://redis:6379'
})
.withExposedPorts(3000)
.withWaitStrategy(Wait.forHttp('/health', 3000))
.start();
const host = appContainer.getHost();
const port = appContainer.getMappedPort(3000);
baseUrl = `http://${host}:${port}`;
}, 120000);
afterAll(async () => {
await appContainer?.stop();
await redisContainer?.stop();
await dbContainer?.stop();
await network?.stop();
});
test('debería crear y recuperar usuario', async () => {
// Crear usuario
const createResponse = await axios.post(`${baseUrl}/api/users`, {
email: 'integration@example.com',
name: 'Usuario de Prueba de Integración'
});
expect(createResponse.status).toBe(201);
const userId = createResponse.data.id;
// Recuperar usuario
const getResponse = await axios.get(`${baseUrl}/api/users/${userId}`);
expect(getResponse.status).toBe(200);
expect(getResponse.data.email).toBe('integration@example.com');
});
test('debería cachear datos accedidos frecuentemente', async () => {
const userId = 1;
// Primera petición (cache miss)
const start1 = Date.now();
await axios.get(`${baseUrl}/api/users/${userId}`);
const duration1 = Date.now() - start1;
// Segunda petición (cache hit)
const start2 = Date.now();
await axios.get(`${baseUrl}/api/users/${userId}`);
const duration2 = Date.now() - start2;
// La segunda petición debería ser más rápida (cacheada)
expect(duration2).toBeLessThan(duration1);
});
});
Docker Compose para Tests de Integración
Patrones avanzados de Docker Compose específicamente diseñados para testing de integración.
Stack Completo de Testing
version: '3.8'
x-common-variables: &common-variables
NODE_ENV: test
LOG_LEVEL: debug
services:
# Base de datos de prueba con inicialización
test-db:
image: postgres:15-alpine
environment:
POSTGRES_DB: testdb
POSTGRES_USER: testuser
POSTGRES_PASSWORD: testpass
PGDATA: /var/lib/postgresql/data/pgdata
volumes:
- ./db-init:/docker-entrypoint-initdb.d
- postgres-data:/var/lib/postgresql/data
ports:
- "5432:5432"
healthcheck:
test: ["CMD-SHELL", "pg_isready -U testuser"]
interval: 5s
timeout: 5s
retries: 10
networks:
- test-net
# Redis para caché y sesiones
test-redis:
image: redis:7-alpine
command: redis-server --appendonly yes --requirepass testpass
volumes:
- redis-data:/data
ports:
- "6379:6379"
healthcheck:
test: ["CMD", "redis-cli", "--raw", "incr", "ping"]
interval: 5s
timeout: 3s
retries: 5
networks:
- test-net
# Cola de mensajes
test-rabbitmq:
image: rabbitmq:3-management-alpine
environment:
RABBITMQ_DEFAULT_USER: testuser
RABBITMQ_DEFAULT_PASS: testpass
ports:
- "5672:5672"
- "15672:15672"
healthcheck:
test: ["CMD", "rabbitmq-diagnostics", "ping"]
interval: 10s
timeout: 10s
retries: 5
networks:
- test-net
# Elasticsearch para búsqueda
test-elasticsearch:
image: docker.elastic.co/elasticsearch/elasticsearch:8.11.0
environment:
- discovery.type=single-node
- xpack.security.enabled=false
- "ES_JAVA_OPTS=-Xms512m -Xmx512m"
volumes:
- es-data:/usr/share/elasticsearch/data
ports:
- "9200:9200"
healthcheck:
test: ["CMD-SHELL", "curl -f http://localhost:9200/_cluster/health || exit 1"]
interval: 10s
timeout: 5s
retries: 10
networks:
- test-net
# API Gateway
api-gateway:
build:
context: ./services/gateway
dockerfile: Dockerfile.test
environment:
<<: *common-variables
PORT: 8080
AUTH_SERVICE_URL: http://auth-service:3001
USER_SERVICE_URL: http://user-service:3002
ports:
- "8080:8080"
depends_on:
test-redis:
condition: service_healthy
networks:
- test-net
healthcheck:
test: ["CMD", "curl", "-f", "http://localhost:8080/health"]
interval: 10s
timeout: 5s
retries: 5
# Servicio de Autenticación
auth-service:
build:
context: ./services/auth
dockerfile: Dockerfile.test
environment:
<<: *common-variables
PORT: 3001
DATABASE_URL: postgresql://testuser:testpass@test-db:5432/testdb
REDIS_URL: redis://:testpass@test-redis:6379
depends_on:
test-db:
condition: service_healthy
test-redis:
condition: service_healthy
networks:
- test-net
healthcheck:
test: ["CMD", "curl", "-f", "http://localhost:3001/health"]
interval: 10s
timeout: 5s
retries: 5
# Servicio de Usuarios
user-service:
build:
context: ./services/users
dockerfile: Dockerfile.test
environment:
<<: *common-variables
PORT: 3002
DATABASE_URL: postgresql://testuser:testpass@test-db:5432/testdb
ELASTICSEARCH_URL: http://test-elasticsearch:9200
RABBITMQ_URL: amqp://testuser:testpass@test-rabbitmq:5672
depends_on:
test-db:
condition: service_healthy
test-elasticsearch:
condition: service_healthy
test-rabbitmq:
condition: service_healthy
networks:
- test-net
healthcheck:
test: ["CMD", "curl", "-f", "http://localhost:3002/health"]
interval: 10s
timeout: 5s
retries: 5
# Ejecutor de Tests E2E
e2e-tests:
build:
context: ./tests/e2e
dockerfile: Dockerfile
environment:
API_BASE_URL: http://api-gateway:8080
SELENIUM_URL: http://selenium-hub:4444/wd/hub
TEST_USER_EMAIL: test@example.com
TEST_USER_PASSWORD: testpass123
volumes:
- ./tests/e2e:/tests
- test-results:/test-results
- ./tests/e2e/screenshots:/screenshots
depends_on:
api-gateway:
condition: service_healthy
auth-service:
condition: service_healthy
user-service:
condition: service_healthy
selenium-hub:
condition: service_started
networks:
- test-net
command: >
sh -c "
echo 'Esperando a que los servicios estén listos...' &&
sleep 10 &&
npm run test:e2e -- --reporter=html --reporter-options output=/test-results/report.html
"
# Selenium Grid Hub
selenium-hub:
image: selenium/hub:4.15.0
ports:
- "4444:4444"
- "4442:4442"
- "4443:4443"
environment:
GRID_MAX_SESSION: 10
GRID_BROWSER_TIMEOUT: 300
GRID_TIMEOUT: 300
networks:
- test-net
# Nodos Chrome
selenium-chrome:
image: selenium/node-chrome:4.15.0
shm_size: 2gb
depends_on:
- selenium-hub
environment:
SE_EVENT_BUS_HOST: selenium-hub
SE_EVENT_BUS_PUBLISH_PORT: 4442
SE_EVENT_BUS_SUBSCRIBE_PORT: 4443
SE_NODE_MAX_SESSIONS: 5
SE_SCREEN_WIDTH: 1920
SE_SCREEN_HEIGHT: 1080
volumes:
- /dev/shm:/dev/shm
networks:
- test-net
# Monitoreo de rendimiento
test-prometheus:
image: prom/prometheus:latest
volumes:
- ./monitoring/prometheus.yml:/etc/prometheus/prometheus.yml
- prometheus-data:/prometheus
command:
- '--config.file=/etc/prometheus/prometheus.yml'
- '--storage.tsdb.path=/prometheus'
ports:
- "9090:9090"
networks:
- test-net
# Visualización de métricas
test-grafana:
image: grafana/grafana:latest
environment:
GF_SECURITY_ADMIN_PASSWORD: admin
GF_USERS_ALLOW_SIGN_UP: false
volumes:
- grafana-data:/var/lib/grafana
- ./monitoring/grafana/dashboards:/etc/grafana/provisioning/dashboards
ports:
- "3000:3000"
depends_on:
- test-prometheus
networks:
- test-net
networks:
test-net:
driver: bridge
volumes:
postgres-data:
redis-data:
es-data:
test-results:
prometheus-data:
grafana-data:
Estrategias de Espera y Health Checks
Script Personalizado de Health Check:
#!/bin/bash
# healthcheck.sh - Verificación integral de salud de servicios
set -e
check_postgres() {
pg_isready -h test-db -U testuser -d testdb
}
check_redis() {
redis-cli -h test-redis -a testpass ping
}
check_api() {
curl -f http://api-gateway:8080/health || exit 1
}
check_elasticsearch() {
curl -f http://test-elasticsearch:9200/_cluster/health?wait_for_status=yellow&timeout=30s
}
echo "Verificando PostgreSQL..."
until check_postgres; do
echo "PostgreSQL no está listo - durmiendo"
sleep 2
done
echo "Verificando Redis..."
until check_redis; do
echo "Redis no está listo - durmiendo"
sleep 2
done
echo "Verificando Elasticsearch..."
until check_elasticsearch; do
echo "Elasticsearch no está listo - durmiendo"
sleep 2
done
echo "Verificando API Gateway..."
until check_api; do
echo "API no está lista - durmiendo"
sleep 2
done
echo "Todos los servicios están saludables!"
Integración CI/CD
GitHub Actions
name: Tests de Integración
on:
push:
branches: [ main, develop ]
pull_request:
branches: [ main ]
jobs:
integration-tests:
runs-on: ubuntu-latest
services:
postgres:
image: postgres:15-alpine
env:
POSTGRES_PASSWORD: testpass
POSTGRES_USER: testuser
POSTGRES_DB: testdb
options: >-
--health-cmd pg_isready
--health-interval 10s
--health-timeout 5s
--health-retries 5
ports:
- 5432:5432
redis:
image: redis:7-alpine
options: >-
--health-cmd "redis-cli ping"
--health-interval 10s
--health-timeout 5s
--health-retries 5
ports:
- 6379:6379
steps:
- uses: actions/checkout@v3
- name: Configurar Docker Buildx
uses: docker/setup-buildx-action@v2
- name: Construir imagen de prueba
uses: docker/build-push-action@v4
with:
context: .
file: ./Dockerfile.test
push: false
load: true
tags: test-runner:latest
cache-from: type=gha
cache-to: type=gha,mode=max
- name: Ejecutar tests de integración
run: |
docker run --rm \
--network host \
-e DATABASE_URL=postgresql://testuser:testpass@localhost:5432/testdb \
-e REDIS_URL=redis://localhost:6379 \
-v ${{ github.workspace }}/test-results:/results \
test-runner:latest \
pytest -v --junitxml=/results/junit.xml
- name: Publicar resultados de tests
uses: EnricoMi/publish-unit-test-result-action@v2
if: always()
with:
files: test-results/junit.xml
- name: Subir cobertura
uses: codecov/codecov-action@v3
with:
files: ./test-results/coverage.xml
docker-compose-tests:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
- name: Ejecutar tests Docker Compose
run: |
docker-compose -f docker-compose.test.yml up \
--abort-on-container-exit \
--exit-code-from tests
- name: Recolectar logs
if: failure()
run: |
docker-compose -f docker-compose.test.yml logs > docker-logs.txt
- name: Subir logs
if: failure()
uses: actions/upload-artifact@v3
with:
name: docker-logs
path: docker-logs.txt
- name: Limpieza
if: always()
run: |
docker-compose -f docker-compose.test.yml down -v
kubernetes-tests:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
- name: Configurar Minikube
uses: medyagh/setup-minikube@latest
- name: Desplegar entorno de prueba
run: |
kubectl apply -f k8s/test-namespace.yaml
kubectl apply -f k8s/test-config.yaml
kubectl apply -f k8s/test-secrets.yaml
kubectl apply -f k8s/test-deployment.yaml
kubectl wait --for=condition=ready pod -l app=test-app -n testing --timeout=300s
- name: Ejecutar tests
run: |
kubectl apply -f k8s/test-job.yaml
kubectl wait --for=condition=complete job/integration-tests -n testing --timeout=600s
- name: Recolectar resultados
if: always()
run: |
kubectl logs job/integration-tests -n testing > test-output.log
- name: Limpieza
if: always()
run: |
kubectl delete namespace testing
GitLab CI
stages:
- build
- test
- cleanup
variables:
DOCKER_DRIVER: overlay2
DOCKER_TLS_CERTDIR: "/certs"
POSTGRES_DB: testdb
POSTGRES_USER: testuser
POSTGRES_PASSWORD: testpass
build-test-image:
stage: build
image: docker:24
services:
- docker:24-dind
script:
- docker build -t $CI_REGISTRY_IMAGE/test-runner:$CI_COMMIT_SHA -f Dockerfile.test .
- docker push $CI_REGISTRY_IMAGE/test-runner:$CI_COMMIT_SHA
only:
- branches
integration-tests:
stage: test
image: docker:24
services:
- docker:24-dind
- postgres:15-alpine
- redis:7-alpine
variables:
POSTGRES_HOST_AUTH_METHOD: trust
script:
# Esperar servicios
- apk add --no-cache postgresql-client
- until pg_isready -h postgres -U $POSTGRES_USER; do sleep 2; done
# Ejecutar tests
- |
docker run --rm \
--network host \
-e DATABASE_URL=postgresql://$POSTGRES_USER:$POSTGRES_PASSWORD@postgres:5432/$POSTGRES_DB \
-e REDIS_URL=redis://redis:6379 \
-v $CI_PROJECT_DIR/test-results:/results \
$CI_REGISTRY_IMAGE/test-runner:$CI_COMMIT_SHA
artifacts:
when: always
reports:
junit: test-results/junit.xml
paths:
- test-results/
expire_in: 1 week
docker-compose-tests:
stage: test
image: docker/compose:latest
services:
- docker:24-dind
script:
- docker-compose -f docker-compose.test.yml up --abort-on-container-exit --exit-code-from tests
after_script:
- docker-compose -f docker-compose.test.yml logs > docker-logs.txt
- docker-compose -f docker-compose.test.yml down -v
artifacts:
when: on_failure
paths:
- docker-logs.txt
expire_in: 3 days
k8s-integration-tests:
stage: test
image: google/cloud-sdk:alpine
script:
# Instalar kubectl
- curl -LO "https://dl.k8s.io/release/$(curl -L -s https://dl.k8s.io/release/stable.txt)/bin/linux/amd64/kubectl"
- chmod +x kubectl
- mv kubectl /usr/local/bin/
# Desplegar en cluster de prueba
- kubectl config use-context test-cluster
- kubectl apply -f k8s/test-namespace.yaml
- kubectl apply -f k8s/ -n testing
- kubectl wait --for=condition=complete job/integration-tests -n testing --timeout=10m
# Recolectar resultados
- kubectl logs job/integration-tests -n testing > k8s-test-output.log
after_script:
- kubectl delete namespace testing --ignore-not-found=true
artifacts:
when: always
paths:
- k8s-test-output.log
expire_in: 1 week
only:
- main
- develop
Pipeline Jenkins
pipeline {
agent any
environment {
DOCKER_REGISTRY = 'registry.example.com'
IMAGE_NAME = 'test-runner'
IMAGE_TAG = "${env.BUILD_NUMBER}"
}
stages {
stage('Construir Imagen de Prueba') {
steps {
script {
docker.build("${DOCKER_REGISTRY}/${IMAGE_NAME}:${IMAGE_TAG}", "-f Dockerfile.test .")
}
}
}
stage('Tests de Integración') {
steps {
script {
docker.image('postgres:15-alpine').withRun('-e POSTGRES_PASSWORD=testpass -e POSTGRES_USER=testuser -e POSTGRES_DB=testdb') { db ->
docker.image('redis:7-alpine').withRun() { redis ->
// Esperar servicios
sh 'sleep 10'
// Ejecutar tests
docker.image("${DOCKER_REGISTRY}/${IMAGE_NAME}:${IMAGE_TAG}").inside("--link ${db.id}:postgres --link ${redis.id}:redis") {
sh '''
export DATABASE_URL=postgresql://testuser:testpass@postgres:5432/testdb
export REDIS_URL=redis://redis:6379
pytest -v --junitxml=test-results/junit.xml
'''
}
}
}
}
}
}
stage('Tests Docker Compose') {
steps {
sh '''
docker-compose -f docker-compose.test.yml up --abort-on-container-exit --exit-code-from tests
'''
}
post {
always {
sh 'docker-compose -f docker-compose.test.yml logs > docker-logs.txt'
sh 'docker-compose -f docker-compose.test.yml down -v'
}
}
}
stage('Publicar Resultados') {
steps {
junit 'test-results/junit.xml'
publishHTML([
reportDir: 'test-results',
reportFiles: 'report.html',
reportName: 'Reporte de Pruebas'
])
}
}
}
post {
failure {
archiveArtifacts artifacts: 'docker-logs.txt', allowEmptyArchive: true
}
cleanup {
cleanWs()
}
}
}
Mejores Prácticas y Optimización de Rendimiento
1. Optimización de Imágenes
Construcción multi-etapa:
# Etapa de construcción
FROM node:18 AS builder
WORKDIR /app
COPY package*.json ./
RUN npm ci
COPY . .
RUN npm run build
# Etapa de prueba
FROM node:18-alpine AS test
WORKDIR /app
COPY --from=builder /app/node_modules ./node_modules
COPY --from=builder /app/dist ./dist
COPY package*.json ./
RUN npm install --only=dev
CMD ["npm", "test"]
# Imagen final mínima
FROM node:18-alpine
WORKDIR /app
COPY --from=builder /app/dist ./dist
COPY --from=builder /app/node_modules ./node_modules
USER node
CMD ["node", "dist/index.js"]
2. Gestión de Recursos
# Límites de recursos para rendimiento predecible
services:
test-runner:
deploy:
resources:
limits:
cpus: '2'
memory: 2G
reservations:
cpus: '1'
memory: 1G
# Prevenir OOM killer
oom_kill_disable: true
# Establecer swappiness de memoria
sysctls:
- vm.swappiness=10
3. Aislamiento y Limpieza de Tests
import pytest
from testcontainers.postgres import PostgresContainer
@pytest.fixture(scope="function") # Nuevo contenedor por test
def isolated_database():
with PostgresContainer("postgres:15") as postgres:
# Cada test obtiene una base de datos limpia
yield postgres
# Limpieza automática después del test
def test_with_isolation_1(isolated_database):
# Test 1 con base de datos limpia
pass
def test_with_isolation_2(isolated_database):
# Test 2 con base de datos limpia (sin interferencia)
pass
4. Depuración de Tests Contenedorizados
# Acceder a contenedor en ejecución
docker-compose exec tests /bin/sh
# Ver logs en tiempo real
docker-compose logs -f tests
# Inspeccionar sistema de archivos del contenedor
docker-compose exec tests ls -la /app
# Verificar variables de entorno
docker-compose exec tests env
# Depurar networking
docker-compose exec tests ping database
docker-compose exec tests nslookup database
# Mantener contenedor corriendo después de fallo de test
docker-compose run --rm tests /bin/sh
5. Monitoreo de Rendimiento
import time
import psutil
from testcontainers.core.container import DockerContainer
def monitor_container_resources(container: DockerContainer):
stats = container.get_wrapped_container().stats(stream=False)
cpu_percent = stats['cpu_stats']['cpu_usage']['total_usage']
memory_usage = stats['memory_stats']['usage'] / (1024 * 1024) # MB
print(f"Uso de CPU: {cpu_percent}%")
print(f"Uso de Memoria: {memory_usage:.2f} MB")
@pytest.fixture
def monitored_container():
container = DockerContainer("myapp:latest")
container.start()
yield container
# Imprimir uso de recursos después del test
monitor_container_resources(container)
container.stop()
Comparación: Enfoques de Testing
Enfoque | Caso de Uso | Ventajas | Desventajas |
---|---|---|---|
Docker Compose | Desarrollo local, stacks simples | Configuración fácil, bueno para equipos pequeños | Escalado limitado, host único |
Testcontainers | Tests unitarios/integración | Nativo del lenguaje, control programático | Overhead por test, requiere Docker |
Kubernetes | Testing a gran escala, similar a producción | Altamente escalable, paridad con producción | Configuración compleja, curva de aprendizaje |
Servicios CI/CD | Tests simples, feedback rápido | Sin gestión de contenedores, rápido | Personalización limitada, vendor lock-in |
Conclusión
La contenedorización ha transformado el testing de software al proporcionar entornos de prueba aislados, reproducibles y escalables. Docker sirve como base para crear infraestructura de testing consistente, mientras Docker Compose simplifica la orquestación multi-servicio para testing de integración.
Testcontainers trae control programático a tu código de prueba, permitiendo integración sin problemas con frameworks de testing existentes en múltiples lenguajes de programación. Para organizaciones que requieren escala masiva, Kubernetes proporciona capacidades sofisticadas de orquestación con escalado horizontal y gestión de recursos.
La clave para el testing contenedorizado exitoso radica en:
- Elegir la herramienta correcta para tu escala y complejidad
- Optimizar imágenes para tiempos de construcción e inicio rápidos
- Implementar health checks apropiados y estrategias de espera
- Gestionar recursos efectivamente para prevenir interferencias
- Integrar sin problemas con pipelines CI/CD
- (como se discute en Cloud Testing Platforms: Complete Guide to BrowserStack, Sauce Labs, AWS Device Farm & More) Mantener aislamiento de tests para resultados confiables
Al dominar estas tecnologías de contenedorización, construirás una infraestructura de testing robusta y escalable que crece con las necesidades de tu aplicación mientras mantiene consistencia y confiabilidad en todos los entornos.