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

15.2. Тесты внутри container

Цели

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

  • объяснить, почему стадия test в multi-stage сборке может не выполниться, и заставить её выполниться;
  • собрать образ так, чтобы тесты проверяли то, что действительно поедет в эксплуатацию;
  • разделить production- и test-зависимости и доказать, что вторые не попали в образ;
  • извлечь отчёт о покрытии из сборки, не запуская container;
  • организовать быструю итерацию без пересборки образа на каждое изменение;
  • понять, почему пути в отчёте о покрытии не совпадают и как это исправить.

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

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

ТерминОбъяснение
стадияИменованный этап multi-stage сборки
targetСтадия, до которой ведётся сборка
достижимостьСвойство стадии быть нужной для цели сборки
--outputИзвлечение файлов из сборки без запуска container
[paths]Раздел настройки coverage, сопоставляющий пути

Теория

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

dockerfile
FROM python:3.13-slim AS base
WORKDIR /app
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
COPY src/ ./src/

FROM base AS test
RUN pip install --no-cache-dir pytest==9.1.1
COPY tests/ ./tests/
RUN python -m pytest tests/ -q          # ← тесты здесь

FROM base AS production
CMD ["python", "-m", "src.main"]
bash
docker build -t app:1.0 .

Тесты не выполнились. BuildKit строит граф зависимостей от целевой стадии и пропускает всё, что до неё не ведёт (урок 5.7). Целевая стадия — последняя, production; она зависит от base, а не от test.

Сборка проходит успешно, вывод выглядит нормально, тесты никогда не запускались. Это одна из самых дорогих ошибок раздела: команда уверена, что тесты в сборке есть.

Три способа сделать стадию достижимой:

СпособКакКогда применять
Явная цельdocker build --target test .Отдельный шаг в CI
Зависимость по артефактуCOPY --from=test /отчёт /отчёт в productionТесты обязательны для сборки
Отдельная командаdocker build --target test . && docker build .Просто и явно

Второй способ — самый надёжный: он делает невозможной сборку production-образа без прохождения тестов.

Тесты должны проверять то, что поедет

Две схемы, различающиеся результатом:

text
НЕВЕРНО                          ВЕРНО

    base                             base
   /    \                              |
test    production               production
                                       |
   тестируется НЕ то,               test
   что собирается                     |
                                 тестируется РОВНО то,
                                 что поедет

В левой схеме test и production — независимые ветви от общей базы. Они могут разойтись: в production добавлена инструкция, которой нет в test.

В правой стадия test наследует готовый production-образ и добавляет к нему только тестовые зависимости и сами тесты. Всё, что проверено, — это и есть отгружаемый артефакт плюс наложенный сверху тестовый слой.

dockerfile
FROM base AS production
COPY src/ ./src/
USER app
CMD ["python", "-m", "src.main"]

FROM production AS test
USER root
RUN pip install --no-cache-dir -r requirements-dev.txt
COPY tests/ ./tests/
RUN python -m pytest tests/ -q

Обратите внимание на USER root в стадии test: установка пакетов требует прав. Это не влияет на production-образ — стадии независимы после ветвления.

Разделение зависимостей

text
requirements.txt          → production-образ
requirements-dev.txt      → только стадия test

Проверка, что разделение соблюдено:

bash
docker run --rm app:1.0 python -c "import pytest" 2>&1 | grep ModuleNotFoundError

Отсутствие вывода означает, что pytest попал в production-образ. Это не только размер — это лишняя поверхность и признак нарушенного разделения.

Типичная причина попадания: COPY . . до установки зависимостей плюс единый requirements.txt, куда сложили всё.

Извлечение отчётов

Отчёт о покрытии создаётся внутри сборки и по умолчанию остаётся там. Три способа достать:

СпособКомандаСвойство
--outputdocker build --target report --output type=local,dest=./out .Не создаёт container
Через containerdocker create + docker cpРаботает везде
Монтированиеdocker run -v "$PWD/out:/out"Не часть сборки

Первый способ — предпочтительный. Он требует отдельной стадии, содержащей только отчёт:

dockerfile
FROM test AS report
RUN python -m pytest tests/ --cov=src --cov-report=xml:/out/coverage.xml -q

FROM scratch AS export
COPY --from=report /out /
bash
docker build --target export --output type=local,dest=./reports .

Стадия export на базе scratch содержит только файлы отчёта. --output type=local выгружает её содержимое в каталог.

Почему пути в отчёте не совпадают

Внутри container код лежит в /app/src, на машине разработчика — в /home/user/проект/src. Отчёт содержит пути из container'а, и инструмент на машине их не находит.

Решение — раздел [paths] в настройке coverage:

ini
[tool.coverage.paths]
source = [
    "src/",
    "/app/src/",
]

Все перечисленные пути считаются одним и тем же исходником. Это же нужно, когда отчёты объединяются из нескольких прогонов — например, из матрицы версий Python.

Быстрая итерация

Пересборка образа на каждое изменение теста занимает секунды даже при хорошем кэше. Для цикла «правка — прогон» это много.

bash
docker build --target test -t app:test .
docker run --rm -v "$PWD/src:/app/src:ro" -v "$PWD/tests:/app/tests:ro" \
    app:test python -m pytest tests/ -q

Образ собирается один раз; код и тесты монтируются. Изменение файла видно немедленно.

Ограничение: так проверяется код, а не образ. Перед публикацией нужен полный прогон в собранном образе — монтирование скрывает ошибки вида «файл не скопирован в образ» (урок 15.1).


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

Как BuildKit решает, что строить

BuildKit разбирает Dockerfile в граф, где узлы — инструкции, а рёбра — зависимости (FROM, COPY --from). Затем от целевого узла обходит граф назад и строит только достижимое.

Отсюда два следствия:

Стадия без входящих рёбер не строится. Именно это происходит с test, если на неё никто не ссылается.

Порядок стадий в файле не определяет порядок сборки. Независимые стадии могут строиться параллельно; зависимые — в порядке рёбер, а не строк.

Проверить, что действительно выполнялось: docker build --progress=plain показывает каждый шаг с номером стадии.

Почему RUN pytest останавливает сборку

Инструкция RUN завершается неуспешно при ненулевом коде возврата команды. pytest возвращает 0 при успехе и 1 при падении хотя бы одного теста.

Отсюда практическое требование: команда в RUN не должна «проглатывать» код возврата.

dockerfile
RUN python -m pytest tests/ -q                    # верно
RUN python -m pytest tests/ -q || true            # тесты не остановят сборку
RUN python -m pytest tests/ -q | tee /tmp/log     # код возврата от tee, не от pytest

