Контейнеризация революционизировала тестирование программного обеспечения, предоставляя согласованные, изолированные и воспроизводимые тестовые окружения. Это всестороннее руководство исследует, как использовать Docker (как обсуждается в Continuous Testing in DevOps: Quality Gates and CI/CD Integration), Kubernetes и Testcontainers для построения надежной тестовой инфраструктуры, которая масштабируется в соответствии с вашими потребностями.
Зачем контейнеризация для тестирования?
Прежде чем погружаться в детали реализации, давайте разберем ключевые преимущества:
- Согласованность окружения: “Работает на моей машине” уходит в прошлое
- Изоляция тестов: Каждый тест выполняется в чистом, изолированном окружении
- Эффективность ресурсов: Контейнеры легковесны по сравнению с виртуальными машинами
- Быстрое развертывание: Поднимайте полные тестовые окружения за секунды
- Параллельное выполнение: Запускайте тесты одновременно без взаимных помех
- Контроль версий: Тестовые окружения определены как код в вашем репозитории
Docker для тестовых окружений
Docker является основой современной контейнеризации. Понимание того, как создавать эффективные Docker-образы для тестирования, крайне важно.
Создание образов тестового окружения
Хорошо спроектированный Dockerfile для тестирования балансирует размер образа, скорость сборки и функциональность.
Базовый Dockerfile для запуска тестов:
# Многоэтапная сборка для оптимального размера образа
FROM node:18-alpine AS builder
# Установить зависимости для сборки
WORKDIR /app
COPY package*.json ./
RUN npm ci --only=production
# Этап выполнения
FROM node:18-alpine
# Установить инструменты тестирования
RUN apk add --no-cache \
curl \
wget \
chromium \
chromium-chromedriver
# Настроить непривилегированного пользователя для безопасности
RUN addgroup -g 1001 tester && \
adduser -D -u 1001 -G tester tester
WORKDIR /app
COPY --from=builder /app/node_modules ./node_modules
COPY . .
# Изменить владельца
RUN chown -R tester:tester /app
USER tester
# Переменные окружения для тестирования
ENV NODE_ENV=test
ENV CHROME_BIN=/usr/bin/chromium-browser
ENV CHROME_PATH=/usr/lib/chromium/
# Проверка здоровья
HEALTHCHECK --interval=30s --timeout=10s --start-period=5s --retries=3 \
CMD node healthcheck.js || exit 1
CMD ["npm", "test"]
Тестовое окружение Python:
FROM python:3.11-slim
# Установить системные зависимости
RUN apt-get update && apt-get install -y \
gcc \
postgresql-client \
curl \
&& rm -rf /var/lib/apt/lists/*
# Настроить рабочую директорию
WORKDIR /tests
# Установить зависимости Python
COPY requirements.txt requirements-test.txt ./
RUN pip install --no-cache-dir -r requirements.txt -r requirements-test.txt
# Скопировать код тестов
COPY tests/ ./tests/
COPY conftest.py pytest.ini ./
# Создать директорию для результатов
RUN mkdir -p /tests/results && \
chmod 777 /tests/results
# Запустить тесты с pytest
CMD (как обсуждается в [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 для оркестрации сервисов
Docker Compose превосходно справляется с управлением многоконтейнерными тестовыми окружениями со сложными зависимостями.
Полная настройка интеграционного тестирования:
version: '3.8'
services:
# Тестируемое приложение
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
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
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
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
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
# Запускатель интеграционных тестов
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:
Продвинутые паттерны Docker Compose
Стратегии ожидания для зависимостей:
# Использование healthchecks с depends_on
services:
app:
depends_on:
database:
condition: service_healthy
cache:
condition: service_started
# Приложение запустится только после того, как база данных будет здорова
database:
healthcheck:
test: ["CMD", "mysqladmin", "ping", "-h", "localhost"]
timeout: 20s
retries: 10
Пользовательский скрипт wait-for-it:
#!/bin/bash
# wait-for-it.sh - Ожидание доступности сервиса
set -e
host="$1"
shift
port="$1"
shift
cmd="$@"
until nc -z "$host" "$port"; do
>&2 echo "Сервис $host:$port недоступен - засыпаем"
sleep 1
done
>&2 echo "Сервис $host:$port доступен - выполняем команду"
exec $cmd
Docker-сети для тестов
Понимание Docker-сетей критично для сложных тестовых сценариев.
Пользовательская конфигурация сети:
networks:
frontend:
driver: bridge
ipam:
config:
- subnet: 172.20.0.0/16
backend:
driver: bridge
internal: true # Без внешнего доступа
services:
web:
networks:
- frontend
- backend
database:
networks:
- backend # Доступна только из backend-сети
Лимиты ресурсов и производительность
Правильное распределение ресурсов предотвращает взаимные помехи тестов и обеспечивает стабильность.
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 для масштабирования выполнения тестов
Kubernetes выводит контейнеризованное тестирование на следующий уровень, обеспечивая массивную масштабируемость и сложную оркестрацию.
Базовый 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
Job для интеграции с CI/CD
apiVersion: batch/v1
kind: Job
metadata:
name: integration-tests
namespace: testing
spec:
# Запустить тесты с 5 параллельными подами
parallelism: 5
completions: 5
backoffLimit: 2
ttlSecondsAfterFinished: 3600 # Очистить через 1 час
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 "Запуск выполнения тестов в поде ${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 для постоянного тестового окружения
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 и Secrets для конфигурации тестов
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: Быстрые дымовые тесты
integration: Интеграционные тесты
slow: Медленные тесты
---
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"
Горизонтальное автомасштабирование подов для тестовых нагрузок
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
Постоянный том для артефактов тестов
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: тестирование с реальными зависимостями
Testcontainers привносит мощь Docker в ваш тестовый код, позволяя программно управлять жизненным циклом контейнеров.
Testcontainers для Java
Базовое тестирование базы данных:
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());
}
}
Продвинутая настройка нескольких контейнеров:
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));
// Выполнить HTTP-запросы к приложению
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 с 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);
// Запустить интеграционные тесты на полном окружении
Response response = RestAssured.get(baseUrl + "/api/status");
assertEquals(200, response.getStatusCode());
}
}
Testcontainers для Python
Интеграционное тестирование 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):
# Получить параметры подключения
connection_url = postgres_container.get_connection_url()
# Подключиться к базе данных
conn = psycopg2.connect(connection_url)
cursor = conn.cursor()
# Создать таблицу
cursor.execute("""
CREATE TABLE users (
id SERIAL PRIMARY KEY,
email VARCHAR(255) UNIQUE NOT NULL,
name VARCHAR(255) NOT NULL
)
""")
# Вставить данные
cursor.execute(
"INSERT INTO users (email, name) VALUES (%s, %s)",
("test@example.com", "Тестовый пользователь")
)
conn.commit()
# Запросить данные
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] == "Тестовый пользователь"
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)
# Создать engine
engine = create_engine(postgres_container.get_connection_url())
Base.metadata.create_all(engine)
# Создать сессию
Session = sessionmaker(bind=engine)
session = Session()
# Добавить пользователя
user = User(email="sqlalchemy@example.com", name="Пользователь SQLAlchemy")
session.add(user)
session.commit()
# Запросить пользователя
found = session.query(User).filter_by(email="sqlalchemy@example.com").first()
assert found is not None
assert found.name == "Пользователь SQLAlchemy"
Тестирование 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):
# Получить детали подключения
host = redis_container.get_container_host_ip()
port = redis_container.get_exposed_port(6379)
# Подключиться к Redis
client = redis.Redis(host=host, port=port, decode_responses=True)
# Установить и получить значение
client.set("test_key", "test_value")
value = client.get("test_key")
assert value == "test_value"
# Проверить истечение срока
client.setex("temp_key", 1, "временное")
assert client.get("temp_key") == "временное"
import time
time.sleep(2)
assert client.get("temp_key") is None
Пользовательская конфигурация контейнера:
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_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 для Node.js
Интеграционное тестирование MongoDB:
const { MongoDBContainer } = require('@testcontainers/mongodb');
const { MongoClient } = require('mongodb');
describe('Интеграционные тесты MongoDB', () => {
let container;
let client;
let db;
beforeAll(async () => {
// Запустить контейнер MongoDB
container = await new MongoDBContainer('mongo:7')
.withExposedPorts(27017)
.start();
// Подключиться к MongoDB
const connectionString = container.getConnectionString();
client = await MongoClient.connect(connectionString);
db = client.db('testdb');
}, 60000);
afterAll(async () => {
await client?.close();
await container?.stop();
});
test('должен вставить и найти документы', async () => {
const collection = db.collection('users');
// Вставить документ
const result = await collection.insertOne({
email: 'test@example.com',
name: 'Тестовый пользователь',
createdAt: new Date()
});
expect(result.acknowledged).toBe(true);
// Найти документ
const user = await collection.findOne({ email: 'test@example.com' });
expect(user).toBeDefined();
expect(user.name).toBe('Тестовый пользователь');
});
test('должен обновить документы', async () => {
const collection = db.collection('users');
await collection.updateOne(
{ email: 'test@example.com' },
{ $set: { name: 'Обновленный пользователь' } }
);
const user = await collection.findOne({ email: 'test@example.com' });
expect(user.name).toBe('Обновленный пользователь');
});
});
PostgreSQL с пользовательской конфигурацией:
const { PostgreSqlContainer } = require('@testcontainers/postgresql');
const { Client } = require('pg');
describe('Продвинутые тесты 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('должен выполнить сложные запросы', 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)
})
]));
});
});
Тестирование многоконтейнерного приложения:
const { GenericContainer, Network } = require('testcontainers');
const axios = require('axios');
describe('Интеграция микросервисов', () => {
let network;
let dbContainer;
let redisContainer;
let appContainer;
let baseUrl;
beforeAll(async () => {
// Создать общую сеть
network = await new Network().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();
// Запустить Redis
redisContainer = await new GenericContainer('redis:7-alpine')
.withNetwork(network)
.withNetworkAliases('redis')
.withExposedPorts(6379)
.start();
// Запустить приложение
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('должен создать и получить пользователя', async () => {
// Создать пользователя
const createResponse = await axios.post(`${baseUrl}/api/users`, {
email: 'integration@example.com',
name: 'Интеграционный тестовый пользователь'
});
expect(createResponse.status).toBe(201);
const userId = createResponse.data.id;
// Получить пользователя
const getResponse = await axios.get(`${baseUrl}/api/users/${userId}`);
expect(getResponse.status).toBe(200);
expect(getResponse.data.email).toBe('integration@example.com');
});
test('должен кэшировать часто используемые данные', async () => {
const userId = 1;
// Первый запрос (промах кэша)
const start1 = Date.now();
await axios.get(`${baseUrl}/api/users/${userId}`);
const duration1 = Date.now() - start1;
// Второй запрос (попадание в кэш)
const start2 = Date.now();
await axios.get(`${baseUrl}/api/users/${userId}`);
const duration2 = Date.now() - start2;
// Второй запрос должен быть быстрее (закэширован)
expect(duration2).toBeLessThan(duration1);
});
});
Docker Compose для интеграционных тестов
Продвинутые паттерны Docker Compose, специально разработанные для интеграционного тестирования.
Полный стек для тестирования
version: '3.8'
x-common-variables: &common-variables
NODE_ENV: test
LOG_LEVEL: debug
services:
# Тестовая база данных с инициализацией
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 для кэширования и сессий
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
# Очередь сообщений
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 для поиска
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
# Сервис аутентификации
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:
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-тестов
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 'Ожидание готовности сервисов...' &&
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
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
# Мониторинг производительности
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
# Визуализация метрик
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:
Стратегии ожидания и проверки здоровья
Пользовательский скрипт проверки здоровья:
#!/bin/bash
# healthcheck.sh - Комплексная проверка здоровья сервисов
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 "Проверка PostgreSQL..."
until check_postgres; do
echo "PostgreSQL не готов - засыпаем"
sleep 2
done
echo "Проверка Redis..."
until check_redis; do
echo "Redis не готов - засыпаем"
sleep 2
done
echo "Проверка Elasticsearch..."
until check_elasticsearch; do
echo "Elasticsearch не готов - засыпаем"
sleep 2
done
echo "Проверка API Gateway..."
until check_api; do
echo "API не готов - засыпаем"
sleep 2
done
echo "Все сервисы здоровы!"
Интеграция с CI/CD
GitHub Actions
name: Интеграционные тесты
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: Настроить Docker Buildx
uses: docker/setup-buildx-action@v2
- name: Собрать тестовый образ
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: |
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: Опубликовать результаты тестов
uses: EnricoMi/publish-unit-test-result-action@v2
if: always()
with:
files: test-results/junit.xml
- name: Загрузить покрытие
uses: codecov/codecov-action@v3
with:
files: ./test-results/coverage.xml
docker-compose-tests:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
- name: Запустить тесты Docker Compose
run: |
docker-compose -f docker-compose.test.yml up \
--abort-on-container-exit \
--exit-code-from tests
- name: Собрать логи
if: failure()
run: |
docker-compose -f docker-compose.test.yml logs > docker-logs.txt
- name: Загрузить логи
if: failure()
uses: actions/upload-artifact@v3
with:
name: docker-logs
path: docker-logs.txt
- name: Очистка
if: always()
run: |
docker-compose -f docker-compose.test.yml down -v
kubernetes-tests:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
- name: Настроить Minikube
uses: medyagh/setup-minikube@latest
- name: Развернуть тестовое окружение
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: |
kubectl apply -f k8s/test-job.yaml
kubectl wait --for=condition=complete job/integration-tests -n testing --timeout=600s
- name: Собрать результаты
if: always()
run: |
kubectl logs job/integration-tests -n testing > test-output.log
- name: Очистка
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:
# Ждать сервисы
- apk add --no-cache postgresql-client
- until pg_isready -h postgres -U $POSTGRES_USER; do sleep 2; done
# Запустить тесты
- |
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:
# Установить 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/
# Развернуть в тестовом кластере
- 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
# Собрать результаты
- 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('Собрать тестовый образ') {
steps {
script {
docker.build("${DOCKER_REGISTRY}/${IMAGE_NAME}:${IMAGE_TAG}", "-f Dockerfile.test .")
}
}
}
stage('Интеграционные тесты') {
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 ->
// Подождать сервисы
sh 'sleep 10'
// Запустить тесты
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') {
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('Опубликовать результаты') {
steps {
junit 'test-results/junit.xml'
publishHTML([
reportDir: 'test-results',
reportFiles: 'report.html',
reportName: 'Отчет о тестировании'
])
}
}
}
post {
failure {
archiveArtifacts artifacts: 'docker-logs.txt', allowEmptyArchive: true
}
cleanup {
cleanWs()
}
}
}
Лучшие практики и оптимизация производительности
1. Оптимизация образов
Многоэтапная сборка:
# Этап сборки
FROM node:18 AS builder
WORKDIR /app
COPY package*.json ./
RUN npm ci
COPY . .
RUN npm run build
# Этап тестирования
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"]
# Финальный минимальный образ
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. Управление ресурсами
# Лимиты ресурсов для предсказуемой производительности
services:
test-runner:
deploy:
resources:
limits:
cpus: '2'
memory: 2G
reservations:
cpus: '1'
memory: 1G
# Предотвратить OOM killer
oom_kill_disable: true
# Установить swappiness памяти
sysctls:
- vm.swappiness=10
3. Изоляция и очистка тестов
import pytest
from testcontainers.postgres import PostgresContainer
@pytest.fixture(scope="function") # Новый контейнер на тест
def isolated_database():
with PostgresContainer("postgres:15") as postgres:
# Каждый тест получает чистую базу данных
yield postgres
# Автоматическая очистка после теста
def test_with_isolation_1(isolated_database):
# Тест 1 с чистой базой данных
pass
def test_with_isolation_2(isolated_database):
# Тест 2 с чистой базой данных (без помех)
pass
4. Отладка контейнеризованных тестов
# Получить доступ к запущенному контейнеру
docker-compose exec tests /bin/sh
# Просмотр логов в реальном времени
docker-compose logs -f tests
# Проверить файловую систему контейнера
docker-compose exec tests ls -la /app
# Проверить переменные окружения
docker-compose exec tests env
# Отладка сетей
docker-compose exec tests ping database
docker-compose exec tests nslookup database
# Сохранить контейнер работающим после сбоя теста
docker-compose run --rm tests /bin/sh
5. Мониторинг производительности
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: {cpu_percent}%")
print(f"Использование памяти: {memory_usage:.2f} MB")
@pytest.fixture
def monitored_container():
container = DockerContainer("myapp:latest")
container.start()
yield container
# Вывести использование ресурсов после теста
monitor_container_resources(container)
container.stop()
Сравнение: подходы к тестированию
Подход | Сценарий использования | Преимущества | Недостатки |
---|---|---|---|
Docker Compose | Локальная разработка, простые стеки | Простая настройка, хорош для малых команд | Ограниченное масштабирование, один хост |
Testcontainers | Юнит/интеграционные тесты | Родной для языка, программный контроль | Накладные расходы на тест, требует Docker |
Kubernetes | Масштабное тестирование, похоже на продакшн | Высокая масштабируемость, паритет с продакшн | Сложная настройка, крутая кривая обучения |
CI/CD сервисы | Простые тесты, быстрая обратная связь | Без управления контейнерами, быстро | Ограниченная кастомизация, vendor lock-in |
Заключение
Контейнеризация преобразила тестирование программного обеспечения, предоставляя изолированные, воспроизводимые и масштабируемые тестовые окружения. Docker служит основой для создания согласованной тестовой инфраструктуры, в то время как Docker Compose упрощает оркестрацию нескольких сервисов для интеграционного тестирования.
Testcontainers привносит программный контроль в ваш тестовый код, обеспечивая бесшовную интеграцию с существующими тестовыми фреймворками в нескольких языках программирования. Для организаций, требующих массивной масштабируемости, Kubernetes предоставляет сложные возможности оркестрации с горизонтальным масштабированием и управлением ресурсами.
Ключ к успешному контейнеризованному тестированию заключается в:
- Выборе правильного инструмента для вашего масштаба и сложности
- Оптимизации образов для быстрой сборки и времени запуска
- Реализации правильных проверок здоровья и стратегий ожидания
- Эффективном управлении ресурсами для предотвращения помех
- Бесшовной интеграции с CI/CD (как обсуждается в Cloud Testing Platforms: Complete Guide to BrowserStack, Sauce Labs, AWS Device Farm & More) конвейерами
- Поддержании изоляции тестов для надежных результатов
Освоив эти технологии контейнеризации, вы построите надежную, масштабируемую тестовую инфраструктуру, которая растет вместе с потребностями вашего приложения, сохраняя при этом согласованность и надежность во всех окружениях.