Главная/Python внутри Container/Урок

6.12. Тестирование в container

Цели

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

  • объяснить, что даёт запуск тестов внутри образа по сравнению с запуском на хосте;
  • вынести тесты в отдельную стадию, не попадающую в production-образ;
  • знать главную ловушку: стадия с тестами по умолчанию не выполняется при сборке;
  • понимать, почему RUN pytest может не запуститься повторно, и как это обойти;
  • различать unit-тесты в сборке и integration-тесты в Compose;
  • извлекать отчёты о покрытии из процесса сборки;
  • диагностировать exit code 5 («тесты не найдены»).

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

Рабочий пример — resources/examples/pytest-container/.

Углублённо тема разбирается в разделе 15. Тестирование; здесь — Python-специфичная часть.

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

ТерминОбъяснение
стадия testСтадия сборки, единственный смысл которой — прогнать тесты
--targetФлаг docker build: собрать до указанной стадии
--no-cache-filterФлаг: игнорировать кэш для перечисленных стадий
smoke testМинимальная проверка, что собранный образ вообще работает
exit code 5Код pytest: тесты не собраны

Теория

Зачем запускать тесты внутри образа

Тесты на хосте отвечают на вопрос «работает ли код у меня». Тесты в образе отвечают на вопрос «работает ли код там, где он поедет в production». Это разные вопросы, и расхождения между ответами вполне реальны.

Различие хоста и образаКак проявляется
Другая версия Python3.12 на хосте, 3.13 в образе — разное поведение
Другой libcХост glibc, образ Alpine с musl — другие колёса (урок 6.1)
Другие версии зависимостейНа хосте разрешение зависимостей было полгода назад
Другие системные библиотекиlibpq, libssl есть на хосте, но забыты в образе
Другая локаль и часовой поясТесты форматирования дат проходят локально
Другой пользовательНа хосте тесты идут от вашего UID, в образе — от 10001

Последнее — частый источник расхождения: тест пишет во временный каталог, на хосте это работает, а в образе USER 10001 не может писать туда, куда рассчитывает код (урок 6.7).

Тесты в образе не заменяют тесты на хосте: локальный прогон быстрее и удобнее для разработки. Они дают дополнительную гарантию перед публикацией.

Тесты как стадия сборки

Базовая конструкция:

dockerfile
FROM builder AS test
COPY requirements-dev.txt .
RUN pip install -r requirements-dev.txt
COPY tests/ ./tests/
RUN pytest              # ненулевой код останавливает сборку

Механизм прост: RUN считает ненулевой exit code ошибкой и прерывает сборку. Падение теста означает, что образ не будет собран.

Свойства подхода:

СвойствоЗначение
Тестовые зависимости в финальном образеНет: стадия test не копируется в runtime
Результат тестовУспех или провал сборки
Окружение тестовРовно то, в котором поедет приложение
Требуется ли CIНет: работает и локально

Главная ловушка: стадия не выполняется

BuildKit не собирает стадии, от которых не зависит цель сборки (урок 5.6).

Из этого следует неочевидное и опасное:

bash
docker build -t app .      # стадия test НЕ выполнится

Если runtime копирует из builder, а не из test, стадия test не входит в граф сборки цели. Образ соберётся с падающими тестами, и никакого сообщения об этом не будет.

Это не ошибка BuildKit, а его достоинство — параллельность и пропуск ненужного. Но конфигурация «тесты в Dockerfile» без учёта этого свойства даёт ложное чувство защищённости.

Три способа получить гарантию:

СпособКакОценка
Явный вызовdocker build --target test ., затем обычная сборкаРекомендуется. Явно, работает везде
Искусственная зависимостьCOPY --from=test /app/.passed /tmp/ в runtimeТесты обязательны, но цена — потеря контроля
Цель по умолчанию — testПоставить test последней стадиейЛомает docker build -t app . для production

Рекомендуемый порядок в CI и локально:

bash
docker build --target test -t app:test .     # тесты; падение здесь останавливает всё
docker build --target runtime -t app .       # образ; стадии переиспользуются из кэша

Второй вызов дешёвый: общие стадии уже в кэше.

Вторая ловушка: тесты не перезапускаются

RUN pytest — обычный слой, и он кэшируется. Если предыдущие слои не изменились, BuildKit возьмёт результат из кэша и тесты не выполнятся.

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

СитуацияПочему кэш вводит в заблуждение
Тест зависит от текущей датыВчера проходил, сегодня упал бы
Тест обращается к внешнему сервисуСостояние сервиса изменилось
Тест нестабилен (flaky)Кэш зафиксировал удачный прогон
Нужен свежий прогон перед релизомХочется убедиться здесь и сейчас

Принудительный перезапуск только стадии тестов:

bash
docker build --target test --no-cache-filter test -t app:test .