Третья строка — распространённая ошибка. Код возврата конвейера в оболочке по умолчанию равен коду последней команды. Решение — set -o pipefail:

dockerfile
SHELL ["/bin/bash", "-o", "pipefail", "-c"]
RUN python -m pytest tests/ -q | tee /tmp/pytest.log

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

Стадия test, которая не выполняется

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

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

cat > src/__init__.py <<'PY'
PY
cat > src/calc.py <<'PY'
"""Простой модуль для демонстрации."""
from __future__ import annotations


def total(price: float, quantity: int, discount: float = 0.0) -> float:
    """Сумма заказа со скидкой."""
    if price < 0 or quantity < 0:
        raise ValueError("отрицательные значения недопустимы")
    if not 0.0 <= discount <= 1.0:
        raise ValueError("скидка вне диапазона 0..1")
    return round(price * quantity * (1 - discount), 2)
PY

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

from src.calc import total


def test_simple():
    assert total(100.0, 3) == 300.0


def test_with_discount():
    assert total(100.0, 2, 0.1) == 180.0


@pytest.mark.parametrize("price,qty", [(-1, 1), (1, -1)])
def test_negative_rejected(price, qty):
    with pytest.raises(ValueError):
        total(price, qty)


def test_bad_discount():
    with pytest.raises(ValueError):
        total(100.0, 1, 1.5)
PY

cat > Dockerfile.unreachable <<'EOF'
# syntax=docker/dockerfile:1
FROM python:3.13-slim AS base
WORKDIR /app
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
COPY src/ ./src/

# Стадия test существует, но НИКТО на неё не ссылается
FROM base AS test
COPY requirements-dev.txt .
RUN pip install --no-cache-dir -r requirements-dev.txt
COPY tests/ ./tests/
RUN echo "=== ТЕСТЫ ВЫПОЛНЯЮТСЯ ===" && python -m pytest tests/ -q

FROM base AS production
CMD ["python", "-c", "from src.calc import total; print(total(10, 2))"]
EOF

echo "═══ обычная сборка ═══"
docker build -f Dockerfile.unreachable -t reach:demo . 2>&1 | tail -6 | sed 's/^/  /'

echo "═══ выполнялись ли тесты? ═══"
if docker build -f Dockerfile.unreachable -t reach:demo --no-cache . 2>&1 \
   | grep -q 'ТЕСТЫ ВЫПОЛНЯЮТСЯ'; then
    echo "  ДА — строка из стадии test найдена в выводе"
else
    echo "  НЕТ — стадия test пропущена, её вывода в сборке нет"
fi

echo "═══ явная цель ═══"
docker build -f Dockerfile.unreachable --target test -t reach:test --no-cache . 2>&1 \
    | grep -E 'ТЕСТЫ ВЫПОЛНЯЮТСЯ|passed|DONE' | head -3 | sed 's/^/  /'

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

text
═══ обычная сборка ═══
   => [production 1/1] ...
   => exporting to image
   => => naming to docker.io/library/reach:demo
═══ выполнялись ли тесты? ═══
  НЕТ — стадия test пропущена, её вывода в сборке нет
═══ явная цель ═══
  #12 0.412 === ТЕСТЫ ВЫПОЛНЯЮТСЯ ===
  #12 0.688 5 passed in 0.03s
  #12 DONE 0.7s

Первая проверка — то, ради чего написан этот блок. Сборка прошла успешно, образ создан, тесты не выполнялись ни разу.

Ошибку легко не заметить: в выводе сборки нет ничего тревожного, просто отсутствуют строки, которых никто не искал.

Как сделать тесты обязательными

bash
cd /tmp/incontainer
cat > Dockerfile.enforced <<'EOF'
# syntax=docker/dockerfile:1
FROM python:3.13-slim AS base
WORKDIR /app
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt

# Production-образ: то, что поедет
FROM base AS production
COPY src/ ./src/
RUN useradd --create-home --uid 10001 app && chown -R app:app /app
USER app
CMD ["python", "-c", "from src.calc import total; print(total(10, 2))"]

# Тесты НАСЛЕДУЮТ production: проверяется ровно то, что отгружается
FROM production AS test
USER root
COPY requirements-dev.txt .
RUN pip install --no-cache-dir -r requirements-dev.txt
COPY tests/ ./tests/
RUN echo "=== ТЕСТЫ ВЫПОЛНЯЮТСЯ ===" \
    && python -m pytest tests/ -q \
    && mkdir -p /out && echo "тесты пройдены" > /out/tests-passed.txt

# Финальная стадия ЗАВИСИТ от test — без прохождения тестов сборки не будет
FROM production AS final
COPY --from=test /out/tests-passed.txt /app/.tests-passed
EOF

echo "═══ сборка без указания цели ═══"
docker build -f Dockerfile.enforced -t enforced:1 --no-cache . 2>&1 \
    | grep -E 'ТЕСТЫ ВЫПОЛНЯЮТСЯ|passed|naming' | sed 's/^/  /'

echo "═══ проверка: тесты выполнились ═══"
docker run --rm enforced:1 cat /app/.tests-passed 2>/dev/null | sed 's/^/  /'

echo "═══ ломаем тест и пробуем собрать ═══"
cat >> tests/test_calc.py <<'PY'


def test_заведомо_ломающий():
    assert total(1.0, 1) == 999.0
PY
build_rc=0
docker build -f Dockerfile.enforced -t enforced:2 --no-cache . > broken.log 2>&1 || build_rc=$?
printf '  код возврата сборки: %s\n' "$build_rc"
grep -E 'assert|failed' broken.log | head -3 | sed 's/^/  /'

echo "═══ создался ли образ ═══"
if docker image inspect enforced:2 > /dev/null 2>&1; then
    echo "  образ enforced:2 СОЗДАН — тесты не остановили сборку"
else
    echo "  образ enforced:2 НЕ создан — сборка остановлена падением теста"
fi

# Убираем сломанный тест
python3 - <<'PY'
from pathlib import Path
p = Path("tests/test_calc.py")
text = p.read_text()
marker = "\n\ndef test_заведомо_ломающий():"
if marker in text:
    p.write_text(text[:text.index(marker)] + "\n")
    print("  сломанный тест удалён")
PY

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

text
═══ сборка без указания цели ═══
  #14 0.401 === ТЕСТЫ ВЫПОЛНЯЮТСЯ ===
  #14 0.702 5 passed in 0.03s
   => => naming to docker.io/library/enforced:1
═══ проверка: тесты выполнились ═══
  тесты пройдены
═══ ломаем тест и пробуем собрать ═══
  код возврата сборки: 1
  E       assert 1.0 == 999.0
  1 failed, 5 passed in 0.04s
