Главная/Testing/Урок

15.3. Integration tests через Compose

Цели

После этого материала вы сможете:

  • получить код возврата тестов из Compose — и объяснить, почему обычный up его теряет;
  • заменить sleep ожиданием условия и измерить выигрыш;
  • объяснить, почему проверка готовности может пройти до того, как база готова;
  • выбрать стратегию изоляции тестов и обосновать выбор её ценой;
  • ускорить тестовую базу переводом данных в память и отключением сохранности;
  • гарантировать удаление окружения при любом исходе, включая прерывание.

Предварительные знания

Ключевые термины

ТерминОбъяснение
--exit-code-fromФлаг, отдающий код возврата указанного сервиса
service_healthyУсловие запуска: зависимость прошла проверку
tmpfsФайловая система в оперативной памяти
fsyncПринудительная запись на диск; главный источник задержки БД
trapОбработчик сигнала в оболочке
-pИмя проекта Compose: изолирует наборы объектов

Теория

Код возврата: главная деталь

bash
docker compose -f compose.test.yaml up
echo $?          # 0 — даже если тесты упали

Обычный up возвращает код своей работы, а не работы сервисов. Тесты упали, Compose отработал успешно, CI считает шаг пройденным.

Два способа получить настоящий код:

bash
docker compose -f compose.test.yaml up \
    --abort-on-container-exit --exit-code-from tests
bash
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 — источник нестабильности

yaml
command: sh -c "sleep 10 && pytest"

Два независимых недостатка (урок 15.1):

СлучайЧто происходит
База поднялась за 2 с8 секунд теряются на каждом прогоне
CI-сборщик загружен, база за 15 сТест падает — но не всегда

Правильное решение — условие:

yaml
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

Тесты стартуют, как только база готова, — не раньше и не позже.

Ловушка: проверка проходит, а база не готова

Классическая ошибка — проверка, не совпадающая с тем, что делает приложение:

yaml
# ПЛОХО: проверяет процесс, а не готовность принять запрос
test: ["CMD-SHELL", "pg_isready"]

Без -U и -d утилита проверяет, отвечает ли сервер вообще, — но не то, что ваша база создана и ваш пользователь может подключиться.

Вторая ловушка глубже. Официальный образ PostgreSQL при первом запуске выполняет инициализацию: создаёт кластер, запускает временный сервер, прогоняет скрипты из docker-entrypoint-initdb.d, останавливает временный сервер и запускает настоящий.

Если проверка успеет попасть в окно работы временного сервера, она пройдёт — а затем сервер будет остановлен, и приложение получит connection refused.

Современные версии образа запускают временный сервер только на unix-сокете, что закрывает этот путь для проверок по TCP. Но принцип остаётся:

Проверка готовности должна выполнять то же действие, что и приложение. Для базы это подключение к нужной базе нужным пользователем, а не факт наличия процесса.

Надёжный вариант — выполнить настоящий запрос:

yaml
test: ["CMD-SHELL", "psql -U $$POSTGRES_USER -d $$POSTGRES_DB -c 'SELECT 1' || exit 1"]

Изоляция тестов

Четыре стратегии, различающиеся ценой и ограничениями:

СтратегияСкоростьПараллельностьОграничение
Откат транзакцииСамая высокаяОграниченнаяЛомается, если код делает commit или открывает своё соединение
Очистка таблиц (TRUNCATE)ВысокаяНетНужно знать все таблицы; сбрасывает последовательности
Схема на тестСредняяДаТребует поддержки схем и настройки search_path
Пересоздание базыНизкаяДаСекунды на тест

Откат транзакции — самый быстрый и самый хрупкий:

python
@pytest.fixture
def session(connection):
    transaction = connection.begin()
    yield make_session(connection)
    transaction.rollback()

Он перестаёт работать, как только тестируемый код вызывает commit сам или создаёт собственное соединение — а это типично для кода, использующего пул соединений.

Схема на тест — единственный вариант, дающий настоящую параллельность:

sql
CREATE SCHEMA test_a1b2c3;
SET search_path TO test_a1b2c3;

Каждый тест видит свой набор таблиц; конфликтов нет; можно запускать в несколько процессов.

Ускорение тестовой базы