Флаг --no-cache-filter принимает имена стадий: остальные стадии берутся из кэша, тесты выполняются заново. Это заметно быстрее, чем --no-cache для всей сборки.

Что должно попасть в образ, а что нет

Тесты нужны в стадии test и не нужны в runtime.

text
.dockerignore
├── исключает: .git, .venv, __pycache__, .pytest_cache
└── НЕ исключает: tests/  ← иначе COPY tests/ упадёт

Типичная ошибка — исключить tests/ в .dockerignore «чтобы не попали в образ». Тесты и не попадут: их не копирует стадия runtime. А стадия test при этом сломается (урок 5.1).

ЧтоВ .dockerignoreВ стадии testВ стадии runtime
src/нетдада
tests/нетданет
requirements-dev.txtнетданет
.pytest_cache/, .venv/, __pycache__/данетнет

Exit codes pytest

Результат сборки определяется кодом возврата, поэтому его значения нужно знать:

КодЗначениеТипичная причина в container
0Все тесты прошли
1Есть упавшие тестыСобственно провал
2Прогон прерванSIGINT, ошибка в conftest.py
3Внутренняя ошибкаПроблема плагина
4Ошибка использованияНеверный аргумент командной строки
5Тесты не собраныКаталог не скопирован, неверный testpaths

Код 5 заслуживает отдельного внимания: сборка падает, хотя ни один тест не упал. Причина почти всегда в том, что pytest не нашёл файлов — неправильный рабочий каталог, tests/ не скопирован, или имена файлов не соответствуют шаблону test_*.py.

Обратная и более опасная ситуация: если в конфигурации задано игнорирование кода 5, отсутствие тестов будет выглядеть как успешная сборка.

Три уровня тестирования

Не всё тестируется в стадии сборки. Разделение по уровням:

УровеньГде выполняетсяЧто проверяетИнструмент
UnitСтадия сборкиЛогику без внешних зависимостейRUN pytest
IntegrationComposeВзаимодействие с базой, очередьюdocker compose run
Smoke образаПосле сборкиЧто образ запускается и отвечаетdocker run плюс проверка

Причина разделения — сеть. Стадия сборки не имеет доступа к сервисам Compose: они ещё не запущены, а сборка изолирована. Тестам, которым нужна база, место в Compose.

Smoke-тест проверяет то, что не может проверить ни один тест внутри стадии: правильность CMD, USER, EXPOSE, HEALTHCHECK — то есть конфигурацию самого образа.

bash
docker run -d --name smoke -p 8000:8000 app
sleep 5
curl -fsS localhost:8000/healthz     # -f делает HTTP-ошибку ненулевым кодом
docker exec smoke id -u              # ожидаем 10001, не 0

Integration-тесты в Compose

Отдельный сервис, зависящий от инфраструктуры:

yaml
services:
  db:
    image: postgres:17-alpine
    environment:
      POSTGRES_PASSWORD: test
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U postgres"]
      interval: 3s
      retries: 10

  tests:
    build:
      context: .
      target: test          # образ с тестовыми зависимостями
    command: ["pytest", "tests/integration", "-v"]
    environment:
      DATABASE_URL: "postgresql://postgres:test@db:5432/postgres"
    depends_on:
      db:
        condition: service_healthy

Запуск с передачей кода возврата наружу:

bash
docker compose run --rm tests
echo "код: $?"

docker compose run возвращает код завершения container — это делает его пригодным для CI. Альтернатива для запуска всего стека сразу:

bash
docker compose up --abort-on-container-exit --exit-code-from tests

Здесь код tests становится кодом всей команды, а остальные сервисы останавливаются, как только тесты закончились.

Ключевое требование — condition: service_healthy: без него тесты стартуют раньше, чем база примет соединения (урок 9.5).

Извлечение отчётов из сборки

Отчёт о покрытии создаётся внутри стадии test и по умолчанию остаётся там. Способ достать его наружу — экспорт содержимого стадии:

dockerfile
FROM scratch AS test-report
COPY --from=test /app/htmlcov /htmlcov
COPY --from=test /app/coverage.xml /coverage.xml
bash
docker build --target test-report --output type=local,dest=./reports .

Флаг --output записывает результат стадии в каталог хоста вместо образа. FROM scratch гарантирует, что выгрузится только то, что скопировано явно.

Альтернатива, если сборка и так запускается через Compose или скрипт, — примонтировать каталог в docker run и запустить тесты в уже собранном образе app:test.


Внутренний механизм

Почему стадия пропускается

BuildKit строит граф зависимостей стадий по инструкциям FROM ... AS и COPY --from=. Затем от целевой стадии обходит граф назад и собирает только достижимые вершины.

text
runtime ──COPY --from──▶ builder ──▶ base
   │
   └── test недостижима из runtime → не собирается

Добавление COPY --from=test в runtime делает test достижимой — на этом основан второй способ из таблицы выше.

Что кэширует RUN pytest

