Python · Тестирование с pytest · Экспертный

Интеграция с CI/CD

5 задач

Запускайте pytest автоматически в CI-пайплайнах с помощью GitHub Actions и tox.

Запуск pytest в CI

#
Когда тесты запускаются только на ноутбуке разработчика, они перестают делать свою настоящую работу: обнаруживать регрессии до того, как код попадёт в продакшн. Непрерывная интеграция (CI) -- это практика автоматического запуска полного тест-сьюта при каждом push или pull request на чистой машине, ничего не знающей о вашей локальной настройке. Два инструмента, с которыми вы будете встречаться чаще всего -- **GitHub Actions** (встроен в GitHub, бесплатен для публичных репозиториев) и **tox** (Python-специфичный инструмент автоматизации тестов). ## Почему CI -- это не просто "pytest на сервере" Запуск pytest в CI вводит ограничения, которых нет локально: - **Никаких предустановленных пакетов.** CI-раннер стартует с базового образа ОС. Ваш воркфлоу должен устанавливать каждую зависимость. - **Никаких `.env`-файлов.** Секреты и переменные окружения должны предоставляться через хранилище секретов CI-системы. - **Ненулевой код выхода важен.** pytest завершается с кодом `1` когда любой тест падает. CI-платформы воспринимают ненулевой выход как сбой пайплайна -- pull request не может быть слит. - **Воспроизводимость.** Тест, проходящий локально но падающий в CI, почти всегда означает что тест зависит от чего-то в вашем окружении (глобально установленный пакет, локальный файл, запущенный сервис). ## Основы GitHub Actions GitHub Actions запускает воркфлоу, определённые как YAML-файлы в `.github/workflows/`. Воркфлоу срабатывает на события (push, pull request, расписание) и выполняет последовательность шагов внутри раннера -- временной виртуальной машины. Минимальная структура воркфлоу для pytest: ```yaml name: Tests on: [push, pull_request] jobs: test: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - name: Set up Python uses: actions/setup-python@v5 with: python-version: "3.12" - name: Install dependencies run: pip install -r requirements.txt - name: Run tests run: pytest ``` Каждая строка `uses:` подключает заранее подготовленное действие. `actions/checkout@v4` клонирует репозиторий в раннер. `actions/setup-python@v5` устанавливает запрошенную версию Python и добавляет её в `PATH`. ## Кэширование загрузок pip Установка пакетов с нуля при каждом запуске медленная. GitHub Actions позволяет кэшировать кэш загрузок pip между запусками: ```yaml - name: Cache pip uses: actions/cache@v4 with: path: ~/.cache/pip key: ${{ runner.os }}-pip-${{ hashFiles('requirements.txt') }} restore-keys: | ${{ runner.os }}-pip- ``` `key` включает хеш `requirements.txt`, поэтому кэш сбрасывается при изменении зависимостей. `restore-keys` обеспечивает запасной вариант, соответствующий ОС даже при промахе точного ключа -- он восстанавливает частичный кэш, который всё равно быстрее чем начинать с нуля. ## Провал пайплайна при падении покрытия Покрытие измеряет, какой процент кода выполняется тестами. Добавление `--cov` к pytest (с установленным `pytest-cov`) создаёт отчёт покрытия. Добавление `--cov-fail-under=80` заставляет pytest завершаться с кодом `2` при падении покрытия ниже 80%, что проваливает CI-пайплайн: ```yaml - name: Run tests with coverage run: pytest --cov=src --cov-fail-under=80 --cov-report=term-missing --cov-report=xml ``` `--cov-report=xml` записывает `coverage.xml` в формате Cobertura -- многие CI-интеграции и инструменты проверки PR могут парсить этот файл для отображения дифф покрытия. Если XML-файл не нужен, флаг можно опустить. `--cov=src` указывает pytest-cov какой каталог измерять (исходный код, а не сами тесты). `--cov-report=term-missing` печатает таблицу, показывающую непокрытые строки. Порог -- это политическое решение. 80% -- распространённая отправная точка; 100% часто нецелесообразны и контрпродуктивны (вы в итоге пишете тесты, которые не тестируют ничего значимого, лишь бы достичь числа). ## Тестирование на нескольких версиях Python с tox `tox` -- инструмент, создающий изолированные виртуальные окружения и запускающий тест-сьют в каждом. Наиболее полезен авторам библиотек, которым нужно проверять совместимость с несколькими версиями Python, но также распространён в прикладных проектах, которым нужна поддержка разных сред. Минимальный `tox.ini`: ```ini [tox] envlist = py311, py312 [testenv] deps = -r requirements.txt commands = pytest {posargs} ``` Запуск `tox` локально создаёт виртуальные окружения `.tox/py311/` и `.tox/py312/`, устанавливает зависимости в каждое и запускает `pytest` внутри каждого. `{posargs}` передаёт любые аргументы, которые вы добавляете после `tox --`, напрямую в pytest (`tox -- -k test_login`). В GitHub Actions можно использовать **матрицу** для запуска tox на нескольких версиях: ```yaml strategy: matrix: python-version: ["3.11", "3.12"] steps: - uses: actions/checkout@v4 - uses: actions/setup-python@v5 with: python-version: ${{ matrix.python-version }} - run: pip install tox - run: tox -e py${{ matrix.python-version | replace('.', '') }} ``` Это запускает два параллельных задания -- по одному на каждую версию -- и помечает воркфлоу как провалившийся если любое из них падает. ## Настройка pytest для CI Вы можете хранить CI-специфичные дефолты pytest в `pytest.ini` (или `pyproject.toml`), чтобы каждый запуск -- локальный и CI -- использовал одинаковые настройки: ```ini [pytest] addopts = -v --tb=short testpaths = tests ``` `--tb=short` даёт достаточно трассировки для диагностики падений без заполнения лога шумом. `-v` (подробный) печатает имя каждого теста при запуске, что помогает при чтении CI-логов видеть какой тест упал. ## Отчёты о результатах тестов Некоторые CI-системы и интеграции GitHub могут отображать результаты тестов как структурированный отчёт, а не сырой вывод терминала. pytest может создавать JUnit XML-файл: ``` pytest --junitxml=reports/test-results.xml ``` GitHub Actions может затем загрузить его как артефакт: ```yaml - name: Upload test results uses: actions/upload-artifact@v4 with: name: test-results path: reports/test-results.xml ``` Это не меняет прохождение или провал пайплайна -- просто делает результаты доступными для скачивания после запуска, что полезно при отладке непостоянных сбоев.