База данных медленна из-за гарантий сохранности, которые тестам не нужны.

НастройкаЧто отключаетВыигрыш
Данные в tmpfsДиск целикомЗначительный
fsync=offПринудительный сброс на дискЗначительный
synchronous_commit=offОжидание записи журналаЗаметный
full_page_writes=offЗащита от частичной записи страницыЗаметный
yaml
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).

Гарантированная очистка

bash
docker compose -f compose.test.yaml down -v

Флаг -v обязателен: без него анонимные тома остаются и накапливаются (урок 13.4).

Но команда не выполнится, если прогон прервали или он упал раньше. Решение — trap:

bash
#!/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.


Команды и примеры

Приложение и тесты

bash
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 "  файлы созданы"

Ожидаемый вывод:

text
  файлы созданы

compose.test.yaml

bash
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 "  конфигурация валидна"

Ожидаемый вывод:

text
  compose.test.yaml проверен и записан
═══ проверка конфигурации ═══
  конфигурация валидна

Код возврата: up против --exit-code-from

bash
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

Ожидаемый вывод:

text
═══ обычный 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

bash
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

Ожидаемый вывод:

text
═══ сравнение времени ═══
  sleep 15                       22.4 с  (код 0)
  ожидание условия                9.1 с  (код 0)
═══ почему sleep плох дважды ═══
  Слишком долго в обычном случае: база готова за 2 секунды,
  но ждём 15 — на каждом прогоне, на каждой ветке, каждый день.
  ...

Разница 13 секунд на прогон. При двадцати прогонах в день это больше часа в неделю.

Существеннее другое: вариант с ожиданием условия не сломается на медленной машине — он подождёт столько, сколько нужно, в пределах retries × interval.

Проверка должна делать то же, что приложение

bash
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

Ожидаемый вывод:

text
═══ три варианта проверки готовности ═══
  1. Проверка процесса (слабая):
       test: ["CMD-SHELL", "pg_isready"]
     Отвечает ли сервер вообще. Не проверяет вашу базу
     и вашего пользователя.
  ...
═══ что проверяет каждая — измерение ═══
  1 с: pg_isready=нет    запрос SELECT 1=нет
  2 с: pg_isready=готов  запрос SELECT 1=нет
  3 с: pg_isready=готов  запрос SELECT 1=готов
═══ принцип ═══
  Проверка готовности должна выполнять то же действие,
  что и приложение.
  ...

Строка «2 с» — то, ради чего сделано измерение: pg_isready уже говорит «готов», а запрос ещё не проходит. Секунда разницы — достаточно, чтобы тест упал.

Изоляция: сравнение стратегий

bash
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

Ожидаемый вывод:

text
═══ сравнение стратегий ═══
  стратегия               цена/тест     на 100 тестов   параллель
  ────────────────────────────────────────────────────────────────
  Откат транзакции              1 мс           0.1 с         нет
  TRUNCATE таблиц              10 мс           1.0 с         нет
  Схема на тест                25 мс           2.5 с          да
  Пересоздание базы           800 мс          80.0 с          да

  Откат транзакции
    ломается когда: код вызывает commit сам или берёт своё соединение из пула
    применять если: тестируемый код не управляет транзакциями
  ...
  Схема на тест — единственная стратегия с настоящей параллельностью.
  При запуске в 4 процесса её 25 с превращаются в ~7 с,
  и она обгоняет TRUNCATE, который параллелить нельзя.

Последние строки — главный довод. Сравнивать стратегии по цене одного теста недостаточно: возможность параллельного запуска меняет итог.

Проверка изоляции на деле

bash
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

Ожидаемый вывод:

text
═══ тест, проверяющий изоляцию ═══
  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
  порядок не гарантирует и может его менять.
  ...

Семь тестов с изоляцией проходят; два теста без неё дают падение. Это и есть проверка того, что фикстура действительно изолирует, а не просто существует.

Гарантированная очистка

bash
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

Ожидаемый вывод:

text
═══ успешный прогон ═══
  проект: 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.