═══ создался ли образ ═══
  образ enforced:2 НЕ создан — сборка остановлена падением теста
  сломанный тест удалён

Ключевая строка — COPY --from=test /out/tests-passed.txt. Она создаёт ребро в графе: финальная стадия не может быть построена, пока не построена test, а test не строится при падении pytest.

Проверка выполнена в обе стороны: с исправными тестами образ собирается, со сломанным — нет.

Тесты проверяют то, что поедет

bash
cd /tmp/incontainer
echo "═══ от какого пользователя работает production ═══"
docker run --rm enforced:1 sh -c 'id -u; id -un' 2>/dev/null | sed 's/^/  /'

echo "═══ от какого пользователя выполнялись тесты ═══"
docker build -f Dockerfile.enforced --target test -t enforced:test . > /dev/null 2>&1
docker run --rm enforced:test sh -c 'id -u; id -un' 2>/dev/null | sed 's/^/  /'

echo "═══ разбор ═══"
cat <<'TXT'
  Стадия test наследует production и переключается на root
  только для установки пакетов. Сам production-образ остаётся
  с USER app.

  Если бы test и production были независимыми ветвями от base,
  тесты могли бы проходить в окружении, отличном от отгружаемого:
  другой пользователь, другой набор файлов, другой рабочий каталог.

  Проверка того, что тесты видят production-окружение:
    docker build --target test -t app:test .
    docker run --rm app:test ls -la /app
TXT
docker run --rm enforced:test sh -c 'ls /app | tr "\n" " "' 2>/dev/null | sed 's/^/  файлы в test: /'
docker run --rm enforced:1 sh -c 'ls /app | tr "\n" " "' 2>/dev/null | sed 's/^/  файлы в prod: /'

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

text
═══ от какого пользователя работает production ═══
  10001
  app
═══ от какого пользователя выполнялись тесты ═══
  0
  root
═══ разбор ═══
  Стадия test наследует production и переключается на root
  только для установки пакетов. Сам production-образ остаётся
  с USER app.
  ...
  файлы в test: requirements.txt requirements-dev.txt src tests
  файлы в prod: requirements.txt src

Последние две строки показывают разницу: в тестовом образе есть tests/ и requirements-dev.txt, в production — нет.

Общее у них — src/, установленный в одной и той же базовой стадии. Именно это и требуется: проверяется тот код, который отгружается.

Проверка разделения зависимостей

bash
cd /tmp/incontainer
cat > check-deps.sh <<'SH'
#!/usr/bin/env bash
# Проверяет, что test-зависимости не попали в production-образ.
set -uo pipefail

IMAGE="${1:?укажите образ}"
DEV_PACKAGES="${2:-pytest pytest-cov coverage ruff mypy ipython pdbpp}"

found=0
printf '\n  Проверка образа: %s\n\n' "$IMAGE"

for pkg in $DEV_PACKAGES; do
    module="${pkg//-/_}"
    if docker run --rm --entrypoint python "$IMAGE" -c "import $module" > /dev/null 2>&1; then
        printf '  ⚠ %-14s ПРИСУТСТВУЕТ в production-образе\n' "$pkg"
        found=$((found + 1))
    else
        printf '  ✓ %-14s отсутствует\n' "$pkg"
    fi
done

# Файлы, которых в production быть не должно
printf '\n'
for path in /app/tests /app/requirements-dev.txt /app/.pytest_cache /app/conftest.py; do
    if docker run --rm --entrypoint sh "$IMAGE" -c "test -e $path" 2>/dev/null; then
        printf '  ⚠ %-26s присутствует\n' "$path"
        found=$((found + 1))
    else
        printf '  ✓ %-26s отсутствует\n' "$path"
    fi
done

printf '\n  нарушений разделения: %s\n\n' "$found"
[ "$found" -gt 0 ] && exit 1
exit 0
SH
chmod +x check-deps.sh

echo "═══ production-образ ═══"
./check-deps.sh enforced:1 || true

echo "═══ тестовый образ (для сравнения) ═══"
./check-deps.sh enforced:test 2>&1 | tail -4 || true

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

text
═══ production-образ ═══

  Проверка образа: enforced:1

  ✓ pytest         отсутствует
  ✓ pytest-cov     отсутствует
  ✓ coverage       отсутствует
  ✓ ruff           отсутствует
  ✓ mypy           отсутствует
  ✓ ipython        отсутствует
  ✓ pdbpp          отсутствует

  ✓ /app/tests                   отсутствует
  ✓ /app/requirements-dev.txt    отсутствует
  ✓ /app/.pytest_cache           отсутствует
  ✓ /app/conftest.py             отсутствует

  нарушений разделения: 0

═══ тестовый образ (для сравнения) ═══
  ⚠ /app/requirements-dev.txt    присутствует

  нарушений разделения: 3

Второй блок подтверждает, что проверка вообще работает: на тестовом образе она находит нарушения. Проверка, всегда возвращающая «чисто», бесполезна.

Извлечение отчёта о покрытии

bash
cd /tmp/incontainer
cat > pyproject.toml <<'EOF'
[tool.coverage.run]
source = ["src"]
branch = true

[tool.coverage.paths]
# Один и тот же исходник под разными путями: в container и на машине
source = ["src/", "/app/src/"]

[tool.coverage.report]
show_missing = true
skip_covered = false
EOF

cat > Dockerfile.report <<'EOF'
# syntax=docker/dockerfile:1
FROM python:3.13-slim AS base
WORKDIR /app
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt

FROM base AS production
COPY src/ ./src/
CMD ["python", "-c", "from src.calc import total; print(total(10, 2))"]

FROM production AS test
COPY requirements-dev.txt pyproject.toml ./
RUN pip install --no-cache-dir -r requirements-dev.txt
COPY tests/ ./tests/
RUN mkdir -p /out && python -m pytest tests/ -q \
        --cov=src \
        --cov-report=term \
        --cov-report=xml:/out/coverage.xml \
        --junit-xml=/out/junit.xml

# Стадия, содержащая ТОЛЬКО отчёты
FROM scratch AS export
COPY --from=test /out/ /
EOF

echo "═══ сборка с выгрузкой отчётов ═══"
rm -rf reports && mkdir -p reports
docker build -f Dockerfile.report --target export \
    --output "type=local,dest=./reports" --no-cache . 2>&1 \
    | grep -E 'TOTAL|passed|DONE.*export' | head -4 | sed 's/^/  /'

echo "═══ что выгрузилось ═══"
ls -la reports/ 2>/dev/null | tail -3 | sed 's/^/  /'

echo "═══ содержимое отчёта о покрытии ═══"
python3 - <<'PY'
import xml.etree.ElementTree as ET
from pathlib import Path