Полный CI-воркфлоу: GitHub Actions + tox + coverage

#
Это пошаговое руководство строит полную CI-настройку начиная с простого запуска pytest и добавляя покрытие, кэширование, тестирование на нескольких версиях и загрузку артефактов. ## Предполагаемая структура проекта ``` my_project/ +-- src/ | +-- my_project/ | +-- __init__.py | +-- calculator.py +-- tests/ | +-- __init__.py | +-- test_calculator.py +-- requirements.txt +-- requirements-dev.txt +-- pytest.ini +-- tox.ini +-- .github/ +-- workflows/ +-- tests.yml ``` ## Шаг 1 -- Базовый воркфлоу Создайте `.github/workflows/tests.yml`: ```yaml name: Tests on: push: branches: [main] pull_request: branches: [main] jobs: test: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - name: Set up Python 3.12 uses: actions/setup-python@v5 with: python-version: "3.12" - name: Install dependencies run: | pip install -r requirements.txt pip install -r requirements-dev.txt - name: Run tests run: pytest ``` `requirements-dev.txt` содержит зависимости только для тестирования (`pytest`, `pytest-cov` и т.д.), которым не место в продакшн-установках. Запушьте этот файл и откройте pull request. GitHub покажет зелёную галочку (или красный крест) в PR. ## Шаг 2 -- Добавить кэширование pip ```yaml - name: Cache pip downloads uses: actions/cache@v4 with: path: ~/.cache/pip key: ${{ runner.os }}-pip-${{ hashFiles('requirements*.txt') }} restore-keys: | ${{ runner.os }}-pip- ``` Поместите этот шаг **перед** `Install dependencies`. Паттерн `hashFiles` соответствует обоим файлам `requirements.txt` и `requirements-dev.txt` -- изменение любого из них инвалидирует кэш. ## Шаг 3 -- Добавить покрытие с порогом Обновите `requirements-dev.txt`: ``` pytest pytest-cov ``` Обновите шаг запуска тестов: ```yaml - name: Run tests with coverage run: pytest --cov=src --cov-fail-under=80 --cov-report=term-missing --cov-report=xml ``` `--cov-report=xml` записывает `coverage.xml` в стандартном формате Cobertura, который многие CI-интеграции могут отображать как дифф в pull request. Обновите `pytest.ini`, чтобы локальные запуски тоже использовали подробный вывод и короткие трассировки: ```ini [pytest] addopts = -v --tb=short testpaths = tests ``` ## Шаг 4 -- Загрузить покрытие как артефакт ```yaml - name: Upload coverage report uses: actions/upload-artifact@v4 with: name: coverage-report path: coverage.xml if: always() ``` `if: always()` загружает артефакт даже если предыдущий шаг упал (например, порог покрытия не выполнен). Это позволяет просматривать отчёт, чтобы видеть что не покрыто. ## Шаг 5 -- Добавить tox для тестирования на нескольких версиях `tox.ini`: ```ini [tox] envlist = py311, py312 isolated_build = false [testenv] deps = -r requirements.txt -r requirements-dev.txt commands = pytest {posargs} --cov=src --cov-report=term-missing --cov-report=xml ``` Обновите воркфлоу для использования матрицы: ```yaml jobs: test: runs-on: ubuntu-latest strategy: fail-fast: false matrix: python-version: ["3.11", "3.12"] steps: - uses: actions/checkout@v4 - name: Set up Python ${{ matrix.python-version }} uses: actions/setup-python@v5 with: python-version: ${{ matrix.python-version }} - name: Cache pip uses: actions/cache@v4 with: path: ~/.cache/pip key: ${{ runner.os }}-pip-${{ matrix.python-version }}-${{ hashFiles('requirements*.txt') }} restore-keys: | ${{ runner.os }}-pip-${{ matrix.python-version }}- - name: Install tox run: pip install tox - name: Run tox run: tox -e py${{ matrix.python-version | replace('.', '') }} ``` `fail-fast: false` означает что если 3.11 падает, 3.12 всё равно запускается -- вы видите падения для всех версий, а не только первой. ## Шаг 6 -- Добавить JUnit XML отчёт Передайте `--junitxml=reports/test-results.xml` через команды `tox.ini` -- не как отдельный шаг `pytest` в воркфлоу. Когда тесты запускаются через tox, `pytest` установлен внутри виртуального окружения tox, а не в базовом окружении раннера, поэтому отдельный шаг `run: pytest ...` завершится с ошибкой `command not found`. Обновите команды в `tox.ini`: ```ini commands = pytest {posargs} --cov=src --cov-report=term-missing --cov-report=xml --junitxml=reports/test-results.xml ``` Затем добавьте шаг загрузки в воркфлоу: ```yaml - name: Upload test results uses: actions/upload-artifact@v4 with: name: test-results-${{ matrix.python-version }} path: reports/test-results.xml if: always() ``` Суффикс `${{ matrix.python-version }}` предотвращает коллизии имён артефактов при наличии нескольких версий в матрице. ## Итоговый полный воркфлоу ```yaml name: Tests on: push: branches: [main] pull_request: branches: [main] jobs: test: runs-on: ubuntu-latest strategy: fail-fast: false matrix: python-version: ["3.11", "3.12"] steps: - uses: actions/checkout@v4 - name: Set up Python ${{ matrix.python-version }} uses: actions/setup-python@v5 with: python-version: ${{ matrix.python-version }} - name: Cache pip uses: actions/cache@v4 with: path: ~/.cache/pip key: ${{ runner.os }}-pip-${{ matrix.python-version }}-${{ hashFiles('requirements*.txt') }} restore-keys: | ${{ runner.os }}-pip-${{ matrix.python-version }}- - name: Install dependencies run: pip install tox - name: Run tests via tox run: tox -e py${{ matrix.python-version | replace('.', '') }} - name: Upload coverage uses: actions/upload-artifact@v4 with: name: coverage-${{ matrix.python-version }} path: coverage.xml if: always() - name: Upload test results uses: actions/upload-artifact@v4 with: name: test-results-${{ matrix.python-version }} path: reports/test-results.xml if: always() ```