Ключ кэша слоя — хеш предыдущего слоя плюс строка команды. Содержимое тестов влияет на ключ косвенно, через слой COPY tests/: изменение файла меняет его хеш, а значит и все последующие слои.

Отсюда практическое следствие: COPY tests/ должен идти после установки зависимостей. Тогда правка теста не приводит к переустановке пакетов.


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

Рабочий пример

bash
cd resources/examples/pytest-container
docker build --target test -t calc:test .

Ожидаемый вывод (фрагмент):

text
 => [test 4/4] RUN pytest --cov=calc --cov-report=term-missing
#12 1.834 ...........                                       [100%]
#12 1.902 Name                  Stmts   Miss Branch BrPart  Cover
#12 1.902 -----------------------------------------------------------
#12 1.902 src/calc/core.py         14      0      8      0   100%
#12 1.902 -----------------------------------------------------------
#12 1.902 TOTAL                    14      0      8      0   100%
#12 1.902 Required test coverage of 90% reached.
 => exporting to image

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

Демонстрация главной ловушки

bash
mkdir -p /tmp/tst/src /tmp/tst/tests && cd /tmp/tst

cat > src/lib.py <<'PY'
def add(a: int, b: int) -> int:
    return a + b
PY

cat > tests/test_lib.py <<'PY'
import sys

sys.path.insert(0, "/app/src")

from lib import add


def test_add_is_broken():
    # Заведомо провальный тест
    assert add(2, 2) == 5
PY

cat > Dockerfile <<'EOF'
# syntax=docker/dockerfile:1
FROM python:3.13-slim AS base
WORKDIR /app
COPY src/ ./src/

FROM base AS test
RUN pip install --no-cache-dir pytest==9.1.1
COPY tests/ ./tests/
RUN pytest -q tests/

FROM base AS runtime
CMD ["python", "-c", "import sys; sys.path.insert(0,'/app/src'); from lib import add; print(add(2,2))"]
EOF

echo "═══ обычная сборка ═══"
docker build -q -t trap . && echo "  ОБРАЗ СОБРАН — тесты не выполнялись!"
docker run --rm trap

echo
echo "═══ явная сборка стадии test ═══"
docker build --target test -t trap:test . 2>&1 | grep -E "assert|FAILED|ERROR|failed" | head -3
echo "  код сборки: $?"

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

text
═══ обычная сборка ═══
  ОБРАЗ СОБРАН — тесты не выполнялись!
4

═══ явная сборка стадии test ═══
E       assert 4 == 5
FAILED tests/test_lib.py::test_add_is_broken
1 failed in 0.06s
  код сборки: 0

Первый блок — суть ловушки: тест заведомо провален, но docker build -t trap . завершился успешно и выдал рабочий образ. Стадия test недостижима из runtime, поэтому BuildKit её пропустил.

Обратите внимание на последнюю строку: код сборки: 0 — это код grep, а не docker build. Проверять нужно код самой сборки:

bash
docker build --target test -t trap:test . > /dev/null 2>&1
echo "код docker build: $?"

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

text
код docker build: 1

Вот он, настоящий результат. В CI проверять нужно именно его.

Способ 2: сделать тесты обязательными

bash
cat > Dockerfile.forced <<'EOF'
# syntax=docker/dockerfile:1
FROM python:3.13-slim AS base
WORKDIR /app
COPY src/ ./src/

FROM base AS test
RUN pip install --no-cache-dir pytest==9.1.1
COPY tests/ ./tests/
# Маркер создаётся только если pytest вернул 0
RUN pytest -q tests/ && touch /app/.tests-passed

FROM base AS runtime
# Эта строка делает стадию test достижимой — она обязана выполниться
COPY --from=test /app/.tests-passed /app/.tests-passed
CMD ["python", "-c", "import sys; sys.path.insert(0,'/app/src'); from lib import add; print(add(2,2))"]
EOF

echo "═══ обычная сборка с обязательными тестами ═══"
docker build -q -f Dockerfile.forced -t forced . > /dev/null 2>&1
echo "  код docker build: $?"

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

text
═══ обычная сборка с обязательными тестами ═══
  код docker build: 1

Теперь docker build без --target падает: runtime зависит от test, значит стадия обязана выполниться.

Приём работает, но у него есть цена. Собрать production-образ без прогона тестов становится невозможно — а это иногда нужно: например, при отладке самой сборки или при выпуске hotfix'а, когда тесты чинятся отдельно. Плюс в финальном образе появляется бессмысленный файл .tests-passed.

Поэтому рекомендация остаётся прежней: явный --target test отдельным шагом. Приём с маркером — для случаев, когда контроль над командой сборки вам не принадлежит.

Кэш прячет прогон тестов

bash
cd /tmp/tst
cat > tests/test_lib.py <<'PY'
import sys
import time

sys.path.insert(0, "/app/src")

from lib import add