path = Path("reports/coverage.xml")
if not path.exists():
    print("  coverage.xml не выгружен")
else:
    root = ET.parse(path).getroot()
    rate = float(root.get("line-rate", 0)) * 100
    print(f"  покрытие строк: {rate:.0f} %")
    for cls in root.iter("class"):
        name = cls.get("filename")
        cls_rate = float(cls.get("line-rate", 0)) * 100
        print(f"    {name:<24} {cls_rate:.0f} %")
PY

echo "═══ почему нужен раздел paths ═══"
cat <<'TXT'
  Внутри container исходники лежат в /app/src, на машине — в ./src.
  Отчёт содержит пути из container'а.

  Без раздела [tool.coverage.paths] инструмент на машине
  не найдёт файлы и покажет 0 % или ошибку.

  С разделом:
    [tool.coverage.paths]
    source = ["src/", "/app/src/"]

  Оба пути считаются одним исходником. Это же нужно
  при объединении отчётов из нескольких прогонов.
TXT

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

text
═══ сборка с выгрузкой отчётов ═══
  #16 0.712 5 passed in 0.09s
  #16 0.712 TOTAL                       8      0      2      0   100%
  #18 DONE 0.1s
═══ что выгрузилось ═══
  -rw-r--r-- 1 evg evg  1284 июл 31 12:04 coverage.xml
  -rw-r--r-- 1 evg evg  1902 июл 31 12:04 junit.xml
═══ содержимое отчёта о покрытии ═══
  покрытие строк: 100 %
    src/calc.py              100 %
═══ почему нужен раздел paths ═══
  Внутри container исходники лежат в /app/src, на машине — в ./src.
  Отчёт содержит пути из container'а.
  ...

Отчёты появились на машине, при этом ни один container не запускался: --output type=local выгружает содержимое стадии напрямую.

Стадия export на базе scratch содержит только файлы отчётов — это важно, иначе в выгрузку попала бы вся файловая система.

Ловушка с кодом возврата

bash
cd /tmp/incontainer
cat > Dockerfile.pipe <<'EOF'
# syntax=docker/dockerfile:1
FROM python:3.13-slim
WORKDIR /app
RUN pip install --no-cache-dir pytest==9.1.1
COPY src/ ./src/
COPY tests/ ./tests/
RUN echo "def test_упадёт(): assert False" > tests/test_broken.py

# ЛОВУШКА: код возврата берётся от tee, а не от pytest
RUN python -m pytest tests/ -q | tee /tmp/pytest.log
EOF

echo "═══ сборка с конвейером без pipefail ═══"
pipe_rc=0
docker build -f Dockerfile.pipe -t pipe:trap --no-cache . > pipe.log 2>&1 || pipe_rc=$?
printf '  код возврата сборки: %s\n' "$pipe_rc"
grep -E 'failed|passed' pipe.log | tail -1 | sed 's/^/  в логе: /'
if docker image inspect pipe:trap > /dev/null 2>&1; then
    echo "  образ СОЗДАН несмотря на упавший тест"
else
    echo "  образ не создан"
fi

echo "═══ исправление: pipefail ═══"
cat > Dockerfile.pipefail <<'EOF'
# syntax=docker/dockerfile:1
FROM python:3.13-slim
SHELL ["/bin/bash", "-o", "pipefail", "-c"]
WORKDIR /app
RUN pip install --no-cache-dir pytest==9.1.1
COPY src/ ./src/
COPY tests/ ./tests/
RUN echo "def test_упадёт(): assert False" > tests/test_broken.py
RUN python -m pytest tests/ -q | tee /tmp/pytest.log
EOF

fix_rc=0
docker build -f Dockerfile.pipefail -t pipe:fixed --no-cache . > pipefix.log 2>&1 || fix_rc=$?
printf '  код возврата сборки: %s\n' "$fix_rc"
if docker image inspect pipe:fixed > /dev/null 2>&1; then
    echo "  образ создан — исправление не сработало"
else
    echo "  образ НЕ создан — сборка остановлена, как и должно быть"
fi

echo "═══ три способа потерять код возврата ═══"
cat <<'TXT'
  RUN pytest tests/ || true              намеренно проглочен
  RUN pytest tests/ ; echo готово        код от echo
  RUN pytest tests/ | tee /tmp/log       код от tee

  Первый вариант иногда пишут «чтобы посмотреть логи» и забывают убрать.
  Третий выглядит безобидно и встречается чаще всего.

  Исправление для третьего:
    SHELL ["/bin/bash", "-o", "pipefail", "-c"]

  Проверка: сломайте один тест и убедитесь, что сборка падает.
  Механизм, который никто не проверял, обычно не работает.
TXT

docker rmi -f pipe:trap pipe:fixed reach:demo reach:test enforced:1 enforced:test > /dev/null 2>&1

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

text
═══ сборка с конвейером без pipefail ═══
  код возврата сборки: 0
  в логе: 1 failed, 5 passed in 0.05s
  образ СОЗДАН несмотря на упавший тест
═══ исправление: pipefail ═══
  код возврата сборки: 1
  образ НЕ создан — сборка остановлена, как и должно быть
═══ три способа потерять код возврата ═══
  RUN pytest tests/ || true              намеренно проглочен
  RUN pytest tests/ ; echo готово        код от echo
  RUN pytest tests/ | tee /tmp/log       код от tee
  ...

1 failed в логе и код возврата сборки: 0 в соседних строках — это и есть ловушка. Сборка сообщила об успехе, хотя тест упал.

Быстрая итерация

bash
cd /tmp/incontainer
docker build -f Dockerfile.report --target test -t iter:test . > /dev/null 2>&1

echo "═══ прогон с монтированием кода ═══"
docker run --rm \
    -v "$PWD/src:/app/src:ro" \
    -v "$PWD/tests:/app/tests:ro" \
    iter:test python -m pytest tests/ -q 2>&1 | tail -2 | sed 's/^/  /'

echo "═══ меняем код без пересборки ═══"
cp src/calc.py src/calc.py.bak
python3 - <<'PY'
from pathlib import Path
p = Path("src/calc.py")
p.write_text(p.read_text().replace("return round(price * quantity", "return round(0 * price * quantity"))
print("  внесена ошибка в src/calc.py")
PY

docker run --rm \
    -v "$PWD/src:/app/src:ro" \
    -v "$PWD/tests:/app/tests:ro" \
    iter:test python -m pytest tests/ -q 2>&1 | tail -2 | sed 's/^/  /'
mv src/calc.py.bak src/calc.py
echo "  код восстановлен"

