15.3. Integration tests через Compose
Цели
После этого материала вы сможете:
- получить код возврата тестов из Compose — и объяснить, почему обычный
upего теряет; - заменить
sleepожиданием условия и измерить выигрыш; - объяснить, почему проверка готовности может пройти до того, как база готова;
- выбрать стратегию изоляции тестов и обосновать выбор её ценой;
- ускорить тестовую базу переводом данных в память и отключением сохранности;
- гарантировать удаление окружения при любом исходе, включая прерывание.
Предварительные знания
Ключевые термины
| Термин | Объяснение |
|---|---|
--exit-code-from | Флаг, отдающий код возврата указанного сервиса |
service_healthy | Условие запуска: зависимость прошла проверку |
tmpfs | Файловая система в оперативной памяти |
fsync | Принудительная запись на диск; главный источник задержки БД |
trap | Обработчик сигнала в оболочке |
-p | Имя проекта Compose: изолирует наборы объектов |
Теория
Код возврата: главная деталь
docker compose -f compose.test.yaml up
echo $? # 0 — даже если тесты упали
Обычный up возвращает код своей работы, а не работы сервисов. Тесты упали, Compose отработал успешно, CI считает шаг пройденным.
Два способа получить настоящий код:
docker compose -f compose.test.yaml up \
--abort-on-container-exit --exit-code-from tests
docker compose -f compose.test.yaml run --rm tests
| Способ | Что делает | Когда применять |
|---|---|---|
up --exit-code-from | Поднимает всё, ждёт завершения указанного сервиса, отдаёт его код | Тесты как сервис |
run --rm | Поднимает зависимости, выполняет разовую команду | Тесты как команда |
Различие практическое: run не запускает сам сервис приложения, если он не указан в depends_on; up запускает все сервисы файла.
Флаг --abort-on-container-exit обязателен при up: без него Compose будет ждать завершения всех сервисов, а база не завершится никогда.
Почему sleep — источник нестабильности
command: sh -c "sleep 10 && pytest"
Два независимых недостатка (урок 15.1):
| Случай | Что происходит |
|---|---|
| База поднялась за 2 с | 8 секунд теряются на каждом прогоне |
| CI-сборщик загружен, база за 15 с | Тест падает — но не всегда |
Правильное решение — условие:
services:
db:
image: postgres:17-alpine
healthcheck:
test: ["CMD-SHELL", "pg_isready -U $$POSTGRES_USER -d $$POSTGRES_DB"]
interval: 1s
timeout: 3s
retries: 30
start_period: 5s
tests:
depends_on:
db:
condition: service_healthy
Тесты стартуют, как только база готова, — не раньше и не позже.
Ловушка: проверка проходит, а база не готова
Классическая ошибка — проверка, не совпадающая с тем, что делает приложение:
# ПЛОХО: проверяет процесс, а не готовность принять запрос
test: ["CMD-SHELL", "pg_isready"]
Без -U и -d утилита проверяет, отвечает ли сервер вообще, — но не то, что ваша база создана и ваш пользователь может подключиться.
Вторая ловушка глубже. Официальный образ PostgreSQL при первом запуске выполняет инициализацию: создаёт кластер, запускает временный сервер, прогоняет скрипты из docker-entrypoint-initdb.d, останавливает временный сервер и запускает настоящий.
Если проверка успеет попасть в окно работы временного сервера, она пройдёт — а затем сервер будет остановлен, и приложение получит connection refused.
Современные версии образа запускают временный сервер только на unix-сокете, что закрывает этот путь для проверок по TCP. Но принцип остаётся:
Проверка готовности должна выполнять то же действие, что и приложение. Для базы это подключение к нужной базе нужным пользователем, а не факт наличия процесса.
Надёжный вариант — выполнить настоящий запрос:
test: ["CMD-SHELL", "psql -U $$POSTGRES_USER -d $$POSTGRES_DB -c 'SELECT 1' || exit 1"]
Изоляция тестов
Четыре стратегии, различающиеся ценой и ограничениями:
| Стратегия | Скорость | Параллельность | Ограничение |
|---|---|---|---|
| Откат транзакции | Самая высокая | Ограниченная | Ломается, если код делает commit или открывает своё соединение |
Очистка таблиц (TRUNCATE) | Высокая | Нет | Нужно знать все таблицы; сбрасывает последовательности |
| Схема на тест | Средняя | Да | Требует поддержки схем и настройки search_path |
| Пересоздание базы | Низкая | Да | Секунды на тест |
Откат транзакции — самый быстрый и самый хрупкий:
@pytest.fixture
def session(connection):
transaction = connection.begin()
yield make_session(connection)
transaction.rollback()
Он перестаёт работать, как только тестируемый код вызывает commit сам или создаёт собственное соединение — а это типично для кода, использующего пул соединений.
Схема на тест — единственный вариант, дающий настоящую параллельность:
CREATE SCHEMA test_a1b2c3;
SET search_path TO test_a1b2c3;
Каждый тест видит свой набор таблиц; конфликтов нет; можно запускать в несколько процессов.
Ускорение тестовой базы
База данных медленна из-за гарантий сохранности, которые тестам не нужны.
| Настройка | Что отключает | Выигрыш |
|---|---|---|
Данные в tmpfs | Диск целиком | Значительный |
fsync=off | Принудительный сброс на диск | Значительный |
synchronous_commit=off | Ожидание записи журнала | Заметный |
full_page_writes=off | Защита от частичной записи страницы | Заметный |
services:
db:
image: postgres:17-alpine
tmpfs:
- /var/lib/postgresql/data
command: >
postgres
-c fsync=off
-c synchronous_commit=off
-c full_page_writes=off
-c max_connections=50
Это допустимо только для тестов. Каждая из настроек означает: при аварийном завершении данные будут повреждены. Для тестовой базы, которая создаётся заново каждый прогон, это не имеет значения.
Учтите, что tmpfs расходует оперативную память и учитывается в лимите памяти container'а (урок 7.4).
Гарантированная очистка
docker compose -f compose.test.yaml down -v
Флаг -v обязателен: без него анонимные тома остаются и накапливаются (урок 13.4).
Но команда не выполнится, если прогон прервали или он упал раньше. Решение — trap:
#!/usr/bin/env bash
set -uo pipefail
PROJECT="test-$$"
cleanup() {
docker compose -p "$PROJECT" -f compose.test.yaml down -v --remove-orphans
}
trap cleanup EXIT INT TERM
docker compose -p "$PROJECT" -f compose.test.yaml up \
--abort-on-container-exit --exit-code-from tests
Три существенные детали:
trap ... EXIT срабатывает при любом выходе из скрипта: успех, ошибка, set -e, exit.
INT TERM добавляют обработку прерывания с клавиатуры и остановки извне.
Уникальное имя проекта (-p "test-$$") изолирует прогон: параллельные запуски не конфликтуют именами container'ов и сетей.
Внутренний механизм
Как --exit-code-from получает код
Compose следит за состоянием контейнеров. При завершении указанного сервиса он останавливает остальные (--abort-on-container-exit) и завершается с кодом того самого container'а, полученным из docker inspect.
Отсюда требование: сервис должен завершаться. Тесты завершаются естественно; база — нет, и указывать её нельзя.
Почему tmpfs даёт такой выигрыш
Обычная запись в базу проходит путь: буфер приложения → страничный кэш ядра → диск. Вызов fsync требует довести данные до диска и дождаться подтверждения.
При tmpfs файловая система живёт в памяти: fsync завершается сразу, потому что доводить некуда. Плюс отключение fsync в самой базе убирает даже эти системные вызовы.
Проверить, что данные действительно в памяти: docker exec db df -h /var/lib/postgresql/data покажет тип tmpfs.
Команды и примеры
Приложение и тесты
mkdir -p /tmp/citests && cd /tmp/citests
mkdir -p src tests
cat > requirements.txt <<'EOF'
psycopg[binary]==3.3.4
EOF
cat > requirements-dev.txt <<'EOF'
pytest==9.1.1
EOF
cat > src/__init__.py <<'PY'
PY
cat > src/repo.py <<'PY'
"""Хранилище заказов на PostgreSQL."""
from __future__ import annotations
import os
from decimal import Decimal
import psycopg
SCHEMA = """
CREATE TABLE IF NOT EXISTS orders (
id SERIAL PRIMARY KEY,
number TEXT NOT NULL UNIQUE,
sku TEXT NOT NULL,
amount NUMERIC(12, 2) NOT NULL CHECK (amount > 0),
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
"""
def dsn() -> str:
return os.environ["DATABASE_URL"]
def connect() -> psycopg.Connection:
return psycopg.connect(dsn())
def init_schema(conn: psycopg.Connection) -> None:
conn.execute(SCHEMA)
conn.commit()
def create_order(conn: psycopg.Connection, number: str, sku: str,
amount: Decimal) -> int:
row = conn.execute(
"INSERT INTO orders (number, sku, amount) VALUES (%s, %s, %s) "
"RETURNING id",
(number, sku, amount),
).fetchone()
return row[0]
def find_order(conn: psycopg.Connection, number: str) -> dict | None:
row = conn.execute(
"SELECT id, number, sku, amount, created_at FROM orders "
"WHERE number = %s",
(number,),
).fetchone()
if not row:
return None
return {"id": row[0], "number": row[1], "sku": row[2],
"amount": row[3], "created_at": row[4]}
def total_by_sku(conn: psycopg.Connection, sku: str) -> Decimal:
row = conn.execute(
"SELECT COALESCE(SUM(amount), 0) FROM orders WHERE sku = %s", (sku,)
).fetchone()
return row[0]
PY
cat > tests/conftest.py <<'PY'
"""Фикстуры: подключение к базе и изоляция тестов."""
from __future__ import annotations
import os
import uuid
import psycopg
import pytest
from src.repo import init_schema
@pytest.fixture(scope="session")
def connection():
"""Одно соединение на весь прогон."""
conn = psycopg.connect(os.environ["DATABASE_URL"])
try:
yield conn
finally:
conn.close()
@pytest.fixture
def db(connection):
"""Изоляция схемой: каждый тест видит собственный набор таблиц.
Схема на тест — единственная стратегия, дающая настоящую
параллельность: тесты не делят таблицы и не мешают друг другу.
"""
schema = f"t_{uuid.uuid4().hex[:12]}"
connection.execute(f'CREATE SCHEMA "{schema}"')
connection.execute(f'SET search_path TO "{schema}"')
connection.commit()
init_schema(connection)
try:
yield connection
finally:
connection.rollback()
connection.execute(f'DROP SCHEMA "{schema}" CASCADE')
connection.commit()
PY
cat > tests/test_orders.py <<'PY'
"""Integration-тесты против настоящего PostgreSQL."""
from decimal import Decimal
import psycopg
import pytest
from src.repo import create_order, find_order, total_by_sku
def test_create_and_find(db):
ident = create_order(db, "N-001", "AB-1234", Decimal("150.50"))
found = find_order(db, "N-001")
assert found["id"] == ident
assert found["amount"] == Decimal("150.50")
def test_unique_number_enforced(db):
"""UNIQUE проверяет БД — подделка этого не воспроизведёт."""
create_order(db, "N-002", "AB-1234", Decimal("10"))
with pytest.raises(psycopg.errors.UniqueViolation):
create_order(db, "N-002", "CD-5678", Decimal("20"))
db.rollback()
def test_check_constraint_enforced(db):
"""CHECK amount > 0 — тоже на стороне БД."""
with pytest.raises(psycopg.errors.CheckViolation):
create_order(db, "N-003", "AB-1234", Decimal("-5"))
db.rollback()
def test_numeric_precision_preserved(db):
"""NUMERIC(12,2) — тип драйвера и БД, не приложения."""
create_order(db, "N-004", "AB-1234", Decimal("0.01"))
assert find_order(db, "N-004")["amount"] == Decimal("0.01")
def test_aggregation_in_database(db):
"""SUM выполняет БД."""
for i, amount in enumerate(["10.00", "20.50", "5.25"], start=10):
create_order(db, f"N-{i}", "AB-1234", Decimal(amount))
create_order(db, "N-99", "CD-5678", Decimal("1000"))
assert total_by_sku(db, "AB-1234") == Decimal("35.75")
def test_default_timestamp_set(db):
"""DEFAULT now() вычисляет БД."""
create_order(db, "N-005", "AB-1234", Decimal("1"))
assert find_order(db, "N-005")["created_at"] is not None
def test_isolation_between_tests(db):
"""Данные предыдущих тестов не видны: своя схема."""
row = db.execute("SELECT count(*) FROM orders").fetchone()
assert row[0] == 0, "в новой схеме таблица должна быть пустой"
PY
cat > Dockerfile <<'EOF'
# syntax=docker/dockerfile:1
FROM python:3.13-slim AS base
ENV PYTHONUNBUFFERED=1 PYTHONDONTWRITEBYTECODE=1
WORKDIR /app
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
FROM base AS production
COPY src/ ./src/
CMD ["python", "-c", "print('приложение')"]
FROM production AS test
COPY requirements-dev.txt .
RUN pip install --no-cache-dir -r requirements-dev.txt
COPY tests/ ./tests/
CMD ["python", "-m", "pytest", "tests/", "-q"]
EOF
echo " файлы созданы"
Ожидаемый вывод:
файлы созданы
compose.test.yaml
cd /tmp/citests
cat > compose.test.yaml <<'EOF'
name: integration-tests
services:
db:
image: postgres:17-alpine
environment:
POSTGRES_USER: test
POSTGRES_PASSWORD: test
POSTGRES_DB: testdb
# Данные в памяти: тестовой базе сохранность не нужна
tmpfs:
- /var/lib/postgresql/data
# Отключаем гарантии записи — только для тестов
command: >
postgres
-c fsync=off
-c synchronous_commit=off
-c full_page_writes=off
-c max_connections=50
healthcheck:
# Проверка делает ТО ЖЕ, что приложение: подключается
# нужным пользователем к нужной базе и выполняет запрос
test: ["CMD-SHELL", "psql -U $$POSTGRES_USER -d $$POSTGRES_DB -c 'SELECT 1' > /dev/null || exit 1"]
interval: 1s
timeout: 3s
retries: 30
start_period: 2s
tmpfs_note: null
tests:
build:
context: .
target: test
environment:
DATABASE_URL: postgresql://test:test@db:5432/testdb
PYTHONPATH: /app
depends_on:
db:
condition: service_healthy
command: ["python", "-m", "pytest", "tests/", "-q", "--tb=short"]
EOF
python3 - <<'PY'
import yaml
from pathlib import Path
path = Path("compose.test.yaml")
data = yaml.safe_load(path.read_text())
# Убираем поясняющий ключ, недопустимый в схеме Compose
data["services"]["db"].pop("tmpfs_note", None)
path.write_text(yaml.safe_dump(data, allow_unicode=True, sort_keys=False))
print(" compose.test.yaml проверен и записан")
PY
echo "═══ проверка конфигурации ═══"
docker compose -f compose.test.yaml config --quiet && echo " конфигурация валидна"
Ожидаемый вывод:
compose.test.yaml проверен и записан
═══ проверка конфигурации ═══
конфигурация валидна
Код возврата: up против --exit-code-from
cd /tmp/citests
echo "═══ обычный up (без флагов) ═══"
timeout 180 docker compose -f compose.test.yaml up --abort-on-container-exit \
> run1.log 2>&1
plain_rc=$?
grep -E '[0-9]+ (passed|failed)' run1.log | tail -1 | sed 's/^/ результат тестов: /'
printf ' код возврата команды: %s\n' "$plain_rc"
docker compose -f compose.test.yaml down -v > /dev/null 2>&1
echo "═══ ломаем один тест ═══"
cat >> tests/test_orders.py <<'PY'
def test_intentionally_broken(db):
"""Заведомо падающий тест: проверяем распространение кода возврата."""
create_order(db, "N-BROKEN", "AB-1234", Decimal("1"))
assert total_by_sku(db, "AB-1234") == Decimal("999999")
PY
echo "═══ up --abort-on-container-exit БЕЗ --exit-code-from ═══"
timeout 180 docker compose -f compose.test.yaml up --abort-on-container-exit \
> run2.log 2>&1
noflag_rc=$?
grep -E '[0-9]+ failed' run2.log | tail -1 | sed 's/^/ результат тестов: /'
printf ' код возврата команды: %s\n' "$noflag_rc"
docker compose -f compose.test.yaml down -v > /dev/null 2>&1
echo "═══ up С --exit-code-from tests ═══"
timeout 180 docker compose -f compose.test.yaml up \
--abort-on-container-exit --exit-code-from tests > run3.log 2>&1
withflag_rc=$?
grep -E '[0-9]+ failed' run3.log | tail -1 | sed 's/^/ результат тестов: /'
printf ' код возврата команды: %s\n' "$withflag_rc"
docker compose -f compose.test.yaml down -v > /dev/null 2>&1
echo "═══ run --rm tests ═══"
timeout 180 docker compose -f compose.test.yaml run --rm tests > run4.log 2>&1
run_rc=$?
printf ' код возврата команды: %s\n' "$run_rc"
docker compose -f compose.test.yaml down -v > /dev/null 2>&1
python3 - <<'PY'
from pathlib import Path
p = Path("tests/test_orders.py")
text = p.read_text()
marker = "\n\ndef test_intentionally_broken(db):"
if marker in text:
p.write_text(text[:text.index(marker)] + "\n")
print(" сломанный тест удалён")
PY
echo "═══ вывод ═══"
cat <<'TXT'
Без --exit-code-from команда возвращает код своей работы,
а не код тестов. В CI это означает: шаг зелёный, тесты упали.
Два рабочих способа:
docker compose up --abort-on-container-exit --exit-code-from tests
docker compose run --rm tests
Второй проще и не требует --abort-on-container-exit.
TXT
Ожидаемый вывод:
═══ обычный up (без флагов) ═══
результат тестов: 7 passed in 0.42s
код возврата команды: 0
═══ ломаем один тест ═══
═══ up --abort-on-container-exit БЕЗ --exit-code-from ═══
результат тестов: 1 failed, 7 passed in 0.48s
код возврата команды: 0
═══ up С --exit-code-from tests ═══
результат тестов: 1 failed, 7 passed in 0.47s
код возврата команды: 1
═══ run --rm tests ═══
код возврата команды: 1
сломанный тест удалён
═══ вывод ═══
Без --exit-code-from команда возвращает код своей работы,
а не код тестов. В CI это означает: шаг зелёный, тесты упали.
...
Второй блок — суть проблемы: 1 failed в выводе и код возврата команды: 0 в соседней строке.
Третий и четвёртый показывают два рабочих способа.
Ожидание условия против sleep
cd /tmp/citests
cat > compose.sleep.yaml <<'EOF'
name: sleep-variant
services:
db:
image: postgres:17-alpine
environment:
POSTGRES_USER: test
POSTGRES_PASSWORD: test
POSTGRES_DB: testdb
tmpfs:
- /var/lib/postgresql/data
command: postgres -c fsync=off -c synchronous_commit=off
tests:
build:
context: .
target: test
environment:
DATABASE_URL: postgresql://test:test@db:5432/testdb
PYTHONPATH: /app
depends_on:
- db
command: ["sh", "-c", "sleep 15 && python -m pytest tests/ -q"]
EOF
measure() {
local file="$1" label="$2"
shift 2
local start end
start="$(python3 -c 'import time; print(time.monotonic())')"
timeout 240 docker compose -f "$file" "$@" > /dev/null 2>&1
local rc=$?
end="$(python3 -c 'import time; print(time.monotonic())')"
docker compose -f "$file" down -v > /dev/null 2>&1
printf ' %-28s %6.1f с (код %s)\n' \
"$label" "$(python3 -c "print($end - $start)")" "$rc"
}
echo "═══ сравнение времени ═══"
measure compose.sleep.yaml "sleep 15" up --abort-on-container-exit --exit-code-from tests
measure compose.test.yaml "ожидание условия" up --abort-on-container-exit --exit-code-from tests
echo "═══ почему sleep плох дважды ═══"
cat <<'TXT'
Слишком долго в обычном случае: база готова за 2 секунды,
но ждём 15 — на каждом прогоне, на каждой ветке, каждый день.
Слишком мало в плохом случае: на загруженном CI-сборщике
база может подниматься 20 секунд. Тест упадёт — но не всегда,
а иногда. Это и есть нестабильность.
Ожидание условия решает обе задачи: старт ровно тогда,
когда база готова.
TXT
Ожидаемый вывод:
═══ сравнение времени ═══
sleep 15 22.4 с (код 0)
ожидание условия 9.1 с (код 0)
═══ почему sleep плох дважды ═══
Слишком долго в обычном случае: база готова за 2 секунды,
но ждём 15 — на каждом прогоне, на каждой ветке, каждый день.
...
Разница 13 секунд на прогон. При двадцати прогонах в день это больше часа в неделю.
Существеннее другое: вариант с ожиданием условия не сломается на медленной машине — он подождёт столько, сколько нужно, в пределах retries × interval.
Проверка должна делать то же, что приложение
cd /tmp/citests
echo "═══ три варианта проверки готовности ═══"
cat <<'TXT'
1. Проверка процесса (слабая):
test: ["CMD-SHELL", "pg_isready"]
Отвечает ли сервер вообще. Не проверяет вашу базу
и вашего пользователя.
2. Проверка подключения (лучше):
test: ["CMD-SHELL", "pg_isready -U $$POSTGRES_USER -d $$POSTGRES_DB"]
Проверяет, что сервер принимает подключение к нужной базе.
3. Проверка запросом (надёжная):
test: ["CMD-SHELL", "psql -U $$POSTGRES_USER -d $$POSTGRES_DB -c 'SELECT 1' || exit 1"]
Делает ровно то, что делает приложение.
TXT
echo "═══ что проверяет каждая — измерение ═══"
docker compose -f compose.test.yaml up -d db > /dev/null 2>&1
sleep 1
for i in 1 2 3 4 5 6 7 8; do
weak="$(docker compose -f compose.test.yaml exec -T db \
sh -c 'pg_isready > /dev/null 2>&1 && echo готов || echo нет' 2>/dev/null || echo "—")"
strong="$(docker compose -f compose.test.yaml exec -T db \
sh -c 'psql -U test -d testdb -c "SELECT 1" > /dev/null 2>&1 && echo готов || echo нет' \
2>/dev/null || echo "—")"
printf ' %s с: pg_isready=%-6s запрос SELECT 1=%s\n' "$i" "$weak" "$strong"
[ "$strong" = "готов" ] && break
sleep 1
done
docker compose -f compose.test.yaml down -v > /dev/null 2>&1
echo "═══ принцип ═══"
cat <<'TXT'
Проверка готовности должна выполнять то же действие,
что и приложение. Для базы это подключение нужным
пользователем к нужной базе и выполнение запроса —
а не факт наличия процесса.
Отдельная историческая деталь: официальный образ PostgreSQL
при ПЕРВОМ запуске поднимает временный сервер для скриптов
инициализации, затем останавливает его. Проверка, попавшая
в это окно, прошла бы — а приложение получило бы отказ.
Современные версии образа поднимают временный сервер
только на unix-сокете, что закрывает этот путь для проверок
по TCP. Принцип «проверяй то, что делает приложение» остаётся.
TXT
Ожидаемый вывод:
═══ три варианта проверки готовности ═══
1. Проверка процесса (слабая):
test: ["CMD-SHELL", "pg_isready"]
Отвечает ли сервер вообще. Не проверяет вашу базу
и вашего пользователя.
...
═══ что проверяет каждая — измерение ═══
1 с: pg_isready=нет запрос SELECT 1=нет
2 с: pg_isready=готов запрос SELECT 1=нет
3 с: pg_isready=готов запрос SELECT 1=готов
═══ принцип ═══
Проверка готовности должна выполнять то же действие,
что и приложение.
...
Строка «2 с» — то, ради чего сделано измерение: pg_isready уже говорит «готов», а запрос ещё не проходит. Секунда разницы — достаточно, чтобы тест упал.
Изоляция: сравнение стратегий
cd /tmp/citests
cat > isolation.py <<'PY'
"""Сравнение стратегий изоляции тестов по цене и ограничениям."""
from __future__ import annotations
STRATEGIES = [
{
"name": "Откат транзакции",
"speed": "самая высокая",
"parallel": False,
"cost_ms": 1,
"breaks_when": "код вызывает commit сам или берёт своё соединение из пула",
"use_when": "тестируемый код не управляет транзакциями",
},
{
"name": "TRUNCATE таблиц",
"speed": "высокая",
"parallel": False,
"cost_ms": 10,
"breaks_when": "появилась таблица, не попавшая в список очистки",
"use_when": "таблиц немного и их список стабилен",
},
{
"name": "Схема на тест",
"speed": "средняя",
"parallel": True,
"cost_ms": 25,
"breaks_when": "код использует явные имена схем или расширения вне схемы",
"use_when": "нужна параллельность",
},
{
"name": "Пересоздание базы",
"speed": "низкая",
"parallel": True,
"cost_ms": 800,
"breaks_when": "почти никогда — самый надёжный вариант",
"use_when": "тестов немного, надёжность важнее скорости",
},
]
def main() -> None:
n = 100
print(f" {'стратегия':<22} {'цена/тест':>10} {'на {} тестов'.format(n):>16} "
f"{'параллель':>11}")
print(" " + "─" * 64)
for s in STRATEGIES:
total = s["cost_ms"] * n / 1000
print(f" {s['name']:<22} {s['cost_ms']:>7} мс {total:>13.1f} с "
f"{'да' if s['parallel'] else 'нет':>11}")
print()
for s in STRATEGIES:
print(f" {s['name']}")
print(f" ломается когда: {s['breaks_when']}")
print(f" применять если: {s['use_when']}")
print()
print(" Схема на тест — единственная стратегия с настоящей параллельностью.")
print(" При запуске в 4 процесса её 25 с превращаются в ~7 с,")
print(" и она обгоняет TRUNCATE, который параллелить нельзя.")
if __name__ == "__main__":
main()
PY
echo "═══ сравнение стратегий ═══"
python3 isolation.py
Ожидаемый вывод:
═══ сравнение стратегий ═══
стратегия цена/тест на 100 тестов параллель
────────────────────────────────────────────────────────────────
Откат транзакции 1 мс 0.1 с нет
TRUNCATE таблиц 10 мс 1.0 с нет
Схема на тест 25 мс 2.5 с да
Пересоздание базы 800 мс 80.0 с да
Откат транзакции
ломается когда: код вызывает commit сам или берёт своё соединение из пула
применять если: тестируемый код не управляет транзакциями
...
Схема на тест — единственная стратегия с настоящей параллельностью.
При запуске в 4 процесса её 25 с превращаются в ~7 с,
и она обгоняет TRUNCATE, который параллелить нельзя.
Последние строки — главный довод. Сравнивать стратегии по цене одного теста недостаточно: возможность параллельного запуска меняет итог.
Проверка изоляции на деле
cd /tmp/citests
echo "═══ тест, проверяющий изоляцию ═══"
grep -A 4 'def test_isolation_between_tests' tests/test_orders.py | sed 's/^/ /'
echo "═══ прогон: видят ли тесты данные друг друга ═══"
timeout 180 docker compose -f compose.test.yaml run --rm tests \
python -m pytest tests/ -q 2>&1 | tail -3 | sed 's/^/ /'
docker compose -f compose.test.yaml down -v > /dev/null 2>&1
echo "═══ что было бы БЕЗ изоляции ═══"
cat > tests/test_no_isolation.py <<'PY'
"""Демонстрация: без изоляции результат зависит от порядка тестов."""
from decimal import Decimal
from src.repo import create_order, init_schema, total_by_sku
def test_a_creates_data(connection):
"""Пишет в ОБЩУЮ схему — без фикстуры db."""
connection.execute("SET search_path TO public")
init_schema(connection)
create_order(connection, "SHARED-1", "XX-0001", Decimal("100"))
connection.commit()
assert total_by_sku(connection, "XX-0001") == Decimal("100")
def test_b_expects_empty(connection):
"""Ожидает пустую таблицу — но предыдущий тест уже записал."""
connection.execute("SET search_path TO public")
total = total_by_sku(connection, "XX-0001")
assert total == Decimal("0"), (
f"таблица не пуста: {total}. Тесты делят состояние — "
"результат зависит от порядка выполнения")
PY
timeout 180 docker compose -f compose.test.yaml run --rm tests \
python -m pytest tests/test_no_isolation.py -q 2>&1 | tail -5 | sed 's/^/ /'
docker compose -f compose.test.yaml down -v > /dev/null 2>&1
rm -f tests/test_no_isolation.py
echo "═══ вывод ═══"
cat <<'TXT'
Второй тест падает, потому что первый оставил данные.
Порядок выполнения стал частью контракта — а pytest
порядок не гарантирует и может его менять.
Симптом в реальном проекте: тест проходит при полном прогоне
и падает при запуске в одиночку. Или наоборот.
TXT
Ожидаемый вывод:
═══ тест, проверяющий изоляцию ═══
def test_isolation_between_tests(db):
"""Данные предыдущих тестов не видны: своя схема."""
row = db.execute("SELECT count(*) FROM orders").fetchone()
assert row[0] == 0, "в новой схеме таблица должна быть пустой"
═══ прогон: видят ли тесты данные друг друга ═══
....... [100%]
7 passed in 0.44s
═══ что было бы БЕЗ изоляции ═══
F. [100%]
E AssertionError: таблица не пуста: 100. Тесты делят состояние —
E результат зависит от порядка выполнения
1 failed, 1 passed in 0.21s
═══ вывод ═══
Второй тест падает, потому что первый оставил данные.
Порядок выполнения стал частью контракта — а pytest
порядок не гарантирует и может его менять.
...
Семь тестов с изоляцией проходят; два теста без неё дают падение. Это и есть проверка того, что фикстура действительно изолирует, а не просто существует.
Гарантированная очистка
cd /tmp/citests
cat > run-tests.sh <<'SH'
#!/usr/bin/env bash
# Прогон integration-тестов с гарантированной очисткой.
#
# Три свойства:
# 1. Уникальное имя проекта — параллельные прогоны не конфликтуют
# 2. trap на EXIT INT TERM — очистка при любом исходе
# 3. Код возврата тестов передаётся наружу
set -uo pipefail
COMPOSE_FILE="${COMPOSE_FILE:-compose.test.yaml}"
PROJECT="tests-$$-$(date +%s)"
cleanup() {
local rc=$?
printf '\n очистка окружения (%s)...\n' "$PROJECT"
docker compose -p "$PROJECT" -f "$COMPOSE_FILE" \
down -v --remove-orphans > /dev/null 2>&1
printf ' очистка завершена\n'
return "$rc"
}
trap cleanup EXIT INT TERM
printf ' проект: %s\n' "$PROJECT"
docker compose -p "$PROJECT" -f "$COMPOSE_FILE" \
up --abort-on-container-exit --exit-code-from tests
exit $?
SH
chmod +x run-tests.sh
echo "═══ успешный прогон ═══"
./run-tests.sh 2>&1 | grep -E 'проект|passed|очистка' | sed 's/^/ /'
ok_rc=${PIPESTATUS[0]}
echo "═══ что осталось после прогона ═══"
printf ' container: %s\n' "$(docker ps -a --filter 'name=tests-' --format '{{.Names}}' | wc -l)"
printf ' volumes: %s\n' "$(docker volume ls -q --filter 'name=tests-' | wc -l)"
printf ' сетей: %s\n' "$(docker network ls -q --filter 'name=tests-' | wc -l)"
echo "═══ прерывание в середине прогона ═══"
./run-tests.sh > interrupted.log 2>&1 &
bg_pid=$!
sleep 6
kill -INT "$bg_pid" 2>/dev/null
wait "$bg_pid" 2>/dev/null
int_rc=$?
grep -E 'очистка' interrupted.log | sed 's/^/ /'
printf ' код возврата после прерывания: %s\n' "$int_rc"
echo "═══ что осталось после прерывания ═══"
sleep 2
printf ' container: %s\n' "$(docker ps -a --filter 'name=tests-' --format '{{.Names}}' | wc -l)"
printf ' volumes: %s\n' "$(docker volume ls -q --filter 'name=tests-' | wc -l)"
echo "═══ зачем каждая деталь ═══"
cat <<'TXT'
trap cleanup EXIT срабатывает при любом выходе: успех, ошибка, exit
trap cleanup INT TERM добавляет прерывание с клавиатуры и остановку извне
-p "tests-$$-..." уникальное имя проекта: параллельные прогоны
не делят container'ы, сети и тома
down -v БЕЗ -v анонимные тома остаются и накапливаются
--remove-orphans убирает сервисы, удалённые из файла Compose
Проверка: прервите прогон и убедитесь, что ничего не осталось.
Механизм, который не проверяли, обычно не работает.
TXT
docker compose -f compose.test.yaml down -v > /dev/null 2>&1
cd /tmp && rm -rf /tmp/citests
Ожидаемый вывод:
═══ успешный прогон ═══
проект: tests-48213-1785321600
tests-1 | 7 passed in 0.44s
очистка окружения (tests-48213-1785321600)...
очистка завершена
═══ что осталось после прогона ═══
container: 0
volumes: 0
сетей: 0
═══ прерывание в середине прогона ═══
очистка окружения (tests-48219-1785321612)...
очистка завершена
код возврата после прерывания: 130
═══ что осталось после прерывания ═══
container: 0
volumes: 0
═══ зачем каждая деталь ═══
trap cleanup EXIT срабатывает при любом выходе: успех, ошибка, exit
...
Проверка выполнена в обоих сценариях: после нормального завершения и после прерывания сигналом INT не осталось ни container'ов, ни томов, ни сетей.
Код 130 — это 128 + 2, то есть завершение по SIGINT (урок 13.3).
Практическое упражнение
Задание. Соберите прогон integration-тестов, пригодный для CI.
Требования:
- Написать
compose.test.yamlс PostgreSQL и получить код возврата тестов. - Показать, что без
--exit-code-fromупавшие тесты дают код0. - Заменить
sleepожиданием условия и измерить разницу во времени. - Показать, что слабая проверка готовности проходит раньше, чем база готова принять запрос.
- Реализовать изоляцию тестов и доказать её тестом, который без изоляции падает.
- Ускорить базу переводом в
tmpfsи отключением сохранности; измерить выигрыш. - Гарантировать очистку при любом исходе и проверить это прерыванием.
Подсказки
Подсказка 1
Для пункта 4 опрашивайте оба варианта проверки в цикле раз в секунду и печатайте оба результата.
Подсказка 2
Пункт 5: добавьте тест, который проверяет, что таблица пуста. Без изоляции он упадёт после теста, который пишет данные.
Подсказка 3
Для пункта 7 запустите скрипт в фоне и пошлите ему SIGINT через несколько секунд.
Решение
Показать решение
mkdir -p /tmp/cilab && cd /tmp/cilab
mkdir -p src tests
cat > requirements.txt <<'EOF'
psycopg[binary]==3.3.4
EOF
cat > requirements-dev.txt <<'EOF'
pytest==9.1.1
EOF
cat > src/__init__.py <<'PY'
PY
cat > src/inventory.py <<'PY'
"""Учёт остатков на PostgreSQL."""
from __future__ import annotations
import os
import psycopg
SCHEMA = """
CREATE TABLE IF NOT EXISTS items (
id SERIAL PRIMARY KEY,
sku TEXT NOT NULL UNIQUE,
quantity INTEGER NOT NULL CHECK (quantity >= 0),
updated TIMESTAMPTZ NOT NULL DEFAULT now()
);
"""
def connect() -> psycopg.Connection:
return psycopg.connect(os.environ["DATABASE_URL"])
def init_schema(conn: psycopg.Connection) -> None:
conn.execute(SCHEMA)
conn.commit()
def add_item(conn: psycopg.Connection, sku: str, quantity: int) -> int:
row = conn.execute(
"INSERT INTO items (sku, quantity) VALUES (%s, %s) RETURNING id",
(sku, quantity),
).fetchone()
return row[0]
def reserve(conn: psycopg.Connection, sku: str, count: int) -> bool:
"""Атомарное уменьшение остатка. CHECK не даст уйти в минус."""
row = conn.execute(
"UPDATE items SET quantity = quantity - %s, updated = now() "
"WHERE sku = %s AND quantity >= %s RETURNING quantity",
(count, sku, count),
).fetchone()
return row is not None
def quantity_of(conn: psycopg.Connection, sku: str) -> int | None:
row = conn.execute("SELECT quantity FROM items WHERE sku = %s",
(sku,)).fetchone()
return row[0] if row else None
def count_items(conn: psycopg.Connection) -> int:
return conn.execute("SELECT count(*) FROM items").fetchone()[0]
PY
cat > tests/conftest.py <<'PY'
"""Изоляция схемой: единственная стратегия с настоящей параллельностью."""
from __future__ import annotations
import os
import uuid
import psycopg
import pytest
from src.inventory import init_schema
@pytest.fixture(scope="session")
def connection():
conn = psycopg.connect(os.environ["DATABASE_URL"])
try:
yield conn
finally:
conn.close()
@pytest.fixture
def db(connection):
schema = f"t_{uuid.uuid4().hex[:12]}"
connection.execute(f'CREATE SCHEMA "{schema}"')
connection.execute(f'SET search_path TO "{schema}"')
connection.commit()
init_schema(connection)
try:
yield connection
finally:
connection.rollback()
connection.execute(f'DROP SCHEMA "{schema}" CASCADE')
connection.commit()
PY
cat > tests/test_inventory.py <<'PY'
"""Integration-тесты против настоящего PostgreSQL."""
import psycopg
import pytest
from src.inventory import add_item, count_items, quantity_of, reserve
def test_add_and_read(db):
ident = add_item(db, "AB-1234", 10)
assert ident > 0
assert quantity_of(db, "AB-1234") == 10
def test_unique_sku_enforced(db):
"""UNIQUE — на стороне БД."""
add_item(db, "AB-1234", 5)
with pytest.raises(psycopg.errors.UniqueViolation):
add_item(db, "AB-1234", 3)
db.rollback()
def test_check_constraint_enforced(db):
"""CHECK quantity >= 0 — тоже на стороне БД."""
with pytest.raises(psycopg.errors.CheckViolation):
add_item(db, "CD-5678", -1)
db.rollback()
def test_reserve_reduces_quantity(db):
add_item(db, "AB-1234", 10)
assert reserve(db, "AB-1234", 3) is True
assert quantity_of(db, "AB-1234") == 7
def test_reserve_refuses_when_insufficient(db):
"""Условие в UPDATE выполняет БД атомарно."""
add_item(db, "AB-1234", 2)
assert reserve(db, "AB-1234", 5) is False
assert quantity_of(db, "AB-1234") == 2
def test_default_timestamp(db):
add_item(db, "AB-1234", 1)
row = db.execute("SELECT updated FROM items WHERE sku = 'AB-1234'").fetchone()
assert row[0] is not None
def test_table_is_empty_at_start(db):
"""ДОКАЗАТЕЛЬСТВО изоляции: без неё этот тест падает,
если выполняется после тестов, добавлявших данные."""
assert count_items(db) == 0, (
"таблица не пуста — тесты делят состояние, "
"и результат зависит от порядка выполнения")
PY
cat > Dockerfile <<'EOF'
# syntax=docker/dockerfile:1
FROM python:3.13-slim AS base
ENV PYTHONUNBUFFERED=1 PYTHONDONTWRITEBYTECODE=1
WORKDIR /app
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
FROM base AS production
COPY src/ ./src/
CMD ["python", "-c", "print('приложение')"]
FROM production AS test
COPY requirements-dev.txt .
RUN pip install --no-cache-dir -r requirements-dev.txt
COPY tests/ ./tests/
CMD ["python", "-m", "pytest", "tests/", "-q"]
EOF
# ─── Быстрый вариант: tmpfs + отключённая сохранность ─────────────────
cat > compose.test.yaml <<'EOF'
name: cilab-fast
services:
db:
image: postgres:17-alpine
environment:
POSTGRES_USER: test
POSTGRES_PASSWORD: test
POSTGRES_DB: testdb
tmpfs:
- /var/lib/postgresql/data
command: >
postgres
-c fsync=off
-c synchronous_commit=off
-c full_page_writes=off
-c max_connections=50
healthcheck:
test: ["CMD-SHELL", "psql -U $$POSTGRES_USER -d $$POSTGRES_DB -c 'SELECT 1' > /dev/null || exit 1"]
interval: 1s
timeout: 3s
retries: 40
start_period: 2s
tests:
build:
context: .
target: test
environment:
DATABASE_URL: postgresql://test:test@db:5432/testdb
PYTHONPATH: /app
depends_on:
db:
condition: service_healthy
command: ["python", "-m", "pytest", "tests/", "-q", "--tb=short"]
EOF
# ─── Медленный вариант: том на диске, сохранность включена ────────────
cat > compose.slow.yaml <<'EOF'
name: cilab-slow
services:
db:
image: postgres:17-alpine
environment:
POSTGRES_USER: test
POSTGRES_PASSWORD: test
POSTGRES_DB: testdb
volumes:
- slowdata:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "psql -U $$POSTGRES_USER -d $$POSTGRES_DB -c 'SELECT 1' > /dev/null || exit 1"]
interval: 1s
timeout: 3s
retries: 40
start_period: 2s
tests:
build:
context: .
target: test
environment:
DATABASE_URL: postgresql://test:test@db:5432/testdb
PYTHONPATH: /app
depends_on:
db:
condition: service_healthy
command: ["python", "-m", "pytest", "tests/", "-q", "--tb=short"]
volumes:
slowdata:
EOF
# ─── Вариант со sleep — для сравнения ─────────────────────────────────
cat > compose.sleep.yaml <<'EOF'
name: cilab-sleep
services:
db:
image: postgres:17-alpine
environment:
POSTGRES_USER: test
POSTGRES_PASSWORD: test
POSTGRES_DB: testdb
tmpfs:
- /var/lib/postgresql/data
command: postgres -c fsync=off -c synchronous_commit=off
tests:
build:
context: .
target: test
environment:
DATABASE_URL: postgresql://test:test@db:5432/testdb
PYTHONPATH: /app
depends_on:
- db
command: ["sh", "-c", "sleep 20 && python -m pytest tests/ -q"]
EOF
# ─── Скрипт прогона с гарантированной очисткой ────────────────────────
cat > run-tests.sh <<'SH'
#!/usr/bin/env bash
# Прогон с гарантированной очисткой при любом исходе.
set -uo pipefail
COMPOSE_FILE="${COMPOSE_FILE:-compose.test.yaml}"
PROJECT="${PROJECT:-cilab-$$-$(date +%s)}"
cleanup() {
local rc=$?
printf ' [очистка] проект %s\n' "$PROJECT"
docker compose -p "$PROJECT" -f "$COMPOSE_FILE" \
down -v --remove-orphans > /dev/null 2>&1
printf ' [очистка] завершена\n'
return "$rc"
}
trap cleanup EXIT INT TERM
docker compose -p "$PROJECT" -f "$COMPOSE_FILE" \
up --abort-on-container-exit --exit-code-from tests
exit $?
SH
chmod +x run-tests.sh
elapsed() {
python3 -c "print(f'{$2 - $1:.1f}')"
}
now() { python3 -c 'import time; print(time.monotonic())'; }
fail=0
ok() { printf ' ✓ %s\n' "$1"; }
bad() { printf ' ✗ %s\n' "$1"; fail=1; }
printf '\n═══ Подготовка: сборка образа ═══\n'
docker compose -f compose.test.yaml build tests > build.log 2>&1
printf ' образ собран\n'
printf '\n═══ Требование 1: код возврата тестов ═══\n'
timeout 240 docker compose -p ok1 -f compose.test.yaml \
up --abort-on-container-exit --exit-code-from tests > pass.log 2>&1
pass_rc=$?
docker compose -p ok1 -f compose.test.yaml down -v > /dev/null 2>&1
grep -E '[0-9]+ passed' pass.log | tail -1 | sed 's/^/ /'
printf ' код возврата: %s\n' "$pass_rc"
[ "$pass_rc" -eq 0 ] \
&& ok "успешный прогон даёт код 0" \
|| bad "код возврата: $pass_rc"
printf '\n═══ Требование 2: без флага упавшие тесты дают 0 ═══\n'
cat >> tests/test_inventory.py <<'PY'
def test_intentionally_broken(db):
"""Заведомо падающий тест."""
add_item(db, "ZZ-9999", 1)
assert quantity_of(db, "ZZ-9999") == 42
PY
docker compose -f compose.test.yaml build tests > /dev/null 2>&1
timeout 240 docker compose -p noflag -f compose.test.yaml \
up --abort-on-container-exit > noflag.log 2>&1
noflag_rc=$?
docker compose -p noflag -f compose.test.yaml down -v > /dev/null 2>&1
timeout 240 docker compose -p withflag -f compose.test.yaml \
up --abort-on-container-exit --exit-code-from tests > withflag.log 2>&1
withflag_rc=$?
docker compose -p withflag -f compose.test.yaml down -v > /dev/null 2>&1
timeout 240 docker compose -p runcmd -f compose.test.yaml run --rm tests \
> runcmd.log 2>&1
run_rc=$?
docker compose -p runcmd -f compose.test.yaml down -v > /dev/null 2>&1
printf ' %-42s %s\n' "способ запуска" "код возврата"
printf ' %s\n' "──────────────────────────────────────────────────────────"
printf ' %-42s %s\n' "up --abort-on-container-exit" "$noflag_rc"
printf ' %-42s %s\n' "up ... --exit-code-from tests" "$withflag_rc"
printf ' %-42s %s\n' "run --rm tests" "$run_rc"
grep -E '[0-9]+ failed' noflag.log | tail -1 | sed 's/^/ в логе первого: /'
python3 - <<'PY'
from pathlib import Path
p = Path("tests/test_inventory.py")
text = p.read_text()
marker = "\n\ndef test_intentionally_broken(db):"
if marker in text:
p.write_text(text[:text.index(marker)] + "\n")
print(" сломанный тест удалён")
PY
docker compose -f compose.test.yaml build tests > /dev/null 2>&1
[ "$noflag_rc" -eq 0 ] && [ "$withflag_rc" -ne 0 ] && [ "$run_rc" -ne 0 ] \
&& ok "без флага падение тестов не видно; с флагом и через run — видно" \
|| bad "коды: без флага=$noflag_rc с флагом=$withflag_rc run=$run_rc"
printf '\n═══ Требование 3: sleep против ожидания условия ═══\n'
docker compose -f compose.sleep.yaml build tests > /dev/null 2>&1
t0="$(now)"
timeout 300 docker compose -p sl -f compose.sleep.yaml \
up --abort-on-container-exit --exit-code-from tests > sleep.log 2>&1
sleep_rc=$?
t1="$(now)"
docker compose -p sl -f compose.sleep.yaml down -v > /dev/null 2>&1
sleep_time="$(elapsed "$t0" "$t1")"
t0="$(now)"
timeout 300 docker compose -p wt -f compose.test.yaml \
up --abort-on-container-exit --exit-code-from tests > wait.log 2>&1
wait_rc=$?
t1="$(now)"
docker compose -p wt -f compose.test.yaml down -v > /dev/null 2>&1
wait_time="$(elapsed "$t0" "$t1")"
printf ' %-26s %8s с (код %s)\n' "sleep 20" "$sleep_time" "$sleep_rc"
printf ' %-26s %8s с (код %s)\n' "ожидание условия" "$wait_time" "$wait_rc"
saved="$(python3 -c "print(f'{$sleep_time - $wait_time:.1f}')")"
printf ' выигрыш: %s с на прогон\n' "$saved"
printf ' при 20 прогонах в день: %s мин в неделю\n' \
"$(python3 -c "print(f'{$saved * 20 * 7 / 60:.0f}')")"
faster="$(python3 -c "print(1 if $wait_time < $sleep_time else 0)")"
[ "$faster" = "1" ] \
&& ok "ожидание условия быстрее и не сломается на медленной машине" \
|| bad "sleep=$sleep_time ожидание=$wait_time"
printf '\n═══ Требование 4: слабая проверка проходит раньше ═══\n'
docker compose -p probe -f compose.test.yaml up -d db > /dev/null 2>&1
printf ' %-6s %-16s %s\n' "время" "pg_isready" "запрос SELECT 1"
printf ' %s\n' "────────────────────────────────────────────"
weak_first=0
strong_first=0
for i in $(seq 1 15); do
weak="$(docker compose -p probe -f compose.test.yaml exec -T db \
sh -c 'pg_isready > /dev/null 2>&1 && echo готов || echo нет' 2>/dev/null || echo "—")"
strong="$(docker compose -p probe -f compose.test.yaml exec -T db \
sh -c 'psql -U test -d testdb -c "SELECT 1" > /dev/null 2>&1 && echo готов || echo нет' \
2>/dev/null || echo "—")"
printf ' %-6s %-16s %s\n' "${i} с" "$weak" "$strong"
[ "$weak" = "готов" ] && [ "$weak_first" -eq 0 ] && weak_first="$i"
[ "$strong" = "готов" ] && { strong_first="$i"; break; }
sleep 1
done
docker compose -p probe -f compose.test.yaml down -v > /dev/null 2>&1
printf ' pg_isready сказал «готов» на %s с, запрос прошёл на %s с\n' \
"$weak_first" "$strong_first"
[ "${weak_first:-0}" -gt 0 ] && [ "${strong_first:-0}" -ge "${weak_first:-0}" ] \
&& ok "слабая проверка проходит не позже сильной — разрыв и есть источник гонки" \
|| bad "слабая=$weak_first сильная=$strong_first"
printf '\n═══ Требование 5: изоляция доказана тестом ═══\n'
printf ' прогон с изоляцией (фикстура db):\n'
timeout 240 docker compose -p iso -f compose.test.yaml run --rm tests \
python -m pytest tests/ -q 2>&1 | tail -2 | sed 's/^/ /'
iso_rc=${PIPESTATUS[0]}
docker compose -p iso -f compose.test.yaml down -v > /dev/null 2>&1
cat > tests/test_shared_state.py <<'PY'
"""Те же проверки БЕЗ изоляции: тесты делят схему public."""
from src.inventory import add_item, count_items, init_schema
def test_a_writes(connection):
connection.execute("SET search_path TO public")
init_schema(connection)
add_item(connection, "SHARED-1", 5)
connection.commit()
assert count_items(connection) >= 1
def test_b_expects_empty(connection):
connection.execute("SET search_path TO public")
total = count_items(connection)
assert total == 0, (
f"таблица не пуста: {total} записей. Тесты делят состояние — "
"результат зависит от порядка выполнения")
PY
docker compose -f compose.test.yaml build tests > /dev/null 2>&1
printf ' прогон без изоляции (общая схема public):\n'
timeout 240 docker compose -p noiso -f compose.test.yaml run --rm tests \
python -m pytest tests/test_shared_state.py -q 2>&1 | tail -3 | sed 's/^/ /'
noiso_rc=${PIPESTATUS[0]}
docker compose -p noiso -f compose.test.yaml down -v > /dev/null 2>&1
rm -f tests/test_shared_state.py
docker compose -f compose.test.yaml build tests > /dev/null 2>&1
printf ' с изоляцией код %s, без изоляции код %s\n' "$iso_rc" "$noiso_rc"
[ "$iso_rc" -eq 0 ] && [ "$noiso_rc" -ne 0 ] \
&& ok "изоляция доказана: тот же набор проверок без неё падает" \
|| bad "с изоляцией=$iso_rc без=$noiso_rc"
printf '\n═══ Требование 6: ускорение базы ═══\n'
docker compose -f compose.slow.yaml build tests > /dev/null 2>&1
t0="$(now)"
timeout 300 docker compose -p slow -f compose.slow.yaml \
up --abort-on-container-exit --exit-code-from tests > slow.log 2>&1
slow_rc=$?
t1="$(now)"
docker compose -p slow -f compose.slow.yaml down -v > /dev/null 2>&1
slow_time="$(elapsed "$t0" "$t1")"
printf ' %-38s %8s с (код %s)\n' "том на диске, сохранность включена" "$slow_time" "$slow_rc"
printf ' %-38s %8s с (код %s)\n' "tmpfs, fsync=off" "$wait_time" "$wait_rc"
printf ' выигрыш: %s с\n' "$(python3 -c "print(f'{$slow_time - $wait_time:.1f}')")"
printf ' проверка, что данные действительно в памяти:\n'
docker compose -p fs -f compose.test.yaml up -d db > /dev/null 2>&1
sleep 6
docker compose -p fs -f compose.test.yaml exec -T db \
df -h /var/lib/postgresql/data 2>/dev/null | tail -1 | sed 's/^/ /'
docker compose -p fs -f compose.test.yaml exec -T db \
psql -U test -d testdb -tAc "SHOW fsync" 2>/dev/null | sed 's/^/ fsync=/'
docker compose -p fs -f compose.test.yaml down -v > /dev/null 2>&1
printf ' ВАЖНО: fsync=off допустим ТОЛЬКО для тестовой базы —\n'
printf ' при аварии данные будут повреждены\n'
[ "$slow_rc" -eq 0 ] \
&& ok "оба варианта работают; быстрый выигрывает $(python3 -c "print(f'{$slow_time - $wait_time:.1f}')") с" \
|| bad "медленный вариант завершился с кодом $slow_rc"
printf '\n═══ Требование 7: очистка при любом исходе ═══\n'
count_leftovers() {
local c v n
c="$(docker ps -aq --filter 'name=cilab-' | wc -l)"
v="$(docker volume ls -q --filter 'name=cilab-' | wc -l)"
n="$(docker network ls -q --filter 'name=cilab-' | wc -l)"
printf '%s %s %s' "$c" "$v" "$n"
}
printf ' до прогонов: container=%s volumes=%s сетей=%s\n' $(count_leftovers)
printf ' успешный прогон:\n'
./run-tests.sh > normal.log 2>&1
normal_rc=$?
grep '\[очистка\]' normal.log | sed 's/^/ /'
printf ' код возврата: %s\n' "$normal_rc"
printf ' осталось: container=%s volumes=%s сетей=%s\n' $(count_leftovers)
printf ' прерывание сигналом INT:\n'
PROJECT="cilab-interrupt-$$" ./run-tests.sh > interrupt.log 2>&1 &
bg=$!
sleep 7
kill -INT "$bg" 2>/dev/null
wait "$bg" 2>/dev/null
int_rc=$?
sleep 3
grep '\[очистка\]' interrupt.log | sed 's/^/ /'
printf ' код возврата: %s\n' "$int_rc"
printf ' осталось: container=%s volumes=%s сетей=%s\n' $(count_leftovers)
read -r left_c left_v left_n <<EOF
$(count_leftovers)
EOF
[ "$left_c" -eq 0 ] && [ "$left_v" -eq 0 ] \
&& ok "после успеха и после прерывания не осталось ни container'ов, ни томов" \
|| bad "осталось: container=$left_c volumes=$left_v сетей=$left_n"
printf '\n═══ ИТОГ ═══\n'
[ "$fail" -eq 0 ] && echo " все требования выполнены" || echo " ЕСТЬ ПРОВАЛЫ"
docker compose -f compose.test.yaml down -v > /dev/null 2>&1
docker compose -f compose.slow.yaml down -v > /dev/null 2>&1
docker compose -f compose.sleep.yaml down -v > /dev/null 2>&1
cd /tmp && rm -rf /tmp/cilab
exit "$fail"
Ожидаемый вывод:
═══ Подготовка: сборка образа ═══
образ собран
═══ Требование 1: код возврата тестов ═══
tests-1 | 7 passed in 0.51s
код возврата: 0
✓ успешный прогон даёт код 0
═══ Требование 2: без флага упавшие тесты дают 0 ═══
способ запуска код возврата
──────────────────────────────────────────────────────────
up --abort-on-container-exit 0
up ... --exit-code-from tests 1
run --rm tests 1
в логе первого: 1 failed, 7 passed in 0.56s
сломанный тест удалён
✓ без флага падение тестов не видно; с флагом и через run — видно
═══ Требование 3: sleep против ожидания условия ═══
sleep 20 28.4 с (код 0)
ожидание условия 9.7 с (код 0)
выигрыш: 18.7 с на прогон
при 20 прогонах в день: 44 мин в неделю
✓ ожидание условия быстрее и не сломается на медленной машине
═══ Требование 4: слабая проверка проходит раньше ═══
время pg_isready запрос SELECT 1
────────────────────────────────────────────
1 с нет нет
2 с готов нет
3 с готов готов
pg_isready сказал «готов» на 2 с, запрос прошёл на 3 с
✓ слабая проверка проходит не позже сильной — разрыв и есть источник гонки
═══ Требование 5: изоляция доказана тестом ═══
прогон с изоляцией (фикстура db):
....... [100%]
7 passed in 0.49s
прогон без изоляции (общая схема public):
.F [100%]
E AssertionError: таблица не пуста: 1 записей. Тесты делят состояние —
1 failed, 1 passed in 0.24s
с изоляцией код 0, без изоляции код 1
✓ изоляция доказана: тот же набор проверок без неё падает
═══ Требование 6: ускорение базы ═══
том на диске, сохранность включена 13.8 с (код 0)
tmpfs, fsync=off 9.7 с (код 0)
выигрыш: 4.1 с
проверка, что данные действительно в памяти:
tmpfs 3.9G 28.4M 3.9G 1% /var/lib/postgresql/data
fsync=off
ВАЖНО: fsync=off допустим ТОЛЬКО для тестовой базы —
при аварии данные будут повреждены
✓ оба варианта работают; быстрый выигрывает 4.1 с
═══ Требование 7: очистка при любом исходе ═══
до прогонов: container=0 volumes=0 сетей=0
успешный прогон:
[очистка] проект cilab-51204-1785321600
[очистка] завершена
код возврата: 0
осталось: container=0 volumes=0 сетей=0
прерывание сигналом INT:
[очистка] проект cilab-interrupt-51204
[очистка] завершена
код возврата: 130
осталось: container=0 volumes=0 сетей=0
✓ после успеха и после прерывания не осталось ни container'ов, ни томов
═══ ИТОГ ═══
все требования выполнены
Все требования выполнены.
Требование 4 даёт самый ценный результат: pg_isready сообщает «готов» на второй секунде, а запрос проходит только на третьей. Одна секунда разрыва — и тест, стартовавший по слабой проверке, получает отказ соединения.
Три решения, определяющие качество.
Изоляция доказана падающим тестом, а не наличием фикстуры. Фикстура db могла бы создавать схему и не переключать search_path — тесты всё равно проходили бы, потому что они не проверяют изоляцию. Отдельный набор без фикстуры, дающий код 1 на тех же проверках, доказывает, что изоляция работает. Без этого контраста утверждение осталось бы декларацией.
Требование 4 опрашивает обе проверки одновременно в одном цикле. Замер их по отдельности дал бы два числа, но не доказал бы разрыв: база могла подниматься по-разному в двух прогонах. Опрос в одной секунде на одном экземпляре делает разницу неопровержимой.
Проверка очистки считает объекты до и после, а не только после. «Ноль container'ов после прогона» ничего не значит, если их и до прогона было ноль по другой причине. Строка «до прогонов: container=0» задаёт точку отсчёта, а фильтр по имени проекта исключает чужие объекты.
Чего решение не делает. Параллельный запуск тестов не проверялся: изоляция схемой его допускает, но подтвердить это можно только прогоном через pytest-xdist в несколько процессов, что требует ещё одной зависимости. Стратегии изоляции сравниваются в уроке таблицей, а измерена только одна — схема на тест. Ловушка с временным сервером PostgreSQL при инициализации описана, но не воспроизведена: современные образы поднимают его только на unix-сокете, и вызвать ситуацию искусственно означало бы собирать особый образ. Наконец, выигрыш от tmpfs измерен на семи тестах — на наборе из сотен разница была бы заметно больше, поскольку она накапливается на каждой операции записи.
Проверка результата
docker compose -f compose.test.yaml config --quiet && echo валидно
docker compose -f compose.test.yaml up --abort-on-container-exit --exit-code-from tests; echo "код: $?"
docker compose -f compose.test.yaml down -v
docker ps -a --filter 'name=ИМЯ_ПРОЕКТА' | wc -l
docker volume ls -q --filter 'name=ИМЯ_ПРОЕКТА' | wc -l
После down -v последние две команды должны давать ноль.
Типичные ошибки
| Ошибка | Причина | Исправление |
|---|---|---|
up без --exit-code-from | Кажется, что код передаётся | Упавшие тесты дают код 0 |
up без --abort-on-container-exit | Не знают о флаге | Команда ждёт завершения базы — никогда |
sleep N перед тестами | Просто | Долго в норме, мало под нагрузкой |
pg_isready без -U и -d | Короче | Проверяет процесс, а не готовность для вас |
down без -v | Забывают флаг | Анонимные тома накапливаются |
Очистка без trap | «Скрипт же дойдёт до конца» | Не дойдёт при падении или прерывании |
| Фиксированное имя проекта | По умолчанию | Параллельные прогоны конфликтуют |
| Изоляция объявлена, но не проверена | Фикстура есть | Нужен тест, падающий без неё |
fsync=off в рабочей базе | Скопировали из тестов | Повреждение данных при аварии |
| Том на диске для тестовой базы | По привычке | tmpfs быстрее, сохранность не нужна |
Контрольные вопросы
На понимание:
- Почему
docker compose upвозвращает0при упавших тестах? - Зачем
--abort-on-container-exit, если есть--exit-code-from? - Почему
pg_isreadyбез параметров — слабая проверка готовности? - Какая стратегия изоляции даёт настоящую параллельность и чем платит?
- Почему
fsync=offдопустим для тестовой базы и недопустим для рабочей?
На применение:
- Как гарантировать очистку окружения при прерывании прогона?
- Как доказать, что изоляция тестов действительно работает?
- Как запускать несколько прогонов параллельно без конфликтов?
На диагностику:
- Тест проходит при полном прогоне и падает при запуске в одиночку. Причина?
- В CI тесты иногда падают на подключении к базе, локально — никогда. Гипотеза?
Краткое резюме
docker compose upвозвращает код своей работы, а не код тестов.--exit-code-from testsвместе с--abort-on-container-exitотдаёт настоящий код.docker compose run --rm tests— более простая альтернатива с тем же результатом.sleepплох дважды: долго в обычном случае и мало в плохом.depends_on: condition: service_healthyстартует тесты ровно при готовности базы.- Проверка готовности должна делать то же, что приложение: подключаться и выполнять запрос.
pg_isreadyможет сказать «готов» на секунду раньше, чем запрос начнёт проходить.- Изоляция схемой — единственная стратегия с настоящей параллельностью.
- Изоляцию нужно доказывать тестом, который без неё падает.
tmpfsплюсfsync=offзаметно ускоряют тестовую базу и недопустимы для рабочей.trap cleanup EXIT INT TERMгарантирует очистку при любом исходе.- Уникальное имя проекта (
-p) изолирует параллельные прогоны.
Официальные источники
| Источник | Ссылка | Что подтверждает |
|---|---|---|
Compose: up | https://docs.docker.com/reference/cli/docker/compose/up/ | --abort-on-container-exit, --exit-code-from |
Compose: run | https://docs.docker.com/reference/cli/docker/compose/run/ | Разовое выполнение команды |
Compose: depends_on | https://docs.docker.com/reference/compose-file/services/#depends_on | condition: service_healthy |
Compose: tmpfs | https://docs.docker.com/reference/compose-file/services/#tmpfs | Данные в памяти |
Compose: down | https://docs.docker.com/reference/cli/docker/compose/down/ | Флаги -v, --remove-orphans |
| PostgreSQL: образ | https://hub.docker.com/_/postgres | Инициализация, переменные окружения |
| PostgreSQL: non-durable settings | https://www.postgresql.org/docs/current/non-durability.html | fsync, synchronous_commit, full_page_writes |
PostgreSQL: pg_isready | https://www.postgresql.org/docs/current/app-pg-isready.html | Параметры проверки |
Навигация
← Предыдущий материал
Вернуться к разделу
Следующий материал → Testcontainers for Python
Главное оглавление