Контейнеризация для тестирования: Полное руководство по Docker, Kubernetes и Testcontainers — критически важная дисциплина в современном обеспечении качества программного обеспечения. According to the 2024 DORA report, organizations with high DevOps maturity have 4x lower change failure rates (DORA State of DevOps 2024). According to Puppet’s State of DevOps report, high-performing DevOps teams spend 44% less time on unplanned work (Puppet State of DevOps). Это руководство охватывает практические подходы, которые QA-команды могут применить немедленно: от базовых концепций и инструментов до реальных паттернов реализации. Независимо от того, развиваешь ли ты навыки в этой области или улучшаешь существующий процесс, здесь ты найдёшь действенные техники, подкреплённые практическим опытом. Цель — не просто теоретическое понимание, а рабочий фреймворк, который можно адаптировать под контекст команды, технологический стек и цели по качеству.
TL;DR
- Тестирование инфраструктуры выявляет дрейф конфигурации до производственных инцидентов
- Тестируй IaC-шаблоны со статическим анализом, затем проверяй развёрнутые ресурсы интеграционными тестами
- Относись к коду инфраструктуры с теми же стандартами качества, что и к коду приложения
Подходит для: Команды, использующие IaC (Terraform, Ansible, CloudFormation) Пропустите если: Команды без управления инфраструктурой или использующие полностью управляемый PaaS
Зачем контейнеризация для тестирования?
Прежде чем погружаться в детали реализации, давайте разберем ключевые преимущества:
- Согласованность окружения: “Работает на моей машине” уходит в прошлое
- Изоляция тестов: Каждый тест выполняется в чистом, изолированном окружении
- Эффективность ресурсов: Контейнеры легковесны по сравнению с виртуальными машинами
- Быстрое развертывание: Поднимайте полные тестовые окружения за секунды
- Параллельное выполнение: Запускайте тесты одновременно без взаимных помех
- Контроль версий: Тестовые окружения определены как код в вашем репозитории
«Тестирование инфраструктуры — это всё равно тестирование. Если ты автоматизировал деплои, но не валидацию инфраструктуры, ты просто автоматизировал путь к сбоям в продакшне.» — Юрий Кан, Senior QA Lead
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](/ru/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) конвейерами
- Поддержании изоляции тестов для надежных результатов
Освоив эти технологии контейнеризации, вы построите надежную, масштабируемую тестовую инфраструктуру, которая растет вместе с потребностями вашего приложения, сохраняя при этом согласованность и надежность во всех окружениях.
Смотрите также
- Selenium Grid 4: Distributed Browser Testing at Scale - Масштабируйте браузерное тестирование с Docker и Kubernetes
- Cloud Testing Platforms: BrowserStack, Sauce Labs и другие - Дополните контейнеры реальными устройствами в облаке
- ReportPortal: AI-Powered Test Aggregation - Централизуйте отчеты из множества тестовых контейнеров
- Allure TestOps: Enterprise Test Management - Корпоративное управление контейнеризованными результатами
- Сравнение систем управления тестами - Выберите подходящую TMS для вашей инфраструктуры
Официальные ресурсы
FAQ
Что такое тестирование инфраструктуры? Тестирование инфраструктуры проверяет, что серверы, сети и облачные ресурсы настроены правильно и ведут себя ожидаемо — та же строгость, что и к коду приложения.
Как тестировать Ansible playbooks? Используй Molecule для unit и интеграционного тестирования ролей Ansible, тестируй в контейнерах или VM, проверяй с InSpec или Serverspec и включай тесты идемпотентности.
Что такое chaos engineering? Chaos engineering намеренно вводит сбои в production-подобные окружения, чтобы тестировать устойчивость системы, выявлять слабые места и строить уверенность в процедурах восстановления.
Как тестировать аварийное восстановление? Регулярно проводи DR-учения, реально переключаясь на резервные системы, измеряя RTO (цель времени восстановления) и RPO (цель точки восстановления) относительно определённых целей.