echo "═══ ограничение подхода ═══"
cat <<'TXT'
  Монтирование даёт быстрый цикл, но проверяет КОД, а не ОБРАЗ.

  Что оно скрывает:
    файл не скопирован в образ инструкцией COPY
    файл исключён .dockerignore
    зависимость не установлена в образ
    права на файл в образе не те

  Поэтому перед публикацией — полный прогон в собранном образе:
    docker build --target test -t app:test . && docker run --rm app:test pytest
TXT

cd /tmp && rm -rf /tmp/incontainer
docker rmi -f iter:test > /dev/null 2>&1

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

text
═══ прогон с монтированием кода ═══
  .....                                                                [100%]
  5 passed in 0.04s
═══ меняем код без пересборки ═══
  внесена ошибка в src/calc.py
  FF...                                                                [100%]
  2 failed, 3 passed in 0.05s
  код восстановлен
═══ ограничение подхода ═══
  Монтирование даёт быстрый цикл, но проверяет КОД, а не ОБРАЗ.

  Что оно скрывает:
    файл не скопирован в образ инструкцией COPY
    файл исключён .dockerignore
    зависимость не установлена в образ
    права на файл в образе не те

  Поэтому перед публикацией — полный прогон в собранном образе:
    docker build --target test -t app:test . && docker run --rm app:test pytest

Изменение файла на машине немедленно отразилось в прогоне — образ не пересобирался.


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

Задание. Встройте тесты в сборку так, чтобы их нельзя было обойти.

Требования:

  1. Показать, что стадия test без ссылок на неё не выполняется при обычной сборке.
  2. Сделать тесты обязательными: сборка production-образа невозможна при падении теста.
  3. Проверить это в обе стороны: с исправными тестами и со сломанным.
  4. Показать, что стадия test наследует production-образ, а не ветвится от базы.
  5. Написать проверку, что test-зависимости не попали в production-образ, и убедиться, что она находит нарушение.
  6. Выгрузить отчёт о покрытии из сборки, не запуская container.
  7. Показать ловушку с потерей кода возврата в конвейере и её исправление.

Подсказки

Подсказка 1

Для пункта 1 добавьте в стадию test вывод характерной строки и ищите её в выводе сборки с --no-cache.

Подсказка 2

Пункт 2 решается инструкцией COPY --from=test в финальной стадии: она создаёт ребро в графе.

Подсказка 3

Проверка из пункта 5 должна быть проверена сама: запустите её на тестовом образе, где нарушение заведомо есть.

Решение

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

# ─── Приложение ───────────────────────────────────────────────────────
cat > requirements.txt <<'EOF'
click==8.3.0
EOF
cat > requirements-dev.txt <<'EOF'
pytest==9.1.1
pytest-cov==7.1.0
EOF

cat > src/__init__.py <<'PY'
PY
cat > src/pricing.py <<'PY'
"""Расчёт стоимости заказа."""
from __future__ import annotations

from decimal import Decimal, ROUND_HALF_UP

VAT = Decimal("0.20")


def line_total(price: Decimal, quantity: int) -> Decimal:
    """Стоимость позиции без НДС."""
    if price < 0:
        raise ValueError("отрицательная цена")
    if quantity < 0:
        raise ValueError("отрицательное количество")
    return (price * quantity).quantize(Decimal("0.01"), rounding=ROUND_HALF_UP)


def with_vat(amount: Decimal) -> Decimal:
    """Сумма с НДС."""
    return (amount * (1 + VAT)).quantize(Decimal("0.01"), rounding=ROUND_HALF_UP)


def apply_discount(amount: Decimal, percent: int) -> Decimal:
    """Скидка в процентах."""
    if not 0 <= percent <= 100:
        raise ValueError("процент вне диапазона 0..100")
    factor = (Decimal(100) - Decimal(percent)) / Decimal(100)
    return (amount * factor).quantize(Decimal("0.01"), rounding=ROUND_HALF_UP)
PY

cat > tests/test_pricing.py <<'PY'
from decimal import Decimal

import pytest

from src.pricing import apply_discount, line_total, with_vat


@pytest.mark.parametrize("price,qty,expected", [
    ("100.00", 3, "300.00"),
    ("9.99", 7, "69.93"),
    ("0.01", 1, "0.01"),
])
def test_line_total(price, qty, expected):
    assert line_total(Decimal(price), qty) == Decimal(expected)


@pytest.mark.parametrize("price,qty", [("-1", 1), ("1", -1)])
def test_line_total_rejects_negative(price, qty):
    with pytest.raises(ValueError):
        line_total(Decimal(price), qty)


def test_with_vat():
    assert with_vat(Decimal("100.00")) == Decimal("120.00")


@pytest.mark.parametrize("amount,percent,expected", [
    ("100.00", 10, "90.00"),
    ("100.00", 0, "100.00"),
    ("100.00", 100, "0.00"),
])
def test_apply_discount(amount, percent, expected):
    assert apply_discount(Decimal(amount), percent) == Decimal(expected)


@pytest.mark.parametrize("percent", [-1, 101])
def test_discount_out_of_range(percent):
    with pytest.raises(ValueError):
        apply_discount(Decimal("100"), percent)
PY

cat > pyproject.toml <<'EOF'
[tool.coverage.run]
source = ["src"]
branch = true

[tool.coverage.paths]
source = ["src/", "/app/src/"]

[tool.coverage.report]
show_missing = true
EOF

# ─── Dockerfile с обязательными тестами ───────────────────────────────
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

# Production-образ: ровно то, что отгружается
FROM base AS production
COPY src/ ./src/
RUN useradd --create-home --uid 10001 app && chown -R app:app /app
USER app
CMD ["python", "-c", \
     "from decimal import Decimal; from src.pricing import with_vat; \
      print(with_vat(Decimal('100')))"]

# Тесты НАСЛЕДУЮТ production: проверяется отгружаемый артефакт
FROM production AS test
USER root
COPY requirements-dev.txt pyproject.toml ./
RUN pip install --no-cache-dir -r requirements-dev.txt
COPY tests/ ./tests/
RUN mkdir -p /out \
    && echo "МАРКЕР-СТАДИИ-TEST" \
    && python -m pytest tests/ -q \
        --cov=src --cov-report=term \
        --cov-report=xml:/out/coverage.xml \
        --junit-xml=/out/junit.xml \
    && echo "пройдено" > /out/passed.txt

# Финальная стадия ЗАВИСИТ от test — обойти тесты невозможно
FROM production AS final
COPY --from=test /out/passed.txt /app/.tests-passed

# Только отчёты, для выгрузки
FROM scratch AS export
COPY --from=test /out/ /
EOF

