Containerization has revolutionized software testing by providing consistent, isolated, and reproducible test environments. This comprehensive guide explores how to leverage Docker (as discussed in Continuous Testing in DevOps: Quality Gates and CI/CD Integration), Kubernetes, and Testcontainers to build robust testing infrastructure that scales with your needs.
Why Containerization for Testing?
Before diving into implementation details, let’s understand the key benefits:
- Environment Consistency: “Works on my machine” becomes a thing of the past
- Test Isolation: Each test runs in a clean, isolated environment
- Resource Efficiency: Containers are lightweight compared to virtual machines
- Rapid Provisioning: Spin up complete test environments in seconds
- Parallel Execution: Run tests concurrently without interference
- Version Control: Test environments defined as code in your repository
Docker for Test Environments
Docker is the foundation of modern containerization. Understanding how to create effective Docker images for testing is crucial.
Creating Test Environment Images
A well-designed Dockerfile for testing balances image size, build speed, and functionality.
Basic Test Runner Dockerfile:
# Multi-stage build for optimal image size
FROM node:18-alpine AS builder
# Install build dependencies
WORKDIR /app
COPY package*.json ./
RUN npm ci --only=production
# Runtime stage
FROM node:18-alpine
# Install testing tools
RUN apk add --no-cache \
curl \
wget \
chromium \
chromium-chromedriver
# Set up non-root user for security
RUN addgroup -g 1001 tester && \
adduser -D -u 1001 -G tester tester
WORKDIR /app
COPY --from=builder /app/node_modules ./node_modules
COPY . .
# Change ownership
RUN chown -R tester:tester /app
USER tester
# Environment variables for 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"]
Python Testing Environment:
FROM python:3.11-slim
# Install system dependencies
RUN apt-get update && apt-get install -y \
gcc \
postgresql-client \
curl \
&& rm -rf /var/lib/apt/lists/*
# Set up working directory
WORKDIR /tests
# Install Python dependencies
COPY requirements.txt requirements-test.txt ./
RUN pip install --no-cache-dir -r requirements.txt -r requirements-test.txt
# Copy test code
COPY tests/ ./tests/
COPY conftest.py pytest.ini ./
# Create test results directory
RUN mkdir -p /tests/results && \
chmod 777 /tests/results
# Run tests with pytest
CMD (as discussed in [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 for Service Orchestration
Docker Compose excels at managing multi-container test environments with complex dependencies.
Complete Integration Testing Setup:
version: '3.8'
services:
# Application under test
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
# PostgreSQL database
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
# Redis cache
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
# Chrome node
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
# Firefox node
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
# Integration test runner
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:
Advanced Docker Compose Patterns
Wait Strategies for Dependencies:
# Using healthchecks with depends_on
services:
app:
depends_on:
database:
condition: service_healthy
cache:
condition: service_started
# App will only start after database is healthy
database:
healthcheck:
test: ["CMD", "mysqladmin", "ping", "-h", "localhost"]
timeout: 20s
retries: 10
Custom wait-for-it Script:
#!/bin/bash
# wait-for-it.sh - Wait for service availability
set -e
host="$1"
shift
port="$1"
shift
cmd="$@"
until nc -z "$host" "$port"; do
>&2 echo "Service $host:$port is unavailable - sleeping"
sleep 1
done
>&2 echo "Service $host:$port is up - executing command"
exec $cmd
Docker Networking for Tests
Understanding Docker networking is crucial for complex test scenarios.
Custom Network Configuration:
networks:
frontend:
driver: bridge
ipam:
config:
- subnet: 172.20.0.0/16
backend:
driver: bridge
internal: true # No external access
services:
web:
networks:
- frontend
- backend
database:
networks:
- backend # Only accessible from backend network
Resource Limits and Performance
Proper resource allocation prevents test interference and ensures stability.
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 for Scaling Test Execution
Kubernetes takes containerized testing to the next level, enabling massive scale and sophisticated orchestration.
Basic Test Execution Pod
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
Test Job for CI/CD Integration
apiVersion: batch/v1
kind: Job
metadata:
name: integration-tests
namespace: testing
spec:
# Run tests with 5 parallel pods
parallelism: 5
completions: 5
backoffLimit: 2
ttlSecondsAfterFinished: 3600 # Clean up after 1 hour
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 "Starting test execution on 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 for Persistent Test Environment
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 and Secrets for Test Configuration
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: Quick smoke tests
integration: Integration tests
slow: Slow running tests
---
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"
Horizontal Pod Autoscaling for Test Loads
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
Persistent Volume for Test Artifacts
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 with Real Dependencies
Testcontainers brings the power of Docker to your test code, allowing you to programmatically manage container lifecycle.
Java Testcontainers
Basic Database Testing:
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());
}
}
Advanced Multi-Container Setup:
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));
// Make HTTP requests to the application
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 with 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);
// Run integration tests against the full environment
Response response = RestAssured.get(baseUrl + "/api/status");
assertEquals(200, response.getStatusCode());
}
}
Python Testcontainers
PostgreSQL Integration Testing:
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):
# Get connection parameters
connection_url = postgres_container.get_connection_url()
# Connect to database
conn = psycopg2.connect(connection_url)
cursor = conn.cursor()
# Create table
cursor.execute("""
CREATE TABLE users (
id SERIAL PRIMARY KEY,
email VARCHAR(255) UNIQUE NOT NULL,
name VARCHAR(255) NOT NULL
)
""")
# Insert data
cursor.execute(
"INSERT INTO users (email, name) VALUES (%s, %s)",
("test@example.com", "Test User")
)
conn.commit()
# Query data
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] == "Test User"
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)
# Create engine
engine = create_engine(postgres_container.get_connection_url())
Base.metadata.create_all(engine)
# Create session
Session = sessionmaker(bind=engine)
session = Session()
# Add user
user = User(email="sqlalchemy@example.com", name="SQLAlchemy User")
session.add(user)
session.commit()
# Query user
found = session.query(User).filter_by(email="sqlalchemy@example.com").first()
assert found is not None
assert found.name == "SQLAlchemy User"
Redis Testing:
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):
# Get connection details
host = redis_container.get_container_host_ip()
port = redis_container.get_exposed_port(6379)
# Connect to Redis
client = redis.Redis(host=host, port=port, decode_responses=True)
# Set and get value
client.set("test_key", "test_value")
value = client.get("test_key")
assert value == "test_value"
# Test expiration
client.setex("temp_key", 1, "temporary")
assert client.get("temp_key") == "temporary"
import time
time.sleep(2)
assert client.get("temp_key") is None
Custom Container Configuration:
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:
# Wait for application to be ready
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"
Node.js Testcontainers
MongoDB Integration Testing:
const { MongoDBContainer } = require('@testcontainers/mongodb');
const { MongoClient } = require('mongodb');
describe('MongoDB Integration Tests', () => {
let container;
let client;
let db;
beforeAll(async () => {
// Start MongoDB container
container = await new MongoDBContainer('mongo:7')
.withExposedPorts(27017)
.start();
// Connect to MongoDB
const connectionString = container.getConnectionString();
client = await MongoClient.connect(connectionString);
db = client.db('testdb');
}, 60000);
afterAll(async () => {
await client?.close();
await container?.stop();
});
test('should insert and find documents', async () => {
const collection = db.collection('users');
// Insert document
const result = await collection.insertOne({
email: 'test@example.com',
name: 'Test User',
createdAt: new Date()
});
expect(result.acknowledged).toBe(true);
// Find document
const user = await collection.findOne({ email: 'test@example.com' });
expect(user).toBeDefined();
expect(user.name).toBe('Test User');
});
test('should update documents', async () => {
const collection = db.collection('users');
await collection.updateOne(
{ email: 'test@example.com' },
{ $set: { name: 'Updated User' } }
);
const user = await collection.findOne({ email: 'test@example.com' });
expect(user.name).toBe('Updated User');
});
});
PostgreSQL with Custom Configuration:
const { PostgreSqlContainer } = require('@testcontainers/postgresql');
const { Client } = require('pg');
describe('PostgreSQL Advanced Tests', () => {
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('should execute complex queries', 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)
})
]));
});
});
Multi-Container Application Testing:
const { GenericContainer, Network } = require('testcontainers');
const axios = require('axios');
describe('Microservices Integration', () => {
let network;
let dbContainer;
let redisContainer;
let appContainer;
let baseUrl;
beforeAll(async () => {
// Create shared network
network = await new Network().start();
// Start 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();
// Start Redis
redisContainer = await new GenericContainer('redis:7-alpine')
.withNetwork(network)
.withNetworkAliases('redis')
.withExposedPorts(6379)
.start();
// Start application
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('should create and retrieve user', async () => {
// Create user
const createResponse = await axios.post(`${baseUrl}/api/users`, {
email: 'integration@example.com',
name: 'Integration Test User'
});
expect(createResponse.status).toBe(201);
const userId = createResponse.data.id;
// Retrieve user
const getResponse = await axios.get(`${baseUrl}/api/users/${userId}`);
expect(getResponse.status).toBe(200);
expect(getResponse.data.email).toBe('integration@example.com');
});
test('should cache frequently accessed data', async () => {
const userId = 1;
// First request (cache miss)
const start1 = Date.now();
await axios.get(`${baseUrl}/api/users/${userId}`);
const duration1 = Date.now() - start1;
// Second request (cache hit)
const start2 = Date.now();
await axios.get(`${baseUrl}/api/users/${userId}`);
const duration2 = Date.now() - start2;
// Second request should be faster (cached)
expect(duration2).toBeLessThan(duration1);
});
});
Docker Compose for Integration Tests
Advanced Docker Compose patterns specifically designed for integration testing.
Complete Testing Stack
version: '3.8'
x-common-variables: &common-variables
NODE_ENV: test
LOG_LEVEL: debug
services:
# Test database with initialization
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 for caching and sessions
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
# Message queue
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 for search
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
# Authentication Service
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
# User Service
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
# E2E Test Runner
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 'Waiting for services to be ready...' &&
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
# Chrome Nodes
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
# Performance monitoring
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
# Metrics visualization
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:
Wait Strategies and Health Checks
Custom Health Check Script:
#!/bin/bash
# healthcheck.sh - Comprehensive service health verification
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 "Checking PostgreSQL..."
until check_postgres; do
echo "PostgreSQL not ready - sleeping"
sleep 2
done
echo "Checking Redis..."
until check_redis; do
echo "Redis not ready - sleeping"
sleep 2
done
echo "Checking Elasticsearch..."
until check_elasticsearch; do
echo "Elasticsearch not ready - sleeping"
sleep 2
done
echo "Checking API Gateway..."
until check_api; do
echo "API not ready - sleeping"
sleep 2
done
echo "All services are healthy!"
CI/CD Integration
GitHub Actions
name: Integration Tests
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: Set up Docker Buildx
uses: docker/setup-buildx-action@v2
- name: Build test image
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: Run integration tests
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: Publish test results
uses: EnricoMi/publish-unit-test-result-action@v2
if: always()
with:
files: test-results/junit.xml
- name: Upload coverage
uses: codecov/codecov-action@v3
with:
files: ./test-results/coverage.xml
docker-compose-tests:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
- name: Run Docker Compose tests
run: |
docker-compose -f docker-compose.test.yml up \
--abort-on-container-exit \
--exit-code-from tests
- name: Collect logs
if: failure()
run: |
docker-compose -f docker-compose.test.yml logs > docker-logs.txt
- name: Upload logs
if: failure()
uses: actions/upload-artifact@v3
with:
name: docker-logs
path: docker-logs.txt
- name: Cleanup
if: always()
run: |
docker-compose -f docker-compose.test.yml down -v
kubernetes-tests:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
- name: Set up Minikube
uses: medyagh/setup-minikube@latest
- name: Deploy test environment
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: Run tests
run: |
kubectl apply -f k8s/test-job.yaml
kubectl wait --for=condition=complete job/integration-tests -n testing --timeout=600s
- name: Collect test results
if: always()
run: |
kubectl logs job/integration-tests -n testing > test-output.log
- name: Cleanup
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:
# Wait for services
- apk add --no-cache postgresql-client
- until pg_isready -h postgres -U $POSTGRES_USER; do sleep 2; done
# Run 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:
# Install 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/
# Deploy to test cluster
- 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
# Collect results
- 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
Jenkins Pipeline
pipeline {
agent any
environment {
DOCKER_REGISTRY = 'registry.example.com'
IMAGE_NAME = 'test-runner'
IMAGE_TAG = "${env.BUILD_NUMBER}"
}
stages {
stage('Build Test Image') {
steps {
script {
docker.build("${DOCKER_REGISTRY}/${IMAGE_NAME}:${IMAGE_TAG}", "-f Dockerfile.test .")
}
}
}
stage('Integration Tests') {
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 ->
// Wait for services
sh 'sleep 10'
// Run 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('Docker Compose Tests') {
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('Publish Results') {
steps {
junit 'test-results/junit.xml'
publishHTML([
reportDir: 'test-results',
reportFiles: 'report.html',
reportName: 'Test Report'
])
}
}
}
post {
failure {
archiveArtifacts artifacts: 'docker-logs.txt', allowEmptyArchive: true
}
cleanup {
cleanWs()
}
}
}
Best Practices and Performance Optimization
1. Image Optimization
Multi-stage builds:
# Build stage
FROM node:18 AS builder
WORKDIR /app
COPY package*.json ./
RUN npm ci
COPY . .
RUN npm run build
# Test stage
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"]
# Final minimal image
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. Resource Management
# Resource limits for predictable performance
services:
test-runner:
deploy:
resources:
limits:
cpus: '2'
memory: 2G
reservations:
cpus: '1'
memory: 1G
# Prevent OOM killer
oom_kill_disable: true
# Set memory swappiness
sysctls:
- vm.swappiness=10
3. Test Isolation and Cleanup
import pytest
from testcontainers.postgres import PostgresContainer
@pytest.fixture(scope="function") # New container per test
def isolated_database():
with PostgresContainer("postgres:15") as postgres:
# Each test gets a fresh database
yield postgres
# Automatic cleanup after test
def test_with_isolation_1(isolated_database):
# Test 1 with clean database
pass
def test_with_isolation_2(isolated_database):
# Test 2 with clean database (no interference)
pass
4. Debugging Containerized Tests
# Access running container
docker-compose exec tests /bin/sh
# View logs in real-time
docker-compose logs -f tests
# Inspect container filesystem
docker-compose exec tests ls -la /app
# Check environment variables
docker-compose exec tests env
# Debug networking
docker-compose exec tests ping database
docker-compose exec tests nslookup database
# Keep container running after test failure
docker-compose run --rm tests /bin/sh
5. Performance Monitoring
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"CPU Usage: {cpu_percent}%")
print(f"Memory Usage: {memory_usage:.2f} MB")
@pytest.fixture
def monitored_container():
container = DockerContainer("myapp:latest")
container.start()
yield container
# Print resource usage after test
monitor_container_resources(container)
container.stop()
Comparison: Testing Approaches
Approach | Use Case | Pros | Cons |
---|---|---|---|
Docker Compose | Local development, simple stacks | Easy setup, good for small teams | Limited scaling, single host |
Testcontainers | Unit/Integration tests | Language-native, programmatic control | Per-test overhead, requires Docker |
Kubernetes | Large-scale testing, production-like | Highly scalable, production parity | Complex setup, steep learning curve |
CI/CD Services | Simple tests, quick feedback | No container management, fast | Limited customization, vendor lock-in |
Conclusion
Containerization has transformed software testing by providing isolated, reproducible, and scalable test environments. Docker serves as the foundation for creating consistent test infrastructure, while Docker Compose simplifies multi-service orchestration for integration testing.
Testcontainers brings programmatic control to your test code, allowing seamless integration with existing test frameworks across multiple programming languages. For organizations requiring massive scale, Kubernetes provides sophisticated orchestration capabilities with horizontal scaling and resource management.
The key to successful containerized testing lies in:
- Choosing the right tool for your scale and complexity
- Optimizing images for fast build and startup times
- Implementing proper health checks and wait strategies
- Managing resources effectively to prevent interference
- Integrating seamlessly with CI/CD (as discussed in Cloud Testing Platforms: Complete Guide to BrowserStack, Sauce Labs, AWS Device Farm & More) pipelines
- Maintaining test isolation for reliable results
By mastering these containerization technologies, you’ll build a robust, scalable testing infrastructure that grows with your application’s needs while maintaining consistency and reliability across all environments.