Справочная карточка CI

#
## Структура GitHub Actions воркфлоу ```yaml name: Tests on: [push, pull_request] jobs: test: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - uses: actions/setup-python@v5 with: python-version: "3.12" - run: pip install -r requirements.txt - run: pytest ``` ## Кэш pip ```yaml - uses: actions/cache@v4 with: path: ~/.cache/pip key: ${{ runner.os }}-pip-${{ hashFiles('requirements*.txt') }} restore-keys: ${{ runner.os }}-pip- ``` ## Флаги покрытия | Флаг | Эффект | |---|---| | `--cov=src` | Измерять покрытие для каталога `src/` | | `--cov-report=term-missing` | Печатать непокрытые строки в терминал | | `--cov-report=xml` | Записать `coverage.xml` (формат Cobertura) | | `--cov-fail-under=80` | Код выхода 2 при покрытии < 80% (проваливает CI) | ## Структура tox.ini ```ini [tox] envlist = py311, py312 [testenv] deps = -r requirements.txt -r requirements-dev.txt commands = pytest {posargs} ``` Локальный запуск: `tox` (все окружения) или `tox -e py312` (одно окружение). ## Стратегия матрицы (несколько версий) ```yaml strategy: fail-fast: false matrix: python-version: ["3.11", "3.12"] ``` Обращение к значению: `${{ matrix.python-version }}` ## pytest.ini для паритета CI и локальной среды ```ini [pytest] addopts = -v --tb=short testpaths = tests ``` ## Загрузка артефакта ```yaml - uses: actions/upload-artifact@v4 with: name: coverage-report path: coverage.xml if: always() ``` `if: always()` -- загружает даже при падении предыдущих шагов. ## JUnit XML отчёт ``` pytest --junitxml=reports/test-results.xml ``` ## Распространённые причины сбоев в CI | Симптом | Вероятная причина | |---|---| | `ModuleNotFoundError` | Пакет не в `requirements.txt`; отсутствует шаг установки | | Тесты проходят локально, падают в CI | Зависимость от локального файла, переменной окружения или запущенного сервиса | | Покрытие ниже порога | Добавлен новый код без тестов; неверный путь `--cov=` | | Медленный CI (2+ мин установка) | Кэш pip не настроен или неверный ключ кэша | | Коллизия имён артефактов | Задания матрицы используют одно имя артефакта -- добавьте суффикс `${{ matrix.python-version }}` |
01