# ─── Dockerfile с недостижимой стадией — для сравнения ────────────────
cat > Dockerfile.unreachable <<'EOF'
# syntax=docker/dockerfile:1
FROM python:3.13-slim AS base
WORKDIR /app
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
COPY src/ ./src/

FROM base AS test
COPY requirements-dev.txt .
RUN pip install --no-cache-dir -r requirements-dev.txt
COPY tests/ ./tests/
RUN echo "МАРКЕР-СТАДИИ-TEST" && python -m pytest tests/ -q

FROM base AS production
CMD ["python", "-c", "print('ok')"]
EOF

# ─── Проверка разделения зависимостей ─────────────────────────────────
cat > check-separation.sh <<'SH'
#!/usr/bin/env bash
# Проверяет, что test-зависимости и тестовые файлы не попали в образ.
set -uo pipefail

IMAGE="${1:?укажите образ}"
QUIET="${2:-}"
DEV_MODULES="pytest coverage _pytest pluggy"
DEV_PATHS="/app/tests /app/requirements-dev.txt /app/pyproject.toml /app/.pytest_cache"

found=0
say() { [ -n "$QUIET" ] || printf "$@"; }

say '\n  Разделение зависимостей: %s\n\n' "$IMAGE"

for module in $DEV_MODULES; do
    if docker run --rm --entrypoint python "$IMAGE" -c "import $module" > /dev/null 2>&1; then
        say '  ⚠ модуль %-12s ПРИСУТСТВУЕТ\n' "$module"
        found=$((found + 1))
    else
        say '  ✓ модуль %-12s отсутствует\n' "$module"
    fi
done

for path in $DEV_PATHS; do
    if docker run --rm --entrypoint sh "$IMAGE" -c "test -e $path" 2>/dev/null; then
        say '  ⚠ путь   %-26s присутствует\n' "$path"
        found=$((found + 1))
    else
        say '  ✓ путь   %-26s отсутствует\n' "$path"
    fi
done

say '\n  нарушений: %s\n\n' "$found"
echo "$found" > /tmp/.separation_count
[ "$found" -gt 0 ] && exit 1
exit 0
SH
chmod +x check-separation.sh

fail=0
ok()  { printf '  ✓ %s\n' "$1"; }
bad() { printf '  ✗ %s\n' "$1"; fail=1; }

printf '\n═══ Требование 1: недостижимая стадия не выполняется ═══\n'
docker build -f Dockerfile.unreachable -t lab:unreach --no-cache . > unreach.log 2>&1
if grep -q 'МАРКЕР-СТАДИИ-TEST' unreach.log; then
    printf '    маркер стадии test в выводе: НАЙДЕН\n'
    unreach_ran=1
else
    printf '    маркер стадии test в выводе: не найден\n'
    unreach_ran=0
fi
printf '    образ собран: %s\n' \
    "$(docker image inspect lab:unreach > /dev/null 2>&1 && echo да || echo нет)"
printf '    с явной целью --target test:\n'
docker build -f Dockerfile.unreachable --target test -t lab:unreach-test --no-cache . 2>&1 \
    | grep -E 'МАРКЕР|passed' | head -2 | sed 's/^/      /'
[ "$unreach_ran" -eq 0 ] \
    && ok "стадия test пропущена при обычной сборке, хотя образ собрался" \
    || bad "маркер найден — стадия выполнилась"

printf '\n═══ Требования 2-3: тесты обязательны ═══\n'
docker build -t lab:final --no-cache . > build-ok.log 2>&1
build_ok_rc=$?
if grep -q 'МАРКЕР-СТАДИИ-TEST' build-ok.log; then
    printf '    маркер стадии test: НАЙДЕН — тесты выполнились\n'
    enforced_ran=1
else
    printf '    маркер стадии test: не найден\n'
    enforced_ran=0
fi
grep -E '[0-9]+ passed' build-ok.log | tail -1 | sed 's/^/    /'
printf '    внутри образа: %s\n' \
    "$(docker run --rm --entrypoint cat lab:final /app/.tests-passed 2>/dev/null)"

printf '\n    ломаем один тест:\n'
cat >> tests/test_pricing.py <<'PY'


def test_intentionally_broken():
    """Заведомо падающий тест: проверяем, что сборка остановится."""
    assert with_vat(Decimal("100.00")) == Decimal("999.99")
PY
broken_rc=0
docker build -t lab:broken --no-cache . > build-broken.log 2>&1 || broken_rc=$?
printf '    код возврата сборки: %s\n' "$broken_rc"
grep -E '[0-9]+ failed' build-broken.log | tail -1 | sed 's/^/    /'
if docker image inspect lab:broken > /dev/null 2>&1; then
    printf '    образ lab:broken СОЗДАН\n'
    broken_created=1
else
    printf '    образ lab:broken не создан\n'
    broken_created=0
fi

python3 - <<'PY'
from pathlib import Path
p = Path("tests/test_pricing.py")
text = p.read_text()
marker = "\n\ndef test_intentionally_broken():"
if marker in text:
    p.write_text(text[:text.index(marker)] + "\n")
    print("    сломанный тест удалён")
PY

[ "$enforced_ran" -eq 1 ] && [ "$build_ok_rc" -eq 0 ] \
    && [ "$broken_rc" -ne 0 ] && [ "$broken_created" -eq 0 ] \
    && ok "проверено в обе стороны: исправные тесты пропускают, сломанный останавливает" \
    || bad "ок=$build_ok_rc сломан=$broken_rc создан=$broken_created"

printf '\n═══ Требование 4: test наследует production ═══\n'
docker build --target test -t lab:test --no-cache . > /dev/null 2>&1
prod_user="$(docker run --rm --entrypoint id lab:final -un 2>/dev/null)"
test_user="$(docker run --rm --entrypoint id lab:test -un 2>/dev/null)"
printf '    пользователь в production: %s\n' "$prod_user"
printf '    пользователь в test:       %s (переключён для установки пакетов)\n' "$test_user"
prod_files="$(docker run --rm --entrypoint sh lab:final -c 'ls /app | tr "\n" " "' 2>/dev/null)"
test_files="$(docker run --rm --entrypoint sh lab:test -c 'ls /app | tr "\n" " "' 2>/dev/null)"
printf '    файлы production: %s\n' "$prod_files"
printf '    файлы test:       %s\n' "$test_files"
prod_src="$(docker run --rm --entrypoint sh lab:final \
    -c 'sha256sum /app/src/pricing.py | cut -c1-16' 2>/dev/null)"
test_src="$(docker run --rm --entrypoint sh lab:test \
    -c 'sha256sum /app/src/pricing.py | cut -c1-16' 2>/dev/null)"