def test_add():
    time.sleep(2)          # чтобы прогон был заметен по времени
    assert add(2, 2) == 4
PY

echo "═══ первая сборка ═══"
s="$(date +%s.%N)"; docker build --target test -q -t c1 . > /dev/null; e="$(date +%s.%N)"
printf '  %.1f c\n' "$(awk -v a="$s" -v b="$e" 'BEGIN{print b-a}')"

echo "═══ повторная сборка: ничего не менялось ═══"
s="$(date +%s.%N)"; docker build --target test -q -t c1 . > /dev/null; e="$(date +%s.%N)"
printf '  %.1f c  ← тесты НЕ выполнялись\n' "$(awk -v a="$s" -v b="$e" 'BEGIN{print b-a}')"

echo "═══ повторная сборка с --no-cache-filter test ═══"
s="$(date +%s.%N)"; docker build --target test --no-cache-filter test -q -t c1 . > /dev/null; e="$(date +%s.%N)"
printf '  %.1f c  ← тесты выполнены заново\n' "$(awk -v a="$s" -v b="$e" 'BEGIN{print b-a}')"

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

text
═══ первая сборка ═══
  8.4 c
═══ повторная сборка: ничего не менялось ═══
  0.3 c  ← тесты НЕ выполнялись
═══ повторная сборка с --no-cache-filter test ═══
  4.1 c  ← тесты выполнены заново

Разница между 0.3 и 4.1 секунды — это и есть прогон тестов. Во втором случае его не было: BuildKit взял слой из кэша.

Третья сборка быстрее первой, потому что установка pytest осталась в кэше — --no-cache-filter сбросил только указанную стадию.

Exit code 5: тесты не найдены

bash
cat > Dockerfile.empty <<'EOF'
# syntax=docker/dockerfile:1
FROM python:3.13-slim
WORKDIR /app
RUN pip install --no-cache-dir pytest==9.1.1
RUN mkdir -p tests
RUN pytest -q tests/ ; echo "код pytest: $?"
EOF

docker build -f Dockerfile.empty -t empty . 2>&1 | grep -E "no tests ran|код pytest"

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

text
no tests ran in 0.01s
код pytest: 5

Код 5 при пустом каталоге. В обычной стадии test (без ; echo) это остановило бы сборку — и это правильное поведение: «тестов нет» не должно выглядеть как «тесты прошли».

Частая причина в реальных проектах — tests/ в .dockerignore:

bash
cd /tmp/tst
echo "tests/" > .dockerignore
docker build --target test -t dockerignore-trap . 2>&1 | tail -3
rm -f .dockerignore

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

text
ERROR: failed to solve: failed to compute cache key: failed to calculate
checksum of ref ...: "/tests": not found

Здесь сборка падает явно — это удача. Хуже, если .dockerignore исключает часть файлов: тогда pytest найдёт меньше тестов и молча сообщит об успехе.

Проверка, что именно попало в контекст:

bash
docker build --target test --progress plain --no-cache -t chk . 2>&1 | \
    grep -E "passed|failed|no tests ran"

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

text
1 passed in 2.02s

Число тестов в выводе — самая простая защита от такого рода потерь: если тестов стало меньше без изменений в коде, что-то исключено из контекста.

Тестовые зависимости не попадают в production

bash
cd resources/examples/pytest-container
docker build -q --target test -t calc:test . > /dev/null
docker build -q --target runtime -t calc:prod . > /dev/null

echo "═══ pytest внутри образов ═══"
printf '  calc:test  → '
docker run --rm calc:test sh -c 'pytest --version 2>&1 | head -1'
printf '  calc:prod  → '
docker run --rm calc:prod sh -c 'pytest --version 2>&1 | head -1 || echo "не установлен"'

echo
echo "═══ размеры ═══"
docker images --format '  {{.Repository}}:{{.Tag}}  {{.Size}}' | grep '^  calc:'

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

text
═══ pytest внутри образов ═══
  calc:test  → pytest 9.1.1
  calc:prod  → sh: 1: pytest: not found
не установлен

═══ размеры ═══
  calc:test  195MB
  calc:prod  148MB

47 MB разницы — это pytest, pytest-cov, ruff, coverage и их зависимости. Не гигантская экономия, но эти пакеты в production-образе не только занимают место: каждый из них — дополнительная поверхность для уязвимостей (раздел 12).

Экспорт отчёта о покрытии

bash
cd /tmp/tst
mkdir -p src tests
cat > Dockerfile.report <<'EOF'
# syntax=docker/dockerfile:1
FROM python:3.13-slim AS base
WORKDIR /app
COPY src/ ./src/

FROM base AS test
RUN pip install --no-cache-dir pytest==9.1.1 pytest-cov==7.1.0
COPY tests/ ./tests/
RUN pytest -q tests/ \
      --cov=/app/src --cov-report=xml:/app/coverage.xml \
      --cov-report=html:/app/htmlcov