Написать GitHub Actions воркфлоу для запуска pytest

#

Создайте файл `.github/workflows/tests.yml` для проекта со следующей структурой: ``` my_project/ +-- src/ | +-- app.py +-- tests/ | +-- test_app.py +-- requirements-dev.txt # содержит: pytest ``` Требования к воркфлоу: 1. Срабатывать на `push` и `pull_request` в ветку `main` 2. Запускаться на `ubuntu-latest` с Python 3.12 3. Проверять код 4. Устанавливать зависимости из `requirements-dev.txt` 5. Запускать `pytest` Напишите полное содержимое YAML-файла воркфлоу.

# Напишите содержимое .github/workflows/tests.yml ниже.
# Это YAML-файл, а не Python -- заполните каждую секцию.

# name: ...

# on:
#   ...

# jobs:
#   test:
#     runs-on: ...
#     steps:
#       - ...
Решение
# .github/workflows/tests.yml
name: Tests

on:
  push:
    branches: [main]
  pull_request:
    branches: [main]

jobs:
  test:
    runs-on: ubuntu-latest

    steps:
      - uses: actions/checkout@v4

      - name: Set up Python 3.12
        uses: actions/setup-python@v5
        with:
          python-version: "3.12"

      - name: Install dependencies
        run: pip install -r requirements-dev.txt

      - name: Run tests
        run: pytest
02

Добавить покрытие с порогом сбоя

#

У вас есть следующий GitHub Actions воркфлоу: ```yaml name: Tests on: push: branches: [main] pull_request: branches: [main] jobs: test: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - uses: actions/setup-python@v5 with: python-version: "3.12" - name: Install dependencies run: pip install -r requirements-dev.txt - name: Run tests run: pytest ``` И `requirements-dev.txt`: ``` pytest ``` Сделайте два изменения: 1. Добавьте `pytest-cov` в `requirements-dev.txt` 2. Обновите шаг `Run tests` чтобы: - Измерять покрытие для каталога `src/` - Печатать непокрытые строки в терминал - Провалить пайплайн при падении покрытия ниже 80% Напишите обновлённый `requirements-dev.txt` и обновлённый шаг `Run tests`.

