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 можуть парсити цей файл для відображення diff покриття в рядку. Якщо 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 + покриття

#
Цей покроковий посібник будує повне 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` тощо), які не належать до виробничих інсталяцій. Зробіть push цього файлу і відкрийте 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-інтеграцій можуть відображати як diff у pull requests. Оновлюємо `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 # запобігає вимозі tox 4 щодо PEP 517; видаліть, якщо є pyproject.toml [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` з будь-якими аргументами, які користувач передає через tox Також напишіть команду для: 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