Learning Platform
Урок 10.04 · 24 мин
Начальный
CI/CDGitHub Actionscoveragepytest-covpre-commit
Production DE workflow: uv + ruff + mypy + pytest + caching pre-commit framework: запускать lint и tests до коммита

От «у меня работает» к «работает у всех»

В прошлых уроках мы научились писать тесты и моки. Локально у вас всё зелёное. Но в команде из пяти человек ничего не помешает Васе закоммитить код, не запустив pytest. Через неделю билд сломан, никто не знает, какой именно коммит виноват.

Решение —

CI/CD
.
Continuous Integration
: на каждый PR (или push в main) автоматически запускается весь набор проверок — линтер, тайпчекер, тесты, билд.
Continuous Delivery
: если все проверки прошли — артефакт автоматически готов к деплою (docker-образ собран, package опубликован).

Если на старте у вас один разработчик — CI всё равно нужен. Это страховка от вашего же будущего «у меня же работало». Прошёл год — вы вернулись к проекту, что-то поменяли, не помните, как запускать тесты. CI запустит сам, скажет «зелёное» или «красное».

Стандарт де-факто для open-source и почти всех публичных репозиториев в 2026 —

GitHub Actions
. Бесплатно для публичных репозиториев, ~2000 минут в месяц для приватных. На нём мы и остановимся.

Анатомия workflow