# requirements-dev.txt
pytest
# добавить pytest-cov здесь


# Обновлённый шаг 'Run tests' (только шаг, не весь воркфлоу):
#
#       - name: Run tests
#         run: pytest ...
Решение
# requirements-dev.txt
pytest
pytest-cov

# Обновлённый шаг в .github/workflows/tests.yml:
#
#       - name: Run tests with coverage
#         run: pytest --cov=src --cov-report=term-missing --cov-fail-under=80
03

Написать tox.ini для Python 3.11 и 3.12

#

Напишите `tox.ini` для проекта, где: - Тесты должны запускаться на Python 3.11 и 3.12 - Зависимости находятся в `requirements.txt` и `requirements-dev.txt` - Команда тестирования -- `pytest` с любыми аргументами, которые передаёт пользователь Также напишите команду для: 1. Запуска tox для всех окружений 2. Запуска tox только для Python 3.12 3. Передачи `-v` в pytest через tox

# tox.ini
[tox]
envlist = ...

[testenv]
deps =
    ...
commands =
    ...


# Команды (напишите в комментариях):
# 1. Запустить все окружения:
#    tox ...
#
# 2. Запустить только Python 3.12:
#    tox ...
#
# 3. Передать -v в pytest:
#    tox ...
Решение
# tox.ini
[tox]
envlist = py311, py312

[testenv]
deps =
    -r requirements.txt
    -r requirements-dev.txt
commands =
    pytest {posargs}


# Команды:
# 1. Запустить все окружения:
#    tox
#
# 2. Запустить только Python 3.12:
#    tox -e py312
#
# 3. Передать -v в pytest:
#    tox -- -v
04

Загрузить отчёт покрытия как CI-артефакт

#

У вас есть следующий шаг воркфлоу, запускающий тесты с покрытием: ```yaml - name: Run tests with coverage run: pytest --cov=src --cov-report=term-missing --cov-fail-under=80 --cov-report=xml ``` Добавьте шаг после него, который: 1. Загружает `coverage.xml` как GitHub Actions артефакт с именем `coverage-report` 2. Выполняется даже если предыдущий шаг упал (например, когда порог покрытия не выполнен) Напишите только новый шаг в YAML.

# Напишите шаг для добавления после 'Run tests with coverage':

#       - name: Upload coverage report
#         uses: ...
#         with:
#           ...
#         if: ...
Решение
      - name: Upload coverage report
        uses: actions/upload-artifact@v4
        with:
          name: coverage-report
          path: coverage.xml
        if: always()
05

Настроить pytest.ini для согласованных локальных и CI-запусков

#

Команда имеет пустой `pytest.ini` и CI-воркфлоу где шаг тестирования: ```yaml - name: Run tests run: pytest -v --tb=short --strict-markers tests/ ``` Проблема: разработчики, запускающие `pytest` локально, получают другой вывод чем CI (без `-v`, полные трассировки), и путь `tests/` приходится повторять как в файле воркфлоу, так и в любых локальных скриптах. Исправьте это, написав `pytest.ini` который: 1. Устанавливает `addopts` так чтобы `-v --tb=short --strict-markers` всегда применялись 2. Устанавливает `testpaths` так чтобы pytest всегда искал в `tests/` по умолчанию 3. Регистрирует три кастомные марки, чтобы избежать `PytestUnknownMarkWarning`: `unit`, `integration`, `slow` После этого изменения шаг CI должен быть просто `pytest` без дополнительных аргументов. Напишите полный `pytest.ini` и упрощённый CI-шаг.

# pytest.ini
[pytest]
addopts = ...
testpaths = ...
markers =
    ...


# Упрощённый CI-шаг (напишите в комментарии):
#       - name: Run tests
#         run: ...
Решение
# pytest.ini
[pytest]
addopts = -v --tb=short --strict-markers
testpaths = tests
markers =
    unit: fast unit tests with no I/O
    integration: tests that hit the database or network
    slow: tests that take more than 1 second


# Упрощённый CI-шаг:
#       - name: Run tests
#         run: pytest