6.12. Тестирование в container
Цели
После этого материала вы сможете:
- объяснить, что даёт запуск тестов внутри образа по сравнению с запуском на хосте;
- вынести тесты в отдельную стадию, не попадающую в production-образ;
- знать главную ловушку: стадия с тестами по умолчанию не выполняется при сборке;
- понимать, почему
RUN pytestможет не запуститься повторно, и как это обойти; - различать unit-тесты в сборке и integration-тесты в Compose;
- извлекать отчёты о покрытии из процесса сборки;
- диагностировать exit code
5(«тесты не найдены»).
Предварительные знания
- 5.7. Multi-stage builds — стадии и
--target; - 5.5. Build cache — когда слой пересобирается;
- 6.2. Управление зависимостями.
Рабочий пример — resources/examples/pytest-container/.
Углублённо тема разбирается в разделе 15. Тестирование; здесь — Python-специфичная часть.
Ключевые термины
| Термин | Объяснение |
|---|---|
стадия test | Стадия сборки, единственный смысл которой — прогнать тесты |
--target | Флаг docker build: собрать до указанной стадии |
--no-cache-filter | Флаг: игнорировать кэш для перечисленных стадий |
smoke test | Минимальная проверка, что собранный образ вообще работает |
exit code 5 | Код pytest: тесты не собраны |
Теория
Зачем запускать тесты внутри образа
Тесты на хосте отвечают на вопрос «работает ли код у меня». Тесты в образе отвечают на вопрос «работает ли код там, где он поедет в production». Это разные вопросы, и расхождения между ответами вполне реальны.
| Различие хоста и образа | Как проявляется |
|---|---|
| Другая версия Python | 3.12 на хосте, 3.13 в образе — разное поведение |
| Другой libc | Хост glibc, образ Alpine с musl — другие колёса (урок 6.1) |
| Другие версии зависимостей | На хосте разрешение зависимостей было полгода назад |
| Другие системные библиотеки | libpq, libssl есть на хосте, но забыты в образе |
| Другая локаль и часовой пояс | Тесты форматирования дат проходят локально |
| Другой пользователь | На хосте тесты идут от вашего UID, в образе — от 10001 |
Последнее — частый источник расхождения: тест пишет во временный каталог, на хосте это работает, а в образе USER 10001 не может писать туда, куда рассчитывает код (урок 6.7).
Тесты в образе не заменяют тесты на хосте: локальный прогон быстрее и удобнее для разработки. Они дают дополнительную гарантию перед публикацией.
Тесты как стадия сборки
Базовая конструкция:
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).
Из этого следует неочевидное и опасное:
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 и локально:
docker build --target test -t app:test . # тесты; падение здесь останавливает всё
docker build --target runtime -t app . # образ; стадии переиспользуются из кэша
Второй вызов дешёвый: общие стадии уже в кэше.
Вторая ловушка: тесты не перезапускаются
RUN pytest — обычный слой, и он кэшируется. Если предыдущие слои не изменились, BuildKit возьмёт результат из кэша и тесты не выполнятся.
Обычно это желаемое поведение: код не менялся — перепроверять нечего. Но есть случаи, когда результат зависит не только от содержимого слоёв:
| Ситуация | Почему кэш вводит в заблуждение |
|---|---|
| Тест зависит от текущей даты | Вчера проходил, сегодня упал бы |
| Тест обращается к внешнему сервису | Состояние сервиса изменилось |
| Тест нестабилен (flaky) | Кэш зафиксировал удачный прогон |
| Нужен свежий прогон перед релизом | Хочется убедиться здесь и сейчас |
Принудительный перезапуск только стадии тестов:
docker build --target test --no-cache-filter test -t app:test .
Флаг --no-cache-filter принимает имена стадий: остальные стадии берутся из кэша, тесты выполняются заново. Это заметно быстрее, чем --no-cache для всей сборки.
Что должно попасть в образ, а что нет
Тесты нужны в стадии test и не нужны в runtime.
.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 |
| Integration | Compose | Взаимодействие с базой, очередью | docker compose run |
| Smoke образа | После сборки | Что образ запускается и отвечает | docker run плюс проверка |
Причина разделения — сеть. Стадия сборки не имеет доступа к сервисам Compose: они ещё не запущены, а сборка изолирована. Тестам, которым нужна база, место в Compose.
Smoke-тест проверяет то, что не может проверить ни один тест внутри стадии: правильность CMD, USER, EXPOSE, HEALTHCHECK — то есть конфигурацию самого образа.
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
Отдельный сервис, зависящий от инфраструктуры:
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
Запуск с передачей кода возврата наружу:
docker compose run --rm tests
echo "код: $?"
docker compose run возвращает код завершения container — это делает его пригодным для CI. Альтернатива для запуска всего стека сразу:
docker compose up --abort-on-container-exit --exit-code-from tests
Здесь код tests становится кодом всей команды, а остальные сервисы останавливаются, как только тесты закончились.
Ключевое требование — condition: service_healthy: без него тесты стартуют раньше, чем база примет соединения (урок 9.5).
Извлечение отчётов из сборки
Отчёт о покрытии создаётся внутри стадии test и по умолчанию остаётся там. Способ достать его наружу — экспорт содержимого стадии:
FROM scratch AS test-report
COPY --from=test /app/htmlcov /htmlcov
COPY --from=test /app/coverage.xml /coverage.xml
docker build --target test-report --output type=local,dest=./reports .
Флаг --output записывает результат стадии в каталог хоста вместо образа. FROM scratch гарантирует, что выгрузится только то, что скопировано явно.
Альтернатива, если сборка и так запускается через Compose или скрипт, — примонтировать каталог в docker run и запустить тесты в уже собранном образе app:test.
Внутренний механизм
Почему стадия пропускается
BuildKit строит граф зависимостей стадий по инструкциям FROM ... AS и COPY --from=. Затем от целевой стадии обходит граф назад и собирает только достижимые вершины.
runtime ──COPY --from──▶ builder ──▶ base
│
└── test недостижима из runtime → не собирается
Добавление COPY --from=test в runtime делает test достижимой — на этом основан второй способ из таблицы выше.
Что кэширует RUN pytest
Ключ кэша слоя — хеш предыдущего слоя плюс строка команды. Содержимое тестов влияет на ключ косвенно, через слой COPY tests/: изменение файла меняет его хеш, а значит и все последующие слои.
Отсюда практическое следствие: COPY tests/ должен идти после установки зависимостей. Тогда правка теста не приводит к переустановке пакетов.
Команды и примеры
Рабочий пример
cd resources/examples/pytest-container
docker build --target test -t calc:test .
Ожидаемый вывод (фрагмент):
=> [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
Тесты прошли, покрытие достигнуто, сборка продолжилась.
Демонстрация главной ловушки
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 " код сборки: $?"
Ожидаемый вывод:
═══ обычная сборка ═══
ОБРАЗ СОБРАН — тесты не выполнялись!
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. Проверять нужно код самой сборки:
docker build --target test -t trap:test . > /dev/null 2>&1
echo "код docker build: $?"
Ожидаемый вывод:
код docker build: 1
Вот он, настоящий результат. В CI проверять нужно именно его.
Способ 2: сделать тесты обязательными
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: $?"
Ожидаемый вывод:
═══ обычная сборка с обязательными тестами ═══
код docker build: 1
Теперь docker build без --target падает: runtime зависит от test, значит стадия обязана выполниться.
Приём работает, но у него есть цена. Собрать production-образ без прогона тестов становится невозможно — а это иногда нужно: например, при отладке самой сборки или при выпуске hotfix'а, когда тесты чинятся отдельно. Плюс в финальном образе появляется бессмысленный файл .tests-passed.
Поэтому рекомендация остаётся прежней: явный --target test отдельным шагом. Приём с маркером — для случаев, когда контроль над командой сборки вам не принадлежит.
Кэш прячет прогон тестов
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}')"
Ожидаемый вывод:
═══ первая сборка ═══
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: тесты не найдены
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"
Ожидаемый вывод:
no tests ran in 0.01s
код pytest: 5
Код 5 при пустом каталоге. В обычной стадии test (без ; echo) это остановило бы сборку — и это правильное поведение: «тестов нет» не должно выглядеть как «тесты прошли».
Частая причина в реальных проектах — tests/ в .dockerignore:
cd /tmp/tst
echo "tests/" > .dockerignore
docker build --target test -t dockerignore-trap . 2>&1 | tail -3
rm -f .dockerignore
Ожидаемый вывод:
ERROR: failed to solve: failed to compute cache key: failed to calculate
checksum of ref ...: "/tests": not found
Здесь сборка падает явно — это удача. Хуже, если .dockerignore исключает часть файлов: тогда pytest найдёт меньше тестов и молча сообщит об успехе.
Проверка, что именно попало в контекст:
docker build --target test --progress plain --no-cache -t chk . 2>&1 | \
grep -E "passed|failed|no tests ran"
Ожидаемый вывод:
1 passed in 2.02s
Число тестов в выводе — самая простая защита от такого рода потерь: если тестов стало меньше без изменений в коде, что-то исключено из контекста.
Тестовые зависимости не попадают в production
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:'
Ожидаемый вывод:
═══ 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).
Экспорт отчёта о покрытии
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/^/ /'
Ожидаемый вывод:
./reports/coverage.xml
./reports/htmlcov/index.html
Отчёт на хосте, при этом в образ он не попал. В CI такой каталог передают в систему отображения покрытия.
Smoke-тест образа
Проверка того, что тесты внутри стадии проверить не могут:
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
Ожидаемый вывод:
✓ пользователь не root 10001
✓ healthz отвечает 200
✓ healthcheck настроен да
✓ CMD в exec form fastapi
✓ код выхода после stop 0
результат: все проверки пройдены
Каждая из этих пяти проверок ловит ошибку конфигурации образа, невидимую для pytest внутри стадии сборки: забытый USER, неверный порт, отсутствующий HEALTHCHECK, shell form в CMD (урок 5.4).
Скрипт возвращает ненулевой код при провале — это делает его пригодным для CI.
Уборка
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-приложения, отвечающий шести требованиям.
- Unit-тесты выполняются в стадии сборки; провал останавливает сборку.
- Порог покрытия соблюдается; недобор останавливает сборку так же, как провал теста.
- Тестовые зависимости отсутствуют в production-образе — доказать.
- Отчёт о покрытии извлекается на хост, не попадая в образ.
- Smoke-тест проверяет конфигурацию собранного образа и возвращает ненулевой код при провале.
- Один скрипт
run-tests.shвыполняет всё и возвращает корректный код.
Отдельно ответьте: что произойдёт при docker build -t app . без --target и почему.
Подсказки
Подсказка 1
Порог покрытия задаётся в pyproject.toml (fail_under) или флагом --cov-fail-under. И то и другое даёт ненулевой код.
Подсказка 2
Для требования 3 не полагайтесь на размер образа: проверьте отсутствие самой команды.
Подсказка 3
set -e в скрипте прервёт выполнение на первой ошибке, но нужно ещё вернуть код наружу.
Решение
Сначала выполните задание самостоятельно.
Показать решение
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 "КОД ВОЗВРАТА СКРИПТА: $?"
Ожидаемый вывод:
═══ 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
Проверим, что конвейер действительно ломается при провале:
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
Ожидаемый вывод:
провальный тест → код: 1
недобор покрытия → код: 1
Оба требования подтверждены не утверждением, а проверкой.
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 доступа не имеет.
Проверка результата
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 они не проверяют |
Контрольные вопросы
На понимание:
- Почему
docker build -t app .может не выполнить стадиюtest? - Что даёт запуск тестов в образе сверх запуска на хосте? Три различия.
- Почему
RUN pytestиногда не перезапускается и как это исправить? - Почему integration-тесты нельзя выполнять в стадии сборки?
- Что означает exit code
5и почему его нельзя игнорировать?
На применение:
- Как извлечь HTML-отчёт о покрытии из сборки, не помещая его в образ?
- Как доказать, что
pytestотсутствует в production-образе? - Как организовать тесты, которым нужен PostgreSQL?
На диагностику:
- Сборка падает с
"/tests": not found. Причина? - Тесты проходят на хосте и падают в образе. Три версии.
Краткое резюме
- Тесты в образе проверяют то окружение, в котором код поедет в production.
- Стадия
testв multi-stage не попадает в финальный образ вместе с зависимостями. - BuildKit не выполняет стадии, недостижимые из цели сборки —
docker build -t app .тесты не прогонит. - Надёжный способ — отдельный шаг
docker build --target test. - Приём с
COPY --from=testделает тесты обязательными, но лишает возможности собрать образ без них. RUN pytestкэшируется; принудительный прогон —--no-cache-filter test.COPY tests/идёт после установки зависимостей, иначе правка теста ломает кэш пакетов.- Exit code
5означает «тесты не собраны» и не должен считаться успехом. tests/не исключают в.dockerignore— их не пускает в образ выбор стадии.- Integration-тесты выполняются в Compose с
condition: service_healthy, а не в сборке. - Smoke-тест проверяет конфигурацию образа:
USER,CMD,HEALTHCHECK, код выхода. - Отчёт о покрытии выгружается стадией
FROM scratchи--output type=local.
Официальные источники
| Источник | Ссылка | Что подтверждает |
|---|---|---|
| Docker: multi-stage builds | https://docs.docker.com/build/building/multi-stage/ | --target, пропуск неиспользуемых стадий |
| Docker: build cache | https://docs.docker.com/build/cache/ | Инвалидация слоёв, порядок инструкций |
Docker: --no-cache-filter | https://docs.docker.com/reference/cli/docker/buildx/build/ | Сброс кэша для отдельных стадий |
| Docker: build exporters | https://docs.docker.com/build/exporters/local-tar/ | --output type=local для выгрузки файлов |
| Docker: test containers in build | https://docs.docker.com/build/building/best-practices/ | Тестирование как часть сборки |
Compose: run и up | https://docs.docker.com/reference/cli/docker/compose/run/ | Передача кода возврата, --abort-on-container-exit |
| pytest: exit codes | https://docs.pytest.org/en/stable/reference/exit-codes.html | Значения 0–5 |
| pytest-cov | https://pytest-cov.readthedocs.io/ | --cov-report, --cov-fail-under |
| coverage.py: configuration | https://coverage.readthedocs.io/en/latest/config.html | fail_under, branch |
Навигация
← Предыдущий материал
Вернуться к разделу
Следующий материал → Resource limits и память Python
Главное оглавление