# Стадия-экспортёр: только файлы отчёта, ничего больше
FROM scratch AS report
COPY --from=test /app/coverage.xml /coverage.xml
COPY --from=test /app/htmlcov /htmlcov
EOF

rm -rf ./reports
docker build -f Dockerfile.report --target report --output type=local,dest=./reports . > /dev/null 2>&1
find ./reports -maxdepth 2 -name "*.xml" -o -maxdepth 2 -name "index.html" | sort | sed 's/^/  /'

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

text
  ./reports/coverage.xml
  ./reports/htmlcov/index.html

Отчёт на хосте, при этом в образ он не попал. В CI такой каталог передают в систему отображения покрытия.

Smoke-тест образа

Проверка того, что тесты внутри стадии проверить не могут:

bash
cd resources/examples/fastapi-basic
docker build -q -t sm . > /dev/null

fail=0
check() {
    if [ "$2" = "$3" ]; then
        printf '  ✓ %-28s %s\n' "$1" "$2"
    else
        printf '  ✗ %-28s получено %s, ожидалось %s\n' "$1" "$2" "$3"
        fail=1
    fi
}

docker run -d --name sm-t -p 8000:8000 sm > /dev/null
sleep 8

check "пользователь не root"  "$(docker run --rm sm id -u)" "10001"
check "healthz отвечает"      "$(curl -s -o /dev/null -w '%{http_code}' localhost:8000/healthz)" "200"
check "healthcheck настроен"  "$(docker inspect sm --format '{{if .Config.Healthcheck}}да{{else}}нет{{end}}')" "да"
check "CMD в exec form"       "$(docker inspect sm --format '{{index .Config.Cmd 0}}')" "fastapi"

docker stop sm-t > /dev/null
check "код выхода после stop" "$(docker inspect sm-t --format '{{.State.ExitCode}}')" "0"

docker rm sm-t > /dev/null; docker rmi -f sm > /dev/null
echo "  результат: $([ $fail -eq 0 ] && echo "все проверки пройдены" || echo "ЕСТЬ ОШИБКИ")"
exit $fail

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

text
  ✓ пользователь не root      10001
  ✓ healthz отвечает          200
  ✓ healthcheck настроен      да
  ✓ CMD в exec form           fastapi
  ✓ код выхода после stop     0
  результат: все проверки пройдены

Каждая из этих пяти проверок ловит ошибку конфигурации образа, невидимую для pytest внутри стадии сборки: забытый USER, неверный порт, отсутствующий HEALTHCHECK, shell form в CMD (урок 5.4).

Скрипт возвращает ненулевой код при провале — это делает его пригодным для CI.

Уборка

bash
cd /tmp
docker rmi -f trap trap:test forced c1 empty chk calc:test calc:prod dockerignore-trap 2>/dev/null || true
rm -rf /tmp/tst

Практическое упражнение

Задание. Постройте конвейер тестирования для Python-приложения, отвечающий шести требованиям.

  1. Unit-тесты выполняются в стадии сборки; провал останавливает сборку.
  2. Порог покрытия соблюдается; недобор останавливает сборку так же, как провал теста.
  3. Тестовые зависимости отсутствуют в production-образе — доказать.
  4. Отчёт о покрытии извлекается на хост, не попадая в образ.
  5. Smoke-тест проверяет конфигурацию собранного образа и возвращает ненулевой код при провале.
  6. Один скрипт run-tests.sh выполняет всё и возвращает корректный код.

Отдельно ответьте: что произойдёт при docker build -t app . без --target и почему.

Подсказки

Подсказка 1

Порог покрытия задаётся в pyproject.toml (fail_under) или флагом --cov-fail-under. И то и другое даёт ненулевой код.

Подсказка 2

Для требования 3 не полагайтесь на размер образа: проверьте отсутствие самой команды.

Подсказка 3

set -e в скрипте прервёт выполнение на первой ошибке, но нужно ещё вернуть код наружу.

Решение

Сначала выполните задание самостоятельно.

Показать решение
bash
mkdir -p /tmp/pipe/src/svc /tmp/pipe/tests && cd /tmp/pipe

cat > src/svc/__init__.py <<'PY'
"""Сервис-пример."""
PY

cat > src/svc/pricing.py <<'PY'
"""Расчёт цены с налогом и скидкой."""
from __future__ import annotations

TAX_RATES = {"standard": 0.20, "reduced": 0.10, "zero": 0.0}


def tax_rate(category: str) -> float:
    try:
        return TAX_RATES[category]
    except KeyError:
        raise ValueError(f"неизвестная категория налога: {category}") from None


def total(price: float, category: str = "standard", discount: float = 0.0) -> float:
    if price < 0:
        raise ValueError("цена не может быть отрицательной")
    if not 0 <= discount <= 100:
        raise ValueError("скидка должна быть в диапазоне 0–100")
    discounted = price * (1 - discount / 100)
    return round(discounted * (1 + tax_rate(category)), 2)
