Compose в CI: интеграционные тесты
В уроке 17-01 мы запускали отдельные testcontainer’ы из pytest. Это идеально для unit/integration тестов одного компонента (тестим ETL-функцию, ей нужна Postgres). Но иногда нужно протестировать связку сервисов — Airflow дёргает Spark, который пишет в ClickHouse, который подписан на Kafka. Это end-to-end тест стека, и для него удобнее compose-файл.
В этом уроке: как запустить compose в CI, дождаться готовности всех сервисов через --wait, прогнать тесты, и корректно teardown.
docker compose up —wait
Главная фича Compose v2 для CI: --wait. Она блокирует команду, пока все сервисы не станут healthy (или один не упадёт).
docker compose up -d --wait
Без --wait:
docker compose up -d
# Команда вернулась мгновенно. Контейнеры стартуют, но Postgres может ещё не быть готов.
# Тесты запускаются и падают с "connection refused".
С --wait:
docker compose up -d --wait
# Команда блокируется до:
# - все healthchecks вернули healthy, ИЛИ
# - один из сервисов завершился с не-нулевым exit code.
# Только после этого можно запускать тесты.
Это обязательно для CI. Без --wait тесты гоняют race condition: иногда зелёные, иногда красные — зависит от того, насколько быстро стартовал Postgres на конкретном runner’е.
Healthcheck’и — это критично
--wait работает только если у сервисов есть healthcheck. Без healthcheck Compose считает сервис готовым, как только процесс стартовал. Это никогда не значит, что сервис реально принимает запросы.
# compose.test.yml
services:
postgres:
image: postgres:16
environment:
POSTGRES_USER: test
POSTGRES_PASSWORD: test
POSTGRES_DB: test
healthcheck:
test: ["CMD", "pg_isready", "-U", "test"]
interval: 5s
timeout: 3s
retries: 10
start_period: 5s
kafka:
image: bitnami/kafka:3.8
# ... настройки KRaft
healthcheck:
test: ["CMD-SHELL", "kafka-topics.sh --bootstrap-server localhost:9092 --list || exit 1"]
interval: 10s
retries: 10
start_period: 20s
api:
build: .
depends_on:
postgres:
condition: service_healthy
kafka:
condition: service_healthy
healthcheck:
test: ["CMD", "curl", "--fail", "http://localhost:8000/health"]
interval: 5s
retries: 5
start_period: 10s
Ключевые параметры:
interval— как часто проверять. Слишком часто = шум, слишком редко = долго ждать. 5-10 секунд для большинства.timeout— таймаут одной проверки. Меньше interval, обычно 3-5 секунд.retries— сколько раз подряд должна упасть проверка, прежде чем сервис считается unhealthy.start_period— grace period после старта. Healthcheck в это время не считается failure. Полезно для тяжёлых сервисов (Kafka стартует 20-30 секунд).
ВНИМАНИЕ: depends_on с condition: service_healthy работает только в Compose v2. Если CI использует docker-compose (legacy v1, через pip install), эти условия игнорируются. Всегда используй docker compose (без дефиса) в современном CI.
GitHub Actions workflow
# .github/workflows/integration.yml
name: integration-tests
on: [push, pull_request]
jobs:
integration:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Start stack
run: docker compose -f compose.test.yml up -d --wait
timeout-minutes: 5 # если за 5 минут не поднялось -- fail
- name: Show running services
run: docker compose -f compose.test.yml ps
- name: Run smoke tests
run: |
curl --fail http://localhost:8000/health
curl --fail http://localhost:8000/api/users
- name: Run pytest
run: |
pip install -r requirements-test.txt
pytest tests/integration/ -v
- name: Capture logs on failure
if: failure()
run: docker compose -f compose.test.yml logs > compose-logs.txt
- uses: actions/upload-artifact@v4
if: failure()
with:
name: compose-logs
path: compose-logs.txt
- name: Teardown
if: always()
run: docker compose -f compose.test.yml down -v
Несколько важных деталей:
timeout-minutes: 5— если--waitне вернулся за 5 минут, GHA убивает step. Иначе застрянет на час.Capture logs on failure— при падении тестов экспортируем логи всех контейнеров в файл. Без этого ты не поймёшь, что упало.upload-artifact— логи доступны в Actions UI для скачивания и анализа.if: always()на teardown — выполняется и при успехе, и при failure. Безalways()runner может застрять с running контейнерами.
Что проверять: примеры
Smoke tests через curl
# Health endpoint
curl --fail http://localhost:8000/health
# Конкретный API endpoint
RESPONSE=$(curl -s http://localhost:8000/api/events?limit=10)
echo "$RESPONSE" | jq -e '.events | length > 0'
# Insert + read цикл
curl -X POST http://localhost:8000/api/events \
-H "Content-Type: application/json" \
-d '{"user_id": 1, "type": "test"}' --fail
sleep 2 # дать pipeline время обработать
curl -s http://localhost:8000/api/events | jq -e '.events[-1].type == "test"'
--fail важен: без него curl возвращает 0 при HTTP 500, и CI считает успех. С --fail curl exit’ит с не-нулевым кодом при >=400.
docker compose exec для проверки БД
# Прочитать состояние Postgres
docker compose exec -T postgres psql -U test -d test -c "SELECT count(*) FROM events"
# Прочитать topic в Kafka
docker compose exec -T kafka kafka-topics.sh \
--bootstrap-server localhost:9092 --list
# Прочитать ClickHouse
docker compose exec -T clickhouse clickhouse-client \
--query "SELECT count() FROM analytics.events"
-T отключает TTY — обязательно в CI (нет терминала).
pytest с runtime-сервисами
Если тесты пишутся на Python, лучше использовать те же compose-сервисы:
# tests/integration/test_pipeline.py
import os
import requests
import psycopg2
API_URL = os.environ.get("API_URL", "http://localhost:8000")
DB_URL = os.environ.get("DB_URL", "postgresql://test:test@localhost:5432/test")
def test_end_to_end():
# POST event
r = requests.post(f"{API_URL}/api/events", json={"user_id": 1, "type": "test"})
assert r.status_code == 201
# Дать pipeline обработать
import time
time.sleep(2)
# Проверить в БД
conn = psycopg2.connect(DB_URL)
with conn.cursor() as cur:
cur.execute("SELECT count(*) FROM events WHERE type = 'test'")
assert cur.fetchone()[0] >= 1
conn.close()
Типичные race conditions
Жизненный цикл процесса: от fork до readyRace 1: healthcheck тривиальный
postgres:
healthcheck:
test: ["CMD", "echo", "ok"] # БЕСПОЛЕЗНО
echo ok всегда возвращает 0. Healthcheck “проходит” мгновенно, но Postgres ещё не готов. Используй pg_isready -U user — он реально проверяет, что postmaster принимает connections.
Race 2: depends_on без condition
api:
depends_on:
- postgres # БЕСПОЛЕЗНО без condition
depends_on: [postgres] (короткая форма) гарантирует только порядок старта, не готовность. API контейнер стартует, как только postgres-контейнер стартовал (даже за 0.1 секунды). Postgres ещё пишет конфиги — API уже пытается подключиться.
api:
depends_on:
postgres:
condition: service_healthy # ДА
Race 3: тесты в том же compose
Некоторые любят запускать тесты внутри compose:
test-runner:
build: .
command: pytest
depends_on:
api:
condition: service_healthy
Это работает, но усложняет CI: нужно docker compose up --abort-on-container-exit --exit-code-from test-runner. Проще запускать тесты на хосте и общаться с compose-сервисами через localhost:port.
Race 4: streaming-задержки
def test_kafka_to_postgres():
requests.post(API_URL + "/events", json={...})
# БЕСПОЛЕЗНО:
conn = psycopg2.connect(DB_URL)
cur.execute("SELECT count() FROM events")
assert cur.fetchone()[0] == 1 # 50% времени fail
Между producer и consumer есть delay (обычно сотни ms — секунды). Используй retry-логику:
import time
def wait_for(condition, timeout=10, interval=0.5):
deadline = time.time() + timeout
while time.time() < deadline:
if condition():
return True
time.sleep(interval)
raise TimeoutError("Condition not met")
wait_for(lambda: db_count() >= 1, timeout=10)
Пример полного workflow
name: integration
on:
push:
branches: [main, develop]
pull_request:
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: docker/setup-buildx-action@v3
- name: Build test image
run: docker compose -f compose.test.yml build
integration:
needs: build
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with:
python-version: '3.11'
- name: Start compose stack
run: docker compose -f compose.test.yml up -d --wait
timeout-minutes: 5
- name: Print stack status
run: |
docker compose -f compose.test.yml ps
docker compose -f compose.test.yml exec -T postgres pg_isready
- name: Install test deps
run: pip install pytest requests psycopg2-binary confluent-kafka
- name: Run integration tests
env:
API_URL: http://localhost:8000
DB_URL: postgresql://test:test@localhost:5432/test
KAFKA_BOOTSTRAP: localhost:29092
run: pytest tests/integration/ -v --tb=short
- name: Dump logs on failure
if: failure()
run: |
mkdir -p logs
docker compose -f compose.test.yml logs --no-color > logs/all.log
for svc in $(docker compose -f compose.test.yml ps --services); do
docker compose -f compose.test.yml logs --no-color $svc > logs/$svc.log
done
- uses: actions/upload-artifact@v4
if: failure()
with:
name: compose-logs
path: logs/
- name: Teardown
if: always()
run: docker compose -f compose.test.yml down -v --remove-orphans
Этот workflow:
- Собирает образы (отдельный job — параллельно с другими CI-проверками).
- Поднимает стек, ждёт
--wait. - Гоняет pytest с переменными окружения.
- Падает дроссельно — логи всегда сохраняются.
- Teardown гарантирован через
if: always().
Попробуй сам
# 1. Создай compose.test.yml:
cat > compose.test.yml <<'EOF'
services:
postgres:
image: postgres:16
environment:
POSTGRES_USER: test
POSTGRES_PASSWORD: test
POSTGRES_DB: test
ports:
- "5432:5432"
healthcheck:
test: ["CMD", "pg_isready", "-U", "test"]
interval: 3s
retries: 10
EOF
# 2. Запусти с --wait и измерь время
time docker compose -f compose.test.yml up -d --wait
# real 0m6.234s -- Postgres готов за ~6 секунд
# 3. Проверь
docker compose -f compose.test.yml exec -T postgres \
psql -U test -d test -c "SELECT 1"
# 4. Запусти "тест"
psql postgresql://test:test@localhost:5432/test -c "SELECT version()"
# 5. Симулируй CI failure: останови postgres
docker compose -f compose.test.yml stop postgres
psql postgresql://test:test@localhost:5432/test -c "SELECT 1"
# psql: connection refused -- здесь упадёт твой тест
# 6. Logs
docker compose -f compose.test.yml logs postgres | tail -20
# 7. Teardown
docker compose -f compose.test.yml down -v