printf '    хеш src/pricing.py: production=%s test=%s\n' "$prod_src" "$test_src"
[ "$prod_src" = "$test_src" ] && [ -n "$prod_src" ] && [ "$prod_user" = "app" ] \
    && ok "тесты проверяют тот же код, что отгружается (совпадение хешей)" \
    || bad "хеши: $prod_src и $test_src, пользователь production: $prod_user"

printf '\n═══ Требование 5: разделение зависимостей ═══\n'
./check-separation.sh lab:final || true
prod_violations="$(cat /tmp/.separation_count 2>/dev/null || echo "?")"
./check-separation.sh lab:test quiet > /dev/null 2>&1 || true
test_violations="$(cat /tmp/.separation_count 2>/dev/null || echo "?")"
printf '  нарушений в production: %s\n' "$prod_violations"
printf '  нарушений в тестовом образе (контроль): %s\n' "$test_violations"
[ "$prod_violations" = "0" ] && [ "${test_violations:-0}" -gt 0 ] \
    && ok "production чист, и проверка доказала свою работоспособность на тестовом образе" \
    || bad "production=$prod_violations test=$test_violations"

printf '\n═══ Требование 6: выгрузка отчётов без запуска container ═══\n'
rm -rf reports && mkdir -p reports
docker build --target export --output "type=local,dest=./reports" --no-cache . \
    > export.log 2>&1
printf '    выгружено файлов: %s\n' "$(ls reports 2>/dev/null | wc -l)"
ls -1 reports 2>/dev/null | sed 's/^/      /'
python3 - <<'PY'
import xml.etree.ElementTree as ET
from pathlib import Path

cov = Path("reports/coverage.xml")
junit = Path("reports/junit.xml")
if cov.exists():
    root = ET.parse(cov).getroot()
    print(f"    покрытие строк: {float(root.get('line-rate', 0)) * 100:.0f} %")
    sources = [s.text for s in root.iter("source")]
    print(f"    пути в отчёте: {sources}")
if junit.exists():
    root = ET.parse(junit).getroot()
    suite = root if root.tag == "testsuite" else root.find("testsuite")
    print(f"    тестов в junit.xml: {suite.get('tests')}, "
          f"падений: {suite.get('failures')}")
PY
n_reports="$(ls reports 2>/dev/null | wc -l)"
[ "$n_reports" -ge 2 ] \
    && ok "отчёты выгружены через --output, container не запускался" \
    || bad "выгружено файлов: $n_reports"

printf '\n═══ Требование 7: потеря кода возврата ═══\n'
cat > Dockerfile.trap <<'EOF'
# syntax=docker/dockerfile:1
FROM python:3.13-slim
WORKDIR /app
RUN pip install --no-cache-dir pytest==9.1.1
COPY src/ ./src/
RUN mkdir -p tests && echo "def test_fails(): assert False" > tests/test_fail.py
RUN python -m pytest tests/ -q | tee /tmp/pytest.log
EOF
cat > Dockerfile.trap-fixed <<'EOF'
# syntax=docker/dockerfile:1
FROM python:3.13-slim
SHELL ["/bin/bash", "-o", "pipefail", "-c"]
WORKDIR /app
RUN pip install --no-cache-dir pytest==9.1.1
COPY src/ ./src/
RUN mkdir -p tests && echo "def test_fails(): assert False" > tests/test_fail.py
RUN python -m pytest tests/ -q | tee /tmp/pytest.log
EOF

trap_rc=0
docker build -f Dockerfile.trap -t lab:trap --no-cache . > trap.log 2>&1 || trap_rc=$?
fixed_rc=0
docker build -f Dockerfile.trap-fixed -t lab:trapfixed --no-cache . > trapfix.log 2>&1 || fixed_rc=$?

printf '    %-34s %-14s %s\n' "вариант" "код сборки" "образ создан"
printf '    %s\n' "──────────────────────────────────────────────────────────────"
printf '    %-34s %-14s %s\n' "pytest | tee (без pipefail)" "$trap_rc" \
    "$(docker image inspect lab:trap > /dev/null 2>&1 && echo ДА || echo нет)"
printf '    %-34s %-14s %s\n' "то же с SHELL pipefail" "$fixed_rc" \
    "$(docker image inspect lab:trapfixed > /dev/null 2>&1 && echo да || echo НЕТ)"
grep -E '[0-9]+ failed' trap.log | tail -1 | sed 's/^/    в логе первого: /'
[ "$trap_rc" -eq 0 ] && [ "$fixed_rc" -ne 0 ] \
    && ok "ловушка воспроизведена: конвейер скрыл падение, pipefail исправил" \
    || bad "коды: без pipefail=$trap_rc с pipefail=$fixed_rc"

printf '\n═══ ИТОГ ═══\n'
[ "$fail" -eq 0 ] && echo "  все требования выполнены" || echo "  ЕСТЬ ПРОВАЛЫ"

docker rmi -f lab:final lab:test lab:unreach lab:unreach-test lab:trap lab:trapfixed \
    > /dev/null 2>&1
cd /tmp && rm -rf /tmp/testlab
exit "$fail"

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

text
═══ Требование 1: недостижимая стадия не выполняется ═══
    маркер стадии test в выводе: не найден
    образ собран: да
    с явной целью --target test:
      #12 0.398 МАРКЕР-СТАДИИ-TEST
      #12 0.701 11 passed in 0.06s
  ✓ стадия test пропущена при обычной сборке, хотя образ собрался

═══ Требования 2-3: тесты обязательны ═══
    маркер стадии test: НАЙДЕН — тесты выполнились
    #14 0.688 11 passed in 0.06s
    внутри образа: пройдено

    ломаем один тест:
    код возврата сборки: 1
    #14 0.712 1 failed, 11 passed in 0.07s
    образ lab:broken не создан
    сломанный тест удалён
  ✓ проверено в обе стороны: исправные тесты пропускают, сломанный останавливает

═══ Требование 4: test наследует production ═══
    пользователь в production: app
    пользователь в test:       root (переключён для установки пакетов)
    файлы production: requirements.txt src
    файлы test:       pyproject.toml requirements-dev.txt requirements.txt src tests
    хеш src/pricing.py: production=8f3a2b1c4d5e6f7a test=8f3a2b1c4d5e6f7a
  ✓ тесты проверяют тот же код, что отгружается (совпадение хешей)

═══ Требование 5: разделение зависимостей ═══

  Разделение зависимостей: lab:final

  ✓ модуль pytest       отсутствует
  ✓ модуль coverage     отсутствует
  ✓ модуль _pytest      отсутствует
  ✓ модуль pluggy       отсутствует
  ✓ путь   /app/tests                   отсутствует
  ✓ путь   /app/requirements-dev.txt    отсутствует
  ✓ путь   /app/pyproject.toml          отсутствует
  ✓ путь   /app/.pytest_cache           отсутствует

  нарушений: 0

  нарушений в production: 0
  нарушений в тестовом образе (контроль): 7
  ✓ production чист, и проверка доказала свою работоспособность на тестовом образе