PY

cat > src/svc/main.py <<'PY'
"""Точка входа: печатает пример расчёта."""
from .pricing import total


def main() -> int:
    print(f"итого: {total(100, 'standard', 10)}")
    return 0


if __name__ == "__main__":
    raise SystemExit(main())
PY

cat > tests/test_pricing.py <<'PY'
import pytest

from svc.pricing import tax_rate, total


@pytest.mark.parametrize(
    ("category", "expected"),
    [("standard", 0.20), ("reduced", 0.10), ("zero", 0.0)],
)
def test_tax_rate(category, expected):
    assert tax_rate(category) == expected


def test_tax_rate_unknown():
    with pytest.raises(ValueError, match="неизвестная категория"):
        tax_rate("нет такой")


def test_total_with_tax():
    assert total(100, "standard") == 120.0


def test_total_with_discount():
    assert total(100, "standard", 10) == 108.0


def test_total_zero_tax():
    assert total(100, "zero") == 100.0


def test_total_negative_price():
    with pytest.raises(ValueError, match="отрицательной"):
        total(-1)


@pytest.mark.parametrize("discount", [-1, 101])
def test_total_bad_discount(discount):
    with pytest.raises(ValueError, match="диапазоне"):
        total(100, "standard", discount)
PY

cat > pyproject.toml <<'EOF'
[project]
name = "svc"
version = "1.0.0"
requires-python = ">=3.11"
dependencies = []

[build-system]
requires = ["setuptools>=77"]
build-backend = "setuptools.build_meta"

[tool.setuptools.packages.find]
where = ["src"]

[tool.pytest.ini_options]
testpaths = ["tests"]
addopts = "-q --strict-markers"

[tool.coverage.run]
source = ["svc"]
branch = true

[tool.coverage.report]
show_missing = true
# Требование 2: недобор покрытия — ошибка
fail_under = 95
EOF

cat > requirements-dev.txt <<'EOF'
pytest==9.1.1
pytest-cov==7.1.0
EOF

cat > .dockerignore <<'EOF'
.git
.venv
__pycache__
*.py[cod]
.pytest_cache
.coverage
htmlcov
reports
Dockerfile
.dockerignore
run-tests.sh
EOF

cat > Dockerfile <<'EOF'
# syntax=docker/dockerfile:1

FROM python:3.13-slim AS base
ENV PYTHONUNBUFFERED=1 \
    PYTHONDONTWRITEBYTECODE=1 \
    PATH="/opt/venv/bin:$PATH"
WORKDIR /app
RUN useradd --create-home --uid 10001 appuser

FROM base AS builder
RUN python -m venv /opt/venv
COPY pyproject.toml .
COPY src/ ./src/
RUN --mount=type=cache,target=/root/.cache/pip pip install .

# ── Требования 1 и 2 ──
FROM builder AS test
COPY requirements-dev.txt .
RUN --mount=type=cache,target=/root/.cache/pip pip install -r requirements-dev.txt
# tests/ копируется последним: правка теста не переустанавливает пакеты
COPY tests/ ./tests/
RUN pytest --cov=svc \
      --cov-report=term-missing \
      --cov-report=xml:/app/coverage.xml \
      --cov-report=html:/app/htmlcov

# ── Требование 4: экспорт отчёта, в образ не попадает ──
FROM scratch AS report
COPY --from=test /app/coverage.xml /coverage.xml
COPY --from=test /app/htmlcov /htmlcov

# ── Требование 3: production без тестовых зависимостей ──
FROM base AS runtime
COPY --from=builder --chown=appuser:appuser /opt/venv /opt/venv
USER 10001:10001
CMD ["python", "-m", "svc.main"]
EOF

cat > run-tests.sh <<'SH'
#!/usr/bin/env bash
# Требование 6: единая точка запуска, корректный код возврата.
set -euo pipefail

IMAGE="${IMAGE:-svc}"
FRESH="${FRESH:-0}"          # FRESH=1 — прогнать тесты в обход кэша

step() { printf '\n═══ %s ═══\n' "$1"; }

step "1–2. Unit-тесты и покрытие"
cache_args=()
[ "$FRESH" = "1" ] && cache_args=(--no-cache-filter test)
docker build --target test "${cache_args[@]}" -t "${IMAGE}:test" . \
    --progress plain 2>&1 | grep -E "passed|failed|TOTAL|Required|Coverage failure" || true
# grep в конвейере скрыл бы код сборки — повторяем проверку явно
docker build --target test "${cache_args[@]}" -q -t "${IMAGE}:test" . > /dev/null

step "4. Экспорт отчёта"
rm -rf ./reports
docker build --target report --output type=local,dest=./reports -q . > /dev/null
find ./reports -maxdepth 2 -name "coverage.xml" -o -maxdepth 2 -name "index.html" | sed 's/^/  /'