Все CI-конфиги для GitHub Actions лежат в одном месте — .github/workflows/*.yml. Минимальный «Hello CI»:

# .github/workflows/ci.yml
name: CI

on:
  push:
    branches: [main]
  pull_request:

jobs:
  test:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: astral-sh/setup-uv@v3
      - run: uv sync --all-groups
      - run: uv run pytest

Разберём по строчкам:

  • name: CI — отображается в UI GitHub. Можно опустить.
  • on: — что запускает workflow. push: branches: [main] — каждый push в main. pull_request (без фильтров) — каждое создание/обновление PR. Это триггер.
  • jobs: — список параллельных работ. У нас одна — test.
  • runs-on: ubuntu-latest — на какой машине запускать. Ubuntu, Windows, macOS. Для Python-проектов почти всегда ubuntu-latest.
  • steps: — последовательность шагов внутри job.
  • uses: actions/checkout@v4 — готовый
    action
    , который клонирует репозиторий на runner.
  • uses: astral-sh/setup-uv@v3 — официальный action от Astral, устанавливает uv на runner.
  • run: ... — шелл-команда. Здесь — uv sync (установить зависимости из uv.lock) и uv run pytest.

При push’е в main или открытии PR GitHub поднимет ubuntu-runner, выполнит эти 4 шага и покажет вам зелёный/красный значок в UI.

Жизненный цикл CI на каждый PR

Триггер → runner → шаги. Если хоть один шаг fail — весь workflow красный.

eventpush / pull_requestТриггер из секции on:
GitHubподнимает runner
checkoutклон репоactions/checkout — клонирует код на runner
setupuv install
installuv syncУстановка всех зависимостей из uv.lock
checksruff + mypy + pytest
statusgreen / redGitHub показывает значок на PR. Можно настроить блокировку merge при красном.
mergeразрешён только при зелёном

Полноценный pipeline для DE-проекта

Минимальный для боевого проекта — линтер + тайпчекер + тесты. Соберём:

# .github/workflows/ci.yml
name: CI

on:
  push:
    branches: [main]
  pull_request:

jobs:
  quality:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4

      - uses: astral-sh/setup-uv@v3
        with:
          enable-cache: true     # кеширует uv-cache между запусками

      - name: Install Python
        run: uv python install 3.13

      - name: Sync deps
        run: uv sync --all-groups

      - name: Ruff format check
        run: uv run ruff format --check .

      - name: Ruff lint
        run: uv run ruff check .

      - name: Mypy
        run: uv run mypy src/

      - name: Pytest
        run: uv run pytest --cov=src --cov-report=term --cov-report=xml

Что важно:

  • enable-cache: true в setup-uv — между запусками pytest pip-кеш не скачивается заново. Экономит 30-60 секунд.
  • Каждая проверка — отдельный шаг. Если упадёт mypy, в UI это будет видно: «mypy red, остальное зелёное». Сравните с одним мешаниной uv run ruff && uv run mypy && uv run pytest — там придётся читать лог.
  • --cov=src --cov-report=xml — про coverage сейчас расскажем.

Matrix: проверка на нескольких версиях Python

Если ваша библиотека должна поддерживать Python 3.11, 3.12, 3.13 — пишем

матрицу
:

jobs:
  test:
    runs-on: ubuntu-latest
    strategy:
      fail-fast: false
      matrix:
        python-version: ["3.11", "3.12", "3.13"]
    steps:
      - uses: actions/checkout@v4
      - uses: astral-sh/setup-uv@v3
      - run: uv python install ${{ matrix.python-version }}
      - run: uv sync --all-groups --python ${{ matrix.python-version }}
      - run: uv run --python ${{ matrix.python-version }} pytest

GitHub запустит три параллельных job’a, каждый с своим Python. Если на 3.11 упало, а на 3.13 зелёное — увидите.

fail-fast: false — не отменять остальные job’ы при первом упавшем. Хочется увидеть полную картину, а не «упало на 3.11, остальное непонятно».

Для прикладного DE-проекта матрицы по версиям обычно нет — у вас один Python в продакшене (3.13). Матрица актуальна для библиотек, которые ставят в чужие проекты.

Coverage: что покрыто, что нет

Coverage
— метрика «какой процент кода покрыт хотя бы одним тестом». Не идеальная (можно прогнать каждую строчку, но не проверить ни одного результата), но полезная: показывает, где совсем не тестировано.

В Python мы пользуемся

pytest-cov
:

uv add --group dev pytest-cov

Запуск:

uv run pytest --cov=src --cov-report=term --cov-report=html

Что говорят флаги:

  • --cov=src — измерять coverage кода в папке src/ (там лежит наш пакет).
  • --cov-report=term — сводка в терминал.
  • --cov-report=html — генерирует HTML-отчёт в htmlcov/ с разбивкой по файлам, кликабельными строками.

Вывод в терминал:

---------- coverage: platform darwin, python 3.13 -----------
Name                       Stmts   Miss  Cover
----------------------------------------------
src/my_etl/__init__.py         2      0   100%
src/my_etl/parser.py          24      2    92%
src/my_etl/sync.py            18      0   100%
src/my_etl/loader.py          31     10    68%
----------------------------------------------
TOTAL                         75     12    84%

Stmts — сколько строк исполняемого кода. Miss — сколько не покрыто. Cover — процент.

HTML-версия (htmlcov/index.html) показывает построчно: непокрытые строки — красным, покрытые — зелёным. Идеально для разбора «куда воткнуть ещё тест».

Line vs branch coverage

По умолчанию pytest-cov считает line coverage — пройдена ли вообще строка. Но строка вроде if x > 0: может быть пройдена, а ветка else — нет. Это

branch coverage
:

uv run pytest --cov=src --cov-branch --cov-report=term

Тогда вывод покажет дополнительно Branch/BrPart/Cover с веточными метриками. Это честнее, но строже — обычно процент проседает на 5-15 пунктов.

Какой target coverage брать

Распространённое заблуждение — «надо 100%». На практике 100% — это дорого и часто бесполезно: вы пишете тесты на тривиальный код (геттеры, dataclass’ы) или на код, который и так невозможно сломать (импорты, константы).

Разумный target:

  • 70-80% line coverage — для прикладного DE-проекта. Покрыта вся бизнес-логика, не тестируется boilerplate.
  • 80-90% — для библиотек.
  • >95% — только для критичной инфраструктуры (платежи, security).

Уменьшение полезности — нелинейное: первые 70% — это easy wins, с 70% до 90% — упор, с 90% до 100% — мучения за тривиальные строки. Не геройствуйте.

В CI можно поставить минимальный порог через --cov-fail-under:

- run: uv run pytest --cov=src --cov-fail-under=80

Если процент опустится ниже 80% — workflow упадёт. Защита от «забыл написать тест к новой фиче».

Исключения из coverage

Не всё имеет смысл покрывать. В pyproject.toml или .coveragerc:

[tool.coverage.run]
source = ["src"]
branch = true
omit = [
    "*/tests/*",
    "*/__main__.py",
    "src/my_etl/migrations/*",
]

[tool.coverage.report]
exclude_lines = [
    "pragma: no cover",
    "raise NotImplementedError",
    "if __name__ == .__main__.:",
    "if TYPE_CHECKING:",
]

exclude_lines — регулярки. Строки, которые матчатся, исключаются из подсчёта. Стандартный набор:

  • pragma: no cover — комментарий, который вы ставите вручную над специально неисполняемой строкой.
  • raise NotImplementedError — заглушки в абстрактных методах.
  • if __name__ == "__main__": — точка входа, тестировать её бессмысленно.
  • if TYPE_CHECKING: — блок только для тайпчекера, в runtime не исполняется.

pre-commit + CI: дублирование с пользой

Линтер, форматтер, mypy — это всё можно (и нужно) запускать локально перед коммитом, через

pre-commit
. Это разбиралось в уроке 03 модуля 2. Зачем тогда CI?

Потому что pre-commit:

  • Можно обойти через git commit --no-verify.
  • Может не стоять у нового члена команды.
  • Не запускает тяжёлые проверки (тесты).

CI — это обязательная сетка контроля. Pre-commit — удобство, чтобы не получить «красный» PR при пуше. Оба нужны, они не конкурируют.