Требования:

  1. Написать compose.test.yaml с PostgreSQL и получить код возврата тестов.
  2. Показать, что без --exit-code-from упавшие тесты дают код 0.
  3. Заменить sleep ожиданием условия и измерить разницу во времени.
  4. Показать, что слабая проверка готовности проходит раньше, чем база готова принять запрос.
  5. Реализовать изоляцию тестов и доказать её тестом, который без изоляции падает.
  6. Ускорить базу переводом в tmpfs и отключением сохранности; измерить выигрыш.
  7. Гарантировать очистку при любом исходе и проверить это прерыванием.

Подсказки

Подсказка 1

Для пункта 4 опрашивайте оба варианта проверки в цикле раз в секунду и печатайте оба результата.

Подсказка 2

Пункт 5: добавьте тест, который проверяет, что таблица пуста. Без изоляции он упадёт после теста, который пишет данные.

Подсказка 3

Для пункта 7 запустите скрипт в фоне и пошлите ему SIGINT через несколько секунд.

Решение

Показать решение
bash
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"

Ожидаемый вывод:

text
═══ Подготовка: сборка образа ═══
    образ собран

═══ Требование 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 измерен на семи тестах — на наборе из сотен разница была бы заметно больше, поскольку она накапливается на каждой операции записи.

Проверка результата

bash
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 быстрее, сохранность не нужна

Контрольные вопросы

На понимание:

  1. Почему docker compose up возвращает 0 при упавших тестах?
  2. Зачем --abort-on-container-exit, если есть --exit-code-from?
  3. Почему pg_isready без параметров — слабая проверка готовности?
  4. Какая стратегия изоляции даёт настоящую параллельность и чем платит?
  5. Почему fsync=off допустим для тестовой базы и недопустим для рабочей?

На применение:

  1. Как гарантировать очистку окружения при прерывании прогона?
  2. Как доказать, что изоляция тестов действительно работает?
  3. Как запускать несколько прогонов параллельно без конфликтов?

На диагностику:

  1. Тест проходит при полном прогоне и падает при запуске в одиночку. Причина?
  2. В CI тесты иногда падают на подключении к базе, локально — никогда. Гипотеза?

Краткое резюме

  1. docker compose up возвращает код своей работы, а не код тестов.
  2. --exit-code-from tests вместе с --abort-on-container-exit отдаёт настоящий код.
  3. docker compose run --rm tests — более простая альтернатива с тем же результатом.
  4. sleep плох дважды: долго в обычном случае и мало в плохом.
  5. depends_on: condition: service_healthy стартует тесты ровно при готовности базы.
  6. Проверка готовности должна делать то же, что приложение: подключаться и выполнять запрос.
  7. pg_isready может сказать «готов» на секунду раньше, чем запрос начнёт проходить.
  8. Изоляция схемой — единственная стратегия с настоящей параллельностью.
  9. Изоляцию нужно доказывать тестом, который без неё падает.
  10. tmpfs плюс fsync=off заметно ускоряют тестовую базу и недопустимы для рабочей.
  11. trap cleanup EXIT INT TERM гарантирует очистку при любом исходе.
  12. Уникальное имя проекта (-p) изолирует параллельные прогоны.

Официальные источники

ИсточникСсылкаЧто подтверждает
Compose: uphttps://docs.docker.com/reference/cli/docker/compose/up/--abort-on-container-exit, --exit-code-from
Compose: runhttps://docs.docker.com/reference/cli/docker/compose/run/Разовое выполнение команды
Compose: depends_onhttps://docs.docker.com/reference/compose-file/services/#depends_oncondition: service_healthy
Compose: tmpfshttps://docs.docker.com/reference/compose-file/services/#tmpfsДанные в памяти
Compose: downhttps://docs.docker.com/reference/cli/docker/compose/down/Флаги -v, --remove-orphans
PostgreSQL: образhttps://hub.docker.com/_/postgresИнициализация, переменные окружения
PostgreSQL: non-durable settingshttps://www.postgresql.org/docs/current/non-durability.htmlfsync, synchronous_commit, full_page_writes
PostgreSQL: pg_isreadyhttps://www.postgresql.org/docs/current/app-pg-isready.htmlПараметры проверки

Навигация

← Предыдущий материал
Вернуться к разделу
Следующий материал → Testcontainers for Python
Главное оглавление

Markdown на GitHub ↗