step "Сборка production-образа"
docker build --target runtime -q -t "$IMAGE" . > /dev/null
echo "  собран: $IMAGE"

step "3. Тестовых зависимостей нет в production"
if docker run --rm "$IMAGE" sh -c 'command -v pytest' > /dev/null 2>&1; then
    echo "  ✗ pytest найден в production-образе"
    exit 1
fi
echo "  ✓ pytest отсутствует"
if docker run --rm "$IMAGE" sh -c 'test -d /app/tests' > /dev/null 2>&1; then
    echo "  ✗ каталог tests/ попал в образ"
    exit 1
fi
echo "  ✓ tests/ отсутствует"

step "5. Smoke-тест образа"
fail=0
check() {
    if [ "$2" = "$3" ]; then
        printf '  ✓ %-26s %s\n' "$1" "$2"
    else
        printf '  ✗ %-26s получено %s, ожидалось %s\n' "$1" "$2" "$3"
        fail=1
    fi
}
check "пользователь"     "$(docker run --rm "$IMAGE" id -u)" "10001"
check "приложение работает" "$(docker run --rm "$IMAGE" | tr -d '\r')" "итого: 108.0"
check "CMD в exec form"  "$(docker inspect "$IMAGE" --format '{{index .Config.Cmd 0}}')" "python"
[ "$fail" -eq 0 ] || { echo "  smoke-тест провален"; exit 1; }

step "Итог"
echo "  все проверки пройдены"
SH
chmod +x run-tests.sh

./run-tests.sh
echo "КОД ВОЗВРАТА СКРИПТА: $?"

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

text
═══ 1–2. Unit-тесты и покрытие ═══
#14 2.104 ..........                                            [100%]
#14 2.180 src/svc/pricing.py       12      0      6      0   100%
#14 2.180 TOTAL                    14      0      6      0   100%
#14 2.180 Required test coverage of 95% reached.

═══ 4. Экспорт отчёта ═══
  ./reports/coverage.xml
  ./reports/htmlcov/index.html

═══ Сборка production-образа ═══
  собран: svc

═══ 3. Тестовых зависимостей нет в production ═══
  ✓ pytest отсутствует
  ✓ tests/ отсутствует

═══ 5. Smoke-тест образа ═══
  ✓ пользователь               10001
  ✓ приложение работает        итого: 108.0
  ✓ CMD в exec form            python
  ═══ Итог ═══
  все проверки пройдены
КОД ВОЗВРАТА СКРИПТА: 0

Проверим, что конвейер действительно ломается при провале:

bash
cd /tmp/pipe
# Требование 1: ломаем тест
sed -i 's/assert total(100, "standard") == 120.0/assert total(100, "standard") == 999.0/' tests/test_pricing.py
./run-tests.sh > /dev/null 2>&1
echo "провальный тест → код: $?"
sed -i 's/assert total(100, "standard") == 999.0/assert total(100, "standard") == 120.0/' tests/test_pricing.py

# Требование 2: поднимаем порог покрытия выше достижимого
sed -i 's/^fail_under = 95/fail_under = 101/' pyproject.toml
./run-tests.sh > /dev/null 2>&1
echo "недобор покрытия → код: $?"
sed -i 's/^fail_under = 101/fail_under = 95/' pyproject.toml

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

text
провальный тест → код: 1
недобор покрытия → код: 1

Оба требования подтверждены не утверждением, а проверкой.

bash
cd /tmp && docker rmi -f svc svc:test > /dev/null 2>&1; rm -rf /tmp/pipe

Ответ на отдельный вопрос. docker build -t app . без --target соберёт последнюю стадию — runtime. Она зависит только от builder, поэтому стадии test и report окажутся недостижимы в графе и не будут выполнены. Образ соберётся даже при полностью провальных тестах.

Именно поэтому run-tests.sh вызывает --target test явным отдельным шагом, а не полагается на то, что сборка «сама» прогонит тесты.

Три решения, определяющие качество.

Двойной вызов docker build --target test в скрипте. Выглядит избыточно, но необходим: первый вызов идёт через grep, а в конвейере кодом возврата становится код grep. Второй вызов — без конвейера — даёт настоящий код сборки и при set -e останавливает скрипт. Альтернатива — set -o pipefail плюс аккуратность с || true; явный повтор надёжнее, а стоит он ноль, потому что второй вызов полностью попадает в кэш.

Требование 3 проверяется через command -v pytest, а не по размеру образа. Размер — косвенный признак: он мог бы уменьшиться по другой причине, а pytest остаться. Проверка отсутствия конкретной команды отвечает ровно на заданный вопрос.

Переменная FRESH. По умолчанию тесты кэшируются — это правильно для частых локальных запусков. Перед релизом FRESH=1 ./run-tests.sh заставляет прогнать их заново. Умолчание выбрано в пользу скорости, потому что при изменении кода кэш и так инвалидируется.