Эталонная схема:

  • Локально (pre-commit, на каждый commit): ruff format, ruff check --fix. Быстро, перенастраивает код, не блокирует.
  • CI (на каждый PR): всё, что было локально + mypy + pytest + coverage. Долго, но обязательно.

DE-кейс: полный workflow ETL-пакета

Соберём финальный .github/workflows/ci.yml для DE-проекта на 50 строк:

name: CI

on:
  push:
    branches: [main]
  pull_request:

# Отменять предыдущие запуски при новых push'ах в тот же PR.
concurrency:
  group: ${{ github.workflow }}-${{ github.ref }}
  cancel-in-progress: true

jobs:
  quality:
    name: Lint + Type + Test
    runs-on: ubuntu-latest
    timeout-minutes: 10

    steps:
      - uses: actions/checkout@v4

      - uses: astral-sh/setup-uv@v3
        with:
          enable-cache: true
          cache-dependency-glob: "uv.lock"

      - name: Install Python 3.13
        run: uv python install 3.13

      - name: Sync deps
        run: uv sync --all-groups --frozen

      - name: Ruff format check
        run: uv run ruff format --check .

      - name: Ruff lint
        run: uv run ruff check .

      - name: Mypy
        run: uv run mypy src/

      - name: Pytest with coverage
        run: |
          uv run pytest \
            --cov=src \
            --cov-branch \
            --cov-report=term \
            --cov-report=xml \
            --cov-fail-under=80

      - name: Upload coverage XML
        if: always()    # даже если pytest упал
        uses: actions/upload-artifact@v4
        with:
          name: coverage-xml
          path: coverage.xml

Тонкости:

  • concurrency — если в PR пришло три push’а подряд, отменит первые два, запустит только последний. Экономит минуты на CI.
  • timeout-minutes: 10 — гарантия, что зависший job не съест всю квоту.
  • uv sync --frozen — fail, если uv.lock устарел относительно pyproject.toml. Защита от «забыл закоммитить lockfile».
  • if: always() на upload coverage — даже если тест упал, выгрузим отчёт. Можно скачать из UI GitHub и посмотреть.

При желании coverage можно

отправлять в Codecov
через codecov/codecov-action@v4 — он показывает в PR, насколько новые строки покрыты тестами. Для приватных проектов — платно; для публичных — бесплатно.

Awareness: что ещё есть

Несколько вещей, которые junior’у полезно знать что они существуют, даже если не применять прямо сейчас:

  • Dependabot
    — автоматически создаёт PR’ы на обновление зависимостей (.github/dependabot.yml). Без этого ваши requests/httpx стареют, копятся уязвимости. Конфиг — 10 строк, ROI огромный.
  • Branch protection rules в GitHub UI — запретить merge в main, если CI не зелёный. Должно быть включено в любом командном репозитории.
  • workflow_dispatch триггер — добавить ручной запуск workflow из UI. Удобно для «прогнать тесты по требованию» или «задеплоить версию X в staging».
  • Self-hosted runners — свои машины как CI-runner’ы. Нужны, если CI должен иметь доступ к внутренней инфраструктуре (тестовый Postgres-кластер в private VPC). На junior-этапе не понадобится.
  • Tox / nox — Python-фреймворки для запуска тестов на разных версиях/конфигах локально. Дублируют matrix в CI. С приходом uv и uv run --python нужда в них значительно упала.
  • GitLab CI
    , Jenkins, CircleCI — альтернативные CI. Концепции одни, синтаксис разный. Если устроились в компанию с GitLab — на привыкание уйдут пол-дня.

Что мы получили

  • CI/CD — автоматический контроль на каждый PR. Защищает main от плохих коммитов.
  • GitHub Actions: .github/workflows/*.yml, триггеры через on:, шаги через steps:.
  • Базовый pipeline: checkout → setup-uv → install Python → uv sync → ruff format → ruff check → mypy → pytest.
  • astral-sh/setup-uv@v3 + enable-cache — быстрый и кешированный uv.
  • Matrix — параллельная проверка на разных версиях Python / ОС. Для библиотек обязательно.
  • Coverage через pytest-cov. Target: 70-80% line + branch. Не геройствовать ради 100%.
  • --cov-fail-under=80 блокирует регрессии coverage.
  • pre-commit (локально, не блокирует) + CI (удалённо, обязательно) — два уровня контроля.
  • Awareness: Dependabot, branch protection, GitLab CI как альтернатива.

Это был последний урок модуля про качество. Дальше — Capstone в модуле 10, где мы соберём всё пройденное в один настоящий DE-проект: тянем данные с API, чистим, пишем в Postgres, всё под тестами и CI.

Закончили урок?

Отметьте его как пройденный, чтобы отслеживать свой прогресс

Войдите чтобы оценить урок

Прогресс модуля
0 из 4