От «у меня работает» к «работает у всех»
В прошлых уроках мы научились писать тесты и моки. Локально у вас всё зелёное. Но в команде из пяти человек ничего не помешает Васе закоммитить код, не запустив pytest. Через неделю билд сломан, никто не знает, какой именно коммит виноват.
Решение —
Если на старте у вас один разработчик — CI всё равно нужен. Это страховка от вашего же будущего «у меня же работало». Прошёл год — вы вернулись к проекту, что-то поменяли, не помните, как запускать тесты. CI запустит сам, скажет «зелёное» или «красное».
Стандарт де-факто для open-source и почти всех публичных репозиториев в 2026 —
Анатомия 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— готовый, который клонирует репозиторий на runner.actionuses: 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.
Триггер → runner → шаги. Если хоть один шаг fail — весь workflow красный.
Полноценный 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: что покрыто, что нет
В Python мы пользуемся
pytest-covuv 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 — нет. Это
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:
- Можно обойти через
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-action@v4 — он показывает в PR, насколько новые строки покрыты тестами. Для приватных проектов — платно; для публичных — бесплатно.
Awareness: что ещё есть
Несколько вещей, которые junior’у полезно знать что они существуют, даже если не применять прямо сейчас:
- — автоматически создаёт PR’ы на обновление зависимостей (Dependabot
.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нужда в них значительно упала. - , Jenkins, CircleCI — альтернативные CI. Концепции одни, синтаксис разный. Если устроились в компанию с GitLab — на привыкание уйдут пол-дня.GitLab CI
Что мы получили
- 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.