═══ Требование 6: выгрузка отчётов без запуска container ═══
    выгружено файлов: 3
      coverage.xml
      junit.xml
      passed.txt
    покрытие строк: 100 %
    пути в отчёте: ['/app']
    тестов в junit.xml: 11, падений: 0
  ✓ отчёты выгружены через --output, container не запускался

═══ Требование 7: потеря кода возврата ═══
    вариант                            код сборки     образ создан
    ──────────────────────────────────────────────────────────────
    pytest | tee (без pipefail)        0              ДА
    то же с SHELL pipefail             1              НЕТ
    в логе первого: 1 failed in 0.04s
  ✓ ловушка воспроизведена: конвейер скрыл падение, pipefail исправил

═══ ИТОГ ═══
  все требования выполнены

Все требования выполнены.

Требование 1 — самое ценное. Строка «маркер не найден» рядом со строкой «образ собран: да» показывает всю суть проблемы: сборка успешна, тесты не выполнялись, и в выводе нет ничего, что бы на это указало.

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

Требование 5 проверяет проверку. Утверждение «в production-образе нет pytest» подтверждается нулём нарушений — но ноль получился бы и у сломанного скрипта, всегда возвращающего успех. Запуск той же проверки на тестовом образе даёт 7 нарушений и доказывает, что она способна их находить. Без контрольного запуска результат «0 нарушений» ничего не значит.

Требование 4 сравнивает хеш файла, а не факт его наличия. Наличие src/pricing.py в обоих образах не доказывает, что это один и тот же файл: стадии могли разойтись. Совпадение sha256 доказывает. Это же выявило бы ситуацию, когда в production попадает файл, изменённый после прогона тестов.

Ловушка с tee воспроизведена в обоих вариантах в одном прогоне. Показать только исправленный вариант — значит попросить поверить на слово. Таблица из двух строк с кодами 0 и 1 при одинаковом падающем тесте делает утверждение проверяемым, а разницу — наглядной.

Чего решение не делает. Отчёт о покрытии выгружен, но не проверено, что раздел [tool.coverage.paths] действительно сопоставляет пути: для этого нужно объединить отчёт из container'а с отчётом с машины, что требует второго прогона вне Docker. Быстрая итерация через монтирование в решении не задействована — она удобна в работе, но проверять её автоматически нечем: результат совпадает с обычным прогоном. Проверка разделения зависимостей перечисляет известные пакеты; неизвестный dev-пакет она не найдёт, для полноты нужен разбор установленного через pip list и сравнение со списком production-зависимостей.

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

bash
docker build --no-cache . 2>&1 | grep -c 'passed'
docker build --target test -t app:test . && docker run --rm app:test python -m pytest -q
docker run --rm --entrypoint python ОБРАЗ -c "import pytest" 2>&1 | tail -1
docker build --target export --output type=local,dest=./reports .

Первая команда должна найти строку о пройденных тестах — иначе стадия недостижима.

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

ОшибкаПричинаИсправление
Стадия test без ссылокКажется, что стадии выполняются по порядкуBuildKit её пропустит; COPY --from=test
test ветвится от base параллельно productionСимметрично выглядитТестируется не то, что поедет; FROM production AS test
RUN pytest | tee logНужны логиКод возврата от tee; SHELL с pipefail
RUN pytest || trueОставили после отладкиТесты не останавливают сборку
Единый requirements.txtПрощеTest-зависимости попадают в production
Отчёт остаётся внутри образаНе знают про --outputОтдельная стадия и type=local
Пути в отчёте не совпадаютРазные каталогиРаздел [tool.coverage.paths]
Проверка разделения не проверена сама«Ноль нарушений — значит чисто»Запустить на образе, где нарушение есть
Только монтирование, без прогона в образеБыстрееСкрывает «файл не скопирован в образ»
USER app перед установкой в стадии testПо аналогии с productionУстановка требует прав; USER root в test

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

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

  1. Почему стадия test может не выполниться при docker build .?
  2. Чем FROM production AS test лучше, чем FROM base AS test?
  3. Почему RUN pytest | tee log не останавливает сборку при падении теста?
  4. Что делает --output type=local и чем это лучше docker cp?
  5. Зачем нужен раздел [tool.coverage.paths]?

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

  1. Как сделать невозможной сборку production-образа без прохождения тестов?
  2. Как убедиться, что pytest не попал в production-образ?
  3. Как организовать быструю итерацию и чем за это платят?

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

  1. Сборка проходит, но в логе нет ни слова о тестах. Гипотеза?
  2. Отчёт о покрытии показывает 0 % при явно работающих тестах. Причина?

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

  1. BuildKit строит только достижимые от цели стадии — стадия test без ссылок пропускается.
  2. Сборка при этом успешна, и в выводе нет признаков проблемы.
  3. COPY --from=test в финальной стадии делает тесты обязательными.
  4. FROM production AS test гарантирует, что проверяется отгружаемый артефакт.
  5. В стадии test переключаются на root для установки пакетов — на production это не влияет.
  6. RUN останавливает сборку при ненулевом коде; конвейер этот код теряет.
  7. SHELL ["/bin/bash", "-o", "pipefail", "-c"] возвращает поведение конвейера к ожидаемому.
  8. Test-зависимости держат в отдельном файле и проверяют их отсутствие в образе.
  9. Проверку разделения нужно проверить на образе, где нарушение заведомо есть.
  10. --output type=local выгружает файлы из стадии, не создавая container.
  11. Стадия для выгрузки строится на scratch и содержит только отчёты.
  12. Монтирование кода ускоряет цикл, но скрывает ошибки сборки образа.

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

ИсточникСсылкаЧто подтверждает
Docker: multi-stage buildshttps://docs.docker.com/build/building/multi-stage/Стадии, --target
Docker: build exportershttps://docs.docker.com/build/exporters/local-tar/--output type=local
Docker: SHELLhttps://docs.docker.com/reference/dockerfile/#shellСмена оболочки, pipefail
Docker: RUNhttps://docs.docker.com/reference/dockerfile/#runОстановка при ненулевом коде
pytest: exit codeshttps://docs.pytest.org/en/stable/reference/exit-codes.htmlКоды возврата
coverage.py: [paths]https://coverage.readthedocs.io/en/latest/config.html#pathsСопоставление путей
pytest-covhttps://pytest-cov.readthedocs.io/Формирование отчётов

Навигация

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

Markdown на GitHub ↗