Чего конвейер не делает. В нём нет integration-тестов: у приложения нет внешних зависимостей. При появлении базы понадобится отдельный сервис Compose с condition: service_healthy — стадия сборки к сервисам Compose доступа не имеет.

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

bash
cd resources/examples/pytest-container
docker build --target test -t calc:test . && echo "тесты прошли"
docker build --target runtime -t calc:prod . > /dev/null
docker run --rm calc:prod
docker run --rm calc:prod sh -c 'command -v pytest || echo "pytest отсутствует — верно"'
docker rmi -f calc:test calc:prod > /dev/null

Ожидается успешный прогон тестов, работающее приложение и отсутствие pytest в production-образе.

Типичные ошибки

ОшибкаПричинаИсправление
docker build -t app . в расчёте на прогон тестовНе учтено, что BuildKit пропускает недостижимые стадииОтдельный шаг --target test
Тесты «прошли», но не выполнялисьСлой RUN pytest взят из кэша--no-cache-filter test перед релизом
tests/ в .dockerignoreХотели не пустить тесты в образИх и так не копирует runtime; стадия test ломается
Тестовые зависимости в общем requirements.txtОдин файл прощеОтдельный requirements-dev.txt, ставится только в test
COPY tests/ до установки зависимостейПорядок не продуманПравка теста переустанавливает все пакеты
Игнорирование кода 5«Тестов нет — не страшно»Отсутствие тестов не должно выглядеть успехом
Проверка кода возврата после конвейера с grep$? содержит код последней командыОтдельный вызов или set -o pipefail
Integration-тесты в стадии сборкиКажется единообразнымСборка не имеет доступа к сервисам Compose
Тесты только на хостеБыстрее и привычнееНе проверяют окружение, в котором код поедет
Нет smoke-теста образаСчитают, что unit-тестов достаточноUSER, CMD, HEALTHCHECK они не проверяют

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

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

  1. Почему docker build -t app . может не выполнить стадию test?
  2. Что даёт запуск тестов в образе сверх запуска на хосте? Три различия.
  3. Почему RUN pytest иногда не перезапускается и как это исправить?
  4. Почему integration-тесты нельзя выполнять в стадии сборки?
  5. Что означает exit code 5 и почему его нельзя игнорировать?

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

  1. Как извлечь HTML-отчёт о покрытии из сборки, не помещая его в образ?
  2. Как доказать, что pytest отсутствует в production-образе?
  3. Как организовать тесты, которым нужен PostgreSQL?

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

  1. Сборка падает с "/tests": not found. Причина?
  2. Тесты проходят на хосте и падают в образе. Три версии.

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

  1. Тесты в образе проверяют то окружение, в котором код поедет в production.
  2. Стадия test в multi-stage не попадает в финальный образ вместе с зависимостями.
  3. BuildKit не выполняет стадии, недостижимые из цели сборкиdocker build -t app . тесты не прогонит.
  4. Надёжный способ — отдельный шаг docker build --target test.
  5. Приём с COPY --from=test делает тесты обязательными, но лишает возможности собрать образ без них.
  6. RUN pytest кэшируется; принудительный прогон — --no-cache-filter test.
  7. COPY tests/ идёт после установки зависимостей, иначе правка теста ломает кэш пакетов.
  8. Exit code 5 означает «тесты не собраны» и не должен считаться успехом.
  9. tests/ не исключают в .dockerignore — их не пускает в образ выбор стадии.
  10. Integration-тесты выполняются в Compose с condition: service_healthy, а не в сборке.
  11. Smoke-тест проверяет конфигурацию образа: USER, CMD, HEALTHCHECK, код выхода.
  12. Отчёт о покрытии выгружается стадией FROM scratch и --output type=local.

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

ИсточникСсылкаЧто подтверждает
Docker: multi-stage buildshttps://docs.docker.com/build/building/multi-stage/--target, пропуск неиспользуемых стадий
Docker: build cachehttps://docs.docker.com/build/cache/Инвалидация слоёв, порядок инструкций
Docker: --no-cache-filterhttps://docs.docker.com/reference/cli/docker/buildx/build/Сброс кэша для отдельных стадий
Docker: build exportershttps://docs.docker.com/build/exporters/local-tar/--output type=local для выгрузки файлов
Docker: test containers in buildhttps://docs.docker.com/build/building/best-practices/Тестирование как часть сборки
Compose: run и uphttps://docs.docker.com/reference/cli/docker/compose/run/Передача кода возврата, --abort-on-container-exit
pytest: exit codeshttps://docs.pytest.org/en/stable/reference/exit-codes.htmlЗначения 05
pytest-covhttps://pytest-cov.readthedocs.io/--cov-report, --cov-fail-under
coverage.py: configurationhttps://coverage.readthedocs.io/en/latest/config.htmlfail_under, branch

Навигация

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

Markdown на GitHub ↗