Тесты в CI: quality gates, параллелизация, флаки-тесты, покрытие
Тест, который запускается только на ноутбуке автора, — не тест, а личная привычка. Он не защищает продукт: как только автор уйдёт в отпуск или уволится, привычка исчезнет вместе с ним. Тест начинает приносить пользу в тот момент, когда его запускает машина — на каждое изменение, без права забыть.
Эта статья — про инженерию вокруг тестов, а не про сами тесты. Мы уже разобрали, как писать юнит-тесты, интеграционные, E2E и нагрузочные. Теперь вопрос другой: как собрать всё это в конвейер, который даёт быстрый и доверенный сигнал, стоит разумных денег и не превращается в ритуал «перезапусти, обычно проходит».
Ключевое слово — доверие. Красная сборка должна означать «продукт сломан». Как только она начинает означать «наверное, опять инфраструктура», вы теряете всю ценность автоматизации, продолжая платить за неё полную цену.
Что вообще делает CI с тестами
Continuous Integration в исходном смысле (Кент Бек, потом Фаулер) — это практика, где каждый разработчик вливает работу в общую ветку минимум раз в день, и каждое вливание автоматически проверяется. Тесты здесь — не самоцель, а механизм проверки утверждения «главная ветка исправна».
Из этого следует всё остальное:
- Сигнал должен быть быстрым. Если проверка идёт час, вливать раз в день физически невозможно — вы просто будете накапливать изменения.
- Сигнал должен быть однозначным. Красный/зелёный, без «ну там два теста всегда падают, не обращай внимания».
- Сигнал должен быть привязан к изменению. Ночной прогон, который упал после сорока смёрженных PR, — это уже не CI, это археология.
в ветку"] --> B["Стадия 1: lint,
типы, сборка
~1 мин"] B -->|ошибка| X["Красный PR
автор чинит сам"] B --> C["Стадия 2: юнит-тесты
+ покрытие diff
~3 мин"] C -->|ошибка| X C --> D["Стадия 3: интеграционные
testcontainers, API
~8 мин"] D -->|ошибка| X D --> E["Стадия 4: E2E smoke
критичные сценарии
~12 мин"] E -->|ошибка| X E --> F["Слияние в main"] F --> G["Полный регресс,
нагрузка, DAST
ночью / по расписанию"] G -->|ошибка| H["Заводится баг,
но PR уже влит"] style B fill:#4caf82,fill-opacity:0.18 style C fill:#4caf82,fill-opacity:0.18 style D fill:#e0a252,fill-opacity:0.18 style E fill:#e0a252,fill-opacity:0.18 style G fill:#d1655a,fill-opacity:0.18
Обратите внимание на асимметрию: чем позже стадия, тем дороже её красный результат. Не потому, что баг хуже, а потому, что дороже переключение контекста у человека.
Эмпирика, которая работает почти везде: обратная связь на PR должна укладываться в 10–15 минут. Дальше начинается «пойду посмотрю почту», и стоимость каждого красного прогона удваивается. Если ваш пайплайн идёт 40 минут — это не «такой у нас проект», это инженерная задача, которая решается (параллелизацией, выносом медленного в ночь, отказом от лишних E2E).
Где расходятся разработчик и тестировщик
| Вопрос | Взгляд разработчика | Взгляд тестировщика |
|---|---|---|
| Зачем пайплайн | «Чтобы не сломать main и быстро мержить» | «Чтобы регресс проверялся сам и я занимался новым» |
| Что важнее | Скорость. Каждая минута ожидания — его минута | Полнота. Пропущенный сценарий — его репутация |
| Красный E2E | «Опять флак, перезапущу» | «Это может быть реальный баг, надо смотреть» |
| Покрытие | Метрика, которую с него требуют | Индикатор того, где тестов нет |
| Кто чинит автотест | «Это же ваш тест» | «Это же ваш код его сломал» |
Здоровая команда закрывает последнюю строку одним решением: автотесты живут в том же репозитории, что и код, и падение автотеста чинит автор PR — при необходимости вместе с QA. Если автотесты лежат отдельно и «принадлежат» отдельной команде, они гарантированно отстанут от продукта.
Quality gates: что именно блокирует слияние
Quality gate — формальное условие, при невыполнении которого изменение не идёт дальше. Главная ошибка новичков — сделать гейтом всё подряд. Через две недели команда научится обходить гейты, а не выполнять их.
Полезное разделение на три категории:
1. Блокирующие (hard gate) — не пропускают PR. Только то, что (а) детерминировано, (б) быстро, (в) однозначно указывает на дефект, внесённый этим PR.
- сборка и проверка типов;
- юнит-тесты — все, без исключений;
- интеграционные тесты критичного ядра;
- линтер с фиксированным набором правил (не «предупреждения»);
- отсутствие новых секретов в коде (gitleaks, trufflehog);
- отсутствие критичных уязвимостей в новых зависимостях;
- покрытие изменённых строк (diff coverage) не ниже порога.
2. Предупреждающие (soft gate) — видны в PR, но не блокируют.
- общий процент покрытия и его дельта;
- новые находки статического анализатора средней важности;
- рост времени сборки, размера бандла;
- результаты мутационного тестирования.
3. Асинхронные — идут после слияния, заводят задачи, а не блокируют.
- полный регрессионный прогон;
- нагрузочные тесты (см. нагрузочное тестирование);
- DAST и сканирование образов (см. тестирование безопасности);
- кроссбраузерная матрица (см. мобильное и кроссбраузерное).
Практическое правило формулируется грубо, но точно: гейтом может быть только то, что вы готовы чинить немедленно. Если на красный гейт команда реагирует «давайте временно отключим» — гейт был выбран неправильно.
Рабочий пример: GitHub Actions
Ниже полный пайплайн для Python-сервиса. Он умышленно содержит все важные детали: кэш, сервисы, разделение стадий, публикацию отчётов, diff coverage.
# .github/workflows/ci.yml
name: CI
on:
pull_request:
push:
branches: [main]
# Отменяем прошлые прогоны той же ветки: экономит минуты и очередь раннеров.
concurrency:
group: ci-${{ github.ref }}
cancel-in-progress: true
env:
PYTHON_VERSION: "3.12"
jobs:
static:
name: Линтеры и типы
runs-on: ubuntu-latest
timeout-minutes: 10 # без таймаута зависший job съест часы
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with:
python-version: ${{ env.PYTHON_VERSION }}
cache: pip
- run: pip install -r requirements-dev.txt
- run: ruff check .
- run: mypy src/
- name: Поиск секретов в коде
uses: gitleaks/gitleaks-action@v2
unit:
name: Юнит-тесты
runs-on: ubuntu-latest
timeout-minutes: 15
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0 # нужен для diff coverage относительно базы
- uses: actions/setup-python@v5
with:
python-version: ${{ env.PYTHON_VERSION }}
cache: pip
- run: pip install -r requirements-dev.txt
- name: Прогон с покрытием, -n auto распараллеливает по ядрам
run: |
pytest tests/unit \
-n auto \
--junitxml=reports/junit-unit.xml \
--cov=src --cov-report=xml:reports/coverage.xml \
--cov-report=term-missing
- name: Покрытие изменённых строк (жёсткий гейт)
run: |
diff-cover reports/coverage.xml \
--compare-branch=origin/${{ github.base_ref || 'main' }} \
--fail-under=80 \
--html-report reports/diff-cover.html
- name: Отчёты сохраняем всегда, даже если тесты упали
if: always()
uses: actions/upload-artifact@v4
with:
name: unit-reports
path: reports/
integration:
name: Интеграционные
runs-on: ubuntu-latest
needs: [static, unit] # не жжём раннеры, если базовое уже красное
timeout-minutes: 25
services:
postgres:
image: postgres:16
env:
POSTGRES_PASSWORD: test
options: >-
--health-cmd "pg_isready -U postgres"
--health-interval 5s --health-retries 10
ports: ["5432:5432"]
env:
DATABASE_URL: postgresql://postgres:test@localhost:5432/postgres
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with:
python-version: ${{ env.PYTHON_VERSION }}
cache: pip
- run: pip install -r requirements-dev.txt
- run: alembic upgrade head
- run: pytest tests/integration --junitxml=reports/junit-integration.xml
e2e:
name: E2E smoke (шардирование)
runs-on: ubuntu-latest
needs: [integration]
timeout-minutes: 30
strategy:
fail-fast: false # хотим видеть все упавшие шарды, а не первый
matrix:
shard: [1, 2, 3, 4]
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: "22"
cache: npm
- run: npm ci
- run: npx playwright install --with-deps chromium
- run: npx playwright test --shard=${{ matrix.shard }}/4 --grep @smoke
- if: failure()
uses: actions/upload-artifact@v4
with:
name: playwright-trace-${{ matrix.shard }}
path: test-results/ # трейсы и видео для разбора падения
Несколько неочевидных мест, которые чаще всего забывают:
concurrencyсcancel-in-progress— самая дешёвая экономия минут в природе. Пять пушей подряд перестают порождать пять полных прогонов.timeout-minutesна каждом job. Без него зависший тест держит раннер до глобального лимита (по умолчанию 6 часов).if: always()на выгрузке артефактов. Отчёт нужен именно тогда, когда упало.fetch-depth: 0— без полной истории diff coverage не сможет посчитать базу.fail-fast: falseдля матрицы E2E: иначе вы увидите один упавший шард и не узнаете, что во втором ещё пять падений.
То же самое в GitLab CI
# .gitlab-ci.yml
stages: [static, test, integration, e2e]
default:
image: python:3.12-slim
cache:
key:
files: [requirements-dev.txt] # ключ по хешу файла, а не по ветке
paths: [.cache/pip]
variables:
PIP_CACHE_DIR: "$CI_PROJECT_DIR/.cache/pip"
lint:
stage: static
script: [pip install -r requirements-dev.txt, ruff check ., mypy src/]
unit:
stage: test
parallel: 4 # GitLab сам выставит CI_NODE_INDEX/TOTAL
script:
- pip install -r requirements-dev.txt
- pytest tests/unit --junitxml=junit.xml
--cov=src --cov-report=xml:coverage.xml
--splits=$CI_NODE_TOTAL --group=$CI_NODE_INDEX
coverage: '/TOTAL.*?(\d+\.\d+)\%/' # регуляркой вытаскиваем % в UI мерж-реквеста
artifacts:
when: always
reports:
junit: junit.xml # падения видны прямо в MR
coverage_report:
coverage_format: cobertura
path: coverage.xml
Ключевая идея одинакова во всех системах: машиночитаемые отчёты (JUnit XML, Cobertura/LCOV) важнее человекочитаемых логов. Только они позволяют показать падение прямо в интерфейсе PR, собирать статистику и автоматически детектировать флаки. Если ваш прогон пишет результат только в stdout — вы отрезали себя от всей аналитики разом.
Параллелизация: как ужать час в семь минут
Тесты — задача, почти идеально поддающаяся распараллеливанию: каждый тест теоретически независим. На практике мешают три вещи: общее состояние (БД, файлы, порты), стоимость запуска окружения и неравномерность длительности тестов.
jest --maxWorkers
несколько ядер одной машины"] end subgraph L2["Уровень 2: между машинами"] A2["шардирование: --shard=2/4
матрица job в CI"] end subgraph L3["Уровень 3: между стадиями"] A3["независимые job идут одновременно:
линтер не ждёт юнит-тесты"] end L1 --> L2 --> L3 L3 --> R["Итог: время прогона стремится
к длительности самого долгого теста"] style L1 fill:#4caf82,fill-opacity:0.15 style L2 fill:#e0a252,fill-opacity:0.15 style L3 fill:#3ba7a0,fill-opacity:0.15
Проблема хвоста: наивное шардирование не помогает
Разбить 1000 тестов на 4 шарда по 250 — очевидное решение, которое даёт ускорение куда меньше четырёхкратного. Причина: тесты неравны по длительности. Один шард может получить три теста по пять минут и закончить последним, пока остальные простаивают.
Решение — шардирование по историческому времени выполнения, а не по числу
тестов. Инструменты это умеют: pytest-split принимает durations-файл,
Playwright с версии 1.44 поддерживает --shard вместе с
отчётом о длительностях,
Jest распределяет по времени из кэша.
# Шаг 1: раз в сутки (например, ночью на main) записываем длительности
pytest tests/ --store-durations --durations-path .test_durations
# Шаг 2: в PR-прогоне режем на равные по ВРЕМЕНИ группы
pytest tests/ --splits 4 --group "$SHARD_INDEX" --durations-path .test_durations
Разница на реальных проектах: наивное деление даёт ускорение в 2.4–2.8 раза на четырёх шардах, деление по времени — 3.6–3.9 раза. Тот же счёт за раннеры, на 30% меньше времени ожидания.
Что ломается при параллельном запуске
Параллелизация безжалостно вскрывает скрытые связи между тестами. Типичные:
| Симптом | Причина | Лечение |
|---|---|---|
| Тесты падают только вместе, по одному зелёные | Общая БД, один тест видит данные другого | Отдельная схема/БД на воркер, транзакция с откатом |
| «Address already in use» | Захардкоженный порт | Порт 0 (ОС выдаст свободный) или testcontainers |
| Падает всегда последний тест файла | Общий модульный кэш, синглтон | Сброс состояния в фикстуре, autouse |
| Случайные падения при высокой нагрузке | Таймауты, рассчитанные на пустую машину | Ожидание условия вместо sleep, увеличенные таймауты в CI |
| Файлы фикстур перетирают друг друга | Общая временная директория | tmp_path / os.tmpdir() на тест |
# Изоляция БД по воркерам pytest-xdist: у каждого воркера своя база.
import os
import pytest
from sqlalchemy import create_engine, text
@pytest.fixture(scope="session")
def db_url(worker_id: str) -> str:
"""worker_id равен 'master' при последовательном прогоне и 'gw0', 'gw1'... при -n."""
base = os.environ["DATABASE_URL"]
suffix = "" if worker_id == "master" else f"_{worker_id}"
url = f"{base}{suffix}"
admin = create_engine(base, isolation_level="AUTOCOMMIT")
with admin.connect() as conn:
conn.execute(text(f'DROP DATABASE IF EXISTS "test{suffix}"'))
conn.execute(text(f'CREATE DATABASE "test{suffix}"'))
return url
@pytest.fixture
def session(db_url):
"""Каждый тест работает в транзакции, которая откатывается — данные не текут."""
engine = create_engine(db_url)
conn = engine.connect()
trans = conn.begin()
try:
yield conn
finally:
trans.rollback() # откат дешевле, чем TRUNCATE всех таблиц
conn.close()
Откат транзакции вместо очистки таблиц — приём, который часто ускоряет интеграционный набор в 2–3 раза. Ограничение: тест не должен сам управлять транзакциями (иначе используйте вложенные savepoint’ы).
Флаки-тесты: главный убийца доверия
Флаки-тест (flaky) — тест, который на неизменном коде даёт разный результат. Это не «иногда падающий баг», а именно недетерминированность самого теста или окружения.
Google в известной работе про флаки приводил цифру: около 1.5% всех прогонов тестов у них флейкали, при этом на разбор этих падений уходила заметная доля времени инженеров. Важнее другое их наблюдение: флаки-тесты растут экспоненциально по вреду. Если в наборе из 500 тестов каждый флейкает с вероятностью 0.2%, вероятность зелёной сборки примерно 0.998 в степени 500 — около 37%. Две трети прогонов красные без причины. Команда мгновенно учится нажимать «Retry» не глядя — и вместе с флаками начинает пропускать настоящие падения.
Откуда берётся недетерминированность
флаки")) Время sleep вместо ожидания условия таймауты под пустую машину зависимость от даты и часового пояса переход через полночь Порядок тесты зависят от порядка выполнения общее состояние между тестами случайный seed без фиксации Конкурентность гонки в самом коде race между поллингом и записью дедлоки в БД под параллелью Внешний мир реальные сети и сторонние API DNS и сертификаты контейнер не успел подняться Окружение CI слабый раннер, шумные соседи нехватка памяти, OOM-killer разные версии браузера или ОС
Отдельно стоит выделить самый частый: ожидание фиксированного времени.
sleep(2) работает на ноутбуке разработчика и падает на загруженном раннере,
где та же операция занимает 2.4 секунды. Лечение всегда одно — ждать условия,
а не времени.
// ПЛОХО: тест «работает» ровно до первого медленного раннера.
await page.click('#submit');
await page.waitForTimeout(2000);
expect(await page.textContent('.status')).toBe('Готово');
// ХОРОШО: Playwright ждёт выполнения условия до таймаута, обычно завершаясь мгновенно.
await page.getByRole('button', { name: 'Отправить' }).click();
await expect(page.getByTestId('status')).toHaveText('Готово', { timeout: 15_000 });
// ХОРОШО для асинхронных бэкенд-процессов: явный поллинг с осмысленным сообщением.
await expect.poll(
async () => (await api.getOrder(orderId)).status,
{ timeout: 30_000, intervals: [250, 500, 1000], message: 'Заказ не перешёл в PAID' },
).toBe('PAID');
Как обнаруживать флаки, а не догадываться о них
Ощущение «этот тест вроде часто падает» — не данные. Нужен измеримый показатель: flake rate = доля прогонов теста, где на одном и том же коммите результат отличался. Практичный прокси, который считается из истории JUnit-отчётов: доля падений теста, после которых при повторном прогоне без изменений кода он стал зелёным.
"""Считает flake rate по JUnit XML-отчётам из истории CI.
Ожидаемая структура: reports/<commit_sha>/<run_id>/junit-*.xml
Тест считается флаки, если на одном и том же commit_sha у него встречаются
и pass, и fail. Сложность: O(N) по числу testcase-элементов, память O(T)
по числу уникальных тестов.
"""
from collections import defaultdict
from pathlib import Path
import xml.etree.ElementTree as ET
# {(commit, test_id): {"pass": n, "fail": m}}
stats: dict[tuple[str, str], dict[str, int]] = defaultdict(lambda: {"pass": 0, "fail": 0})
for xml_path in Path("reports").rglob("junit-*.xml"):
commit = xml_path.parts[1]
for case in ET.parse(xml_path).iter("testcase"):
test_id = f"{case.get('classname')}::{case.get('name')}"
failed = case.find("failure") is not None or case.find("error") is not None
stats[(commit, test_id)]["fail" if failed else "pass"] += 1
# Агрегируем по тесту: сколько коммитов дали противоречивый результат.
per_test: dict[str, dict[str, int]] = defaultdict(lambda: {"flaky": 0, "total": 0})
for (_commit, test_id), r in stats.items():
per_test[test_id]["total"] += 1
if r["pass"] > 0 and r["fail"] > 0: # на одном коде и упал, и прошёл
per_test[test_id]["flaky"] += 1
ranked = sorted(
((t, v["flaky"] / v["total"], v["total"]) for t, v in per_test.items() if v["total"] >= 5),
key=lambda x: -x[1],
)
for test_id, rate, runs in ranked[:20]:
if rate > 0:
print(f"{rate:6.1%} ({runs:3d} коммитов) {test_id}")
Двадцать строк на выходе — это ваш реальный backlog по стабильности, отсортированный
по вреду. Готовые решения того же класса: встроенная детекция флаки в
GitLab,
--retries с пометкой flaky в отчёте Playwright, платформы вроде Datadog CI Visibility
или BuildPulse.
Жизненный цикл флаки-теста
на одном коммите Подозрительный --> Стабильный: 20 прогонов подряд зелёные Подозрительный --> Карантин: flake rate > 5%
или блокирует релиз Карантин --> Чинится: назначен владелец,
SLA 5 рабочих дней Чинится --> Стабильный: причина устранена,
тест возвращён в гейт Чинится --> Удалён: сценарий не окупает
стоимость поддержки Карантин --> Удалён: 30 дней без владельца Удалён --> [*] note right of Карантин В карантине тест ВСЁ ЕЩЁ ЗАПУСКАЕТСЯ, но не блокирует слияние. Отключить и забыть — не карантин. end note
Три правила, без которых карантин превращается в свалку:
- У карантина есть срок и владелец. Тест без владельца через 30 дней удаляется — это честнее, чем делать вид, что сценарий покрыт.
- Карантин виден. Дашборд «сколько тестов в карантине» на видном месте. Растущее число — сигнал о деградации, который нельзя игнорировать.
- Размер карантина ограничен. Например, не более 2% набора. Достигли потолка — новый тест туда попадает, только если старый вышел.
Retry: яд в правильной дозировке
Автоматический повтор упавшего теста — самая соблазнительная и самая опасная настройка в CI. Она мгновенно делает сборку зелёной и мгновенно же прячет проблему.
Компромисс, который работает:
- Юнит-тесты: retries = 0. Всегда. Юнит-тест недетерминированным быть не может по определению — если он флейкает, он сломан.
- Интеграционные: retries = 0 или 1, только для отдельных помеченных тестов, завязанных на внешние системы.
- E2E: retries = 1–2, но с обязательной пометкой
flakyв отчёте и учётом в статистике.
// playwright.config.ts
import { defineConfig } from '@playwright/test';
export default defineConfig({
// В CI один повтор: гасим сетевые всплески, но не прячем системную проблему.
// Локально ноль — разработчик должен видеть флак сразу.
retries: process.env.CI ? 1 : 0,
// Прогон, где всё зелёное только со второй попытки, — красный флаг,
// даже если формально пайплайн прошёл. Reporter это покажет.
reporter: [
['list'],
['junit', { outputFile: 'reports/junit-e2e.xml' }],
['html', { open: 'never' }],
],
use: {
trace: 'on-first-retry', // трейс только для упавших: дёшево и достаточно
video: 'retain-on-failure',
screenshot: 'only-on-failure',
},
// Запрещаем test.only в CI: иначе один забытый .only прогонит один тест
// и пайплайн будет зелёным при полностью отключённом наборе.
forbidOnly: !!process.env.CI,
});
Главное правило: retry никогда не должен быть тихим. Если тест прошёл со второй попытки, это событие обязано попасть в метрику. Иначе флаки копятся незаметно, пока однажды набор не станет бесполезным.
Покрытие: полезный инструмент и вредная цель
Покрытие кода (code coverage) измеряет ровно одно: какая часть кода была выполнена во время прогона тестов. Не «проверена», не «протестирована» — именно выполнена. Разница между этими словами и есть источник всех заблуждений.
Виды покрытия по возрастанию строгости
- Line / statement coverage — доля исполненных строк. Самая слабая метрика и самая популярная, потому что дешевле всех считается.
- Branch (decision) coverage — доля пройденных исходов ветвлений. Уже
осмысленно: заставляет проверять и
else. - Condition coverage — каждое подусловие принимало оба значения.
- MC-DC (Modified Condition/Decision Coverage) — для каждого подусловия показано, что оно самостоятельно влияет на результат. Требуется стандартом DO-178C в авионике; в обычной разработке избыточно.
- Mutation score — доля искусственно внесённых дефектов («мутантов»), которые тесты поймали. Единственная метрика, которая измеряет не «где были тесты», а проверяют ли они хоть что-нибудь.
Классическая демонстрация бессмысленности line coverage:
def test_divide_smoke():
divide(10, 2) # ни одного assert
Этот тест даёт 100% покрытия строк функции divide и ноль информации о её
корректности. Причём такие тесты пишутся не по злому умыслу — они появляются
ровно в тот момент, когда команде поставили KPI «85% покрытия».
Это закон Гудхарта в чистом виде: как только показатель становится целью, он перестаёт быть хорошим показателем. Покрытие безупречно работает «наоборот»: низкое покрытие достоверно означает, что тестов нет; высокое не означает ничего.
Как использовать покрытие правильно
1. Смотрите на непокрытые строки, а не на процент. Отчёт с подсветкой — инструмент code review: «вот этот обработчик ошибки не выполнялся ни разу, уверены, что он вообще работает?»
2. Гейтом делайте diff coverage, а не общий процент. Требование «покрытие изменённых строк ≥ 80%» справедливо, локально и выполнимо. Требование «поднять общий процент с 42% до 80%» приводит к трёхмесячному марафону написания бессмысленных тестов на геттеры.
3. Используйте храповик (ratchet). Общий процент не обязан расти, но не должен падать: гейт «дельта покрытия ≥ −0.1%» не даёт медленно скатываться вниз.
4. Исключайте то, что нет смысла покрывать. Сгенерированный код, миграции,
__repr__, защитные ветки «этого не может быть».
# pyproject.toml (фрагмент)
[tool.coverage.run]
branch = true # ветвления, а не только строки: включайте всегда
source = ["src"]
omit = ["*/migrations/*", "*/generated/*", "src/manage.py"]
relative_files = true # иначе пути из разных шардов не склеятся
[tool.coverage.report]
exclude_also = [
"if TYPE_CHECKING:",
"raise NotImplementedError",
"@(abc\\.)?abstractmethod",
"if __name__ == .__main__.:",
]
# Гейт по общему проценту держим НАМЕРЕННО низким: он ловит катастрофы
# (кто-то выключил половину набора), а не управляет качеством.
fail_under = 55
show_missing = true
При шардировании не забудьте склеить частичные отчёты, иначе каждый шард покажет свои 30%:
# Каждый шард пишет .coverage.<shard>; combine объединяет их в один отчёт.
coverage combine reports/.coverage.*
coverage xml -o reports/coverage.xml
diff-cover reports/coverage.xml --compare-branch=origin/main --fail-under=80
Мутационное тестирование как проверка тестов
Мутационное тестирование берёт ваш код, вносит в него мелкие изменения
(> → >=, + → -, return x → return None) и прогоняет тесты.
Если тесты остались зелёными — мутант «выжил», и значит эта логика фактически
не проверяется.
# Python: mutmut. Медленно — не ставьте в PR-гейт, гоняйте по расписанию.
mutmut run --paths-to-mutate src/pricing/ --tests-dir tests/unit/
mutmut results
# JS/TS: Stryker, умеет инкрементальный режим по изменённым файлам.
npx stryker run --incremental
Практика: не пытайтесь мерить mutation score по всему проекту — это часы машинного времени. Возьмите 2–3 модуля с самой высокой ценой ошибки (расчёт цены, права доступа, начисления) и проверьте их. Результат почти всегда отрезвляет: при 90% line coverage mutation score нередко оказывается 40–50%.
Подробнее про сам подход — у Фаулера про тесты и в документации Stryker.
Сколько это стоит и как решать, что гонять
CI — это статья расходов, которую легко не заметить, пока она не станет заметной. Считать нужно две величины:
- Прямые деньги: минуты раннеров × число прогонов × цена минуты. Пайплайн на 25 минут (сумма по всем job) при 60 PR-прогонах в день — это 25 000 минут в месяц. На платных раннерах это уже ощутимая сумма.
- Время людей: ожидание × число разработчиков. Обычно оно дороже раннеров в разы, и именно поэтому «докупить параллельных раннеров» почти всегда выгоднее, чем «потерпеть».
Решение «что запускать на каждом PR» принимается риск-ориентированно — ровно теми же рассуждениями, что и выбор глубины тестирования в стратегии автоматизации и тест-дизайне.
| Проверка | Каждый PR | После слияния | Ночью | Перед релизом |
|---|---|---|---|---|
| Линтеры, типы | да | — | — | — |
| Юнит-тесты | да (все) | — | — | — |
| Интеграционные | да (ядро) | все | — | — |
| API-контракты | да | да | — | — |
| E2E | smoke, 10–20 шт. | критичный путь | полный регресс | полный регресс |
| Кроссбраузерность | — | — | да | да |
| Нагрузка | — | — | базовый профиль | полный профиль |
| SAST | инкрементально | — | полный | полный |
| DAST | — | на стенде | да | да |
Дополнительный приём, который сильно экономит на монорепозиториях, —
запуск только затронутых тестов. Nx, Bazel, Turborepo умеют строить граф
зависимостей и прогонять лишь то, что могло сломаться. В GitHub Actions базовый
вариант делается через paths:
on:
pull_request:
paths:
- "services/billing/**"
- "libs/money/**" # общая библиотека тоже влияет на биллинг
- ".github/workflows/billing.yml"
Осторожно: селективный запуск — это оптимизация с риском. Неверно описанный граф зависимостей означает пропущенный дефект. Правило безопасности: на основной ветке всегда гоняйте всё целиком, селективность оставьте PR-прогонам.
Типичные ошибки
- Тесты запускаются только ночью. Утром падение уже нельзя привязать к конкретному изменению. Это не CI.
- Красная main как норма. Если главная ветка красная больше часа — останавливайте команду и чините. Правило Фаулера про «остановить конвейер» из Continuous Delivery работает.
- Тесты зависят от общего стенда. Два PR одновременно ломают друг другу данные. Изолируйте окружение через testcontainers или отдельные схемы БД.
if: always()забыт на артефактах. Классика: тест упал, а трейса нет.- Секреты доступны PR-прогонам из форков. GitHub по умолчанию их не даёт — не обходите это ради удобства.
- Гейт на общем проценте покрытия. Порождает тесты без assert. Всегда diff coverage.
- Тихий retry. Флаки не исчезают, а перестают быть видимыми.
- Пайплайн, который никто не может запустить локально. Держите
make ciили аналог, воспроизводящий проверки одной командой. - Кэш зависимостей без ключа по lock-файлу. Либо не срабатывает, либо подсовывает устаревшие пакеты — и вы отлаживаете фантом.
- Один гигантский job. Ошибка линтера обнаруживается на 18-й минуте, после того как отработали все E2E.
Мини-итог
- CI существует ради быстрого доверенного сигнала, а не ради галочки «у нас есть автотесты». Целевая обратная связь на PR — 10–15 минут.
- Quality gate ставится только на то, что быстро, детерминировано и будет починено немедленно. Остальное — предупреждения и асинхронные прогоны.
- Параллелизация — это три уровня (ядра, машины, стадии), а шардировать надо по историческому времени, а не по числу тестов.
- Флаки-тесты убивают доверие экспоненциально. Их надо измерять (flake rate), а не ощущать; карантин обязан иметь владельца, срок и потолок.
- Retry допустим только громкий — с пометкой в отчёте и учётом в метрике. Для юнит-тестов недопустим вообще.
- Покрытие — диагностический инструмент, а не цель. Гейтом делайте diff coverage; чтобы узнать, проверяют ли тесты хоть что-то, используйте мутационное тестирование на самых дорогих модулях.
- Разработчику CI нужен как быстрый мерж, тестировщику — как гарантия регресса. Конфликт снимается тем, что тесты живут в одном репозитории с кодом и чинятся автором изменения.
Что почитать
- Martin Fowler. Continuous Integration — первоисточник о том, зачем всё это.
- Jez Humble, David Farley. Continuous Delivery — deployment pipeline как концепция, continuousdelivery.com.
- Google Testing Blog. Flaky Tests at Google.
- Playwright: Test sharding и Retries.
- coverage.py documentation — про branch coverage,
combineи исключения. - Stryker Mutator — мутационное тестирование для JS/TS, C#, Scala.
- GitHub Actions docs и GitLab CI reference.
Что дальше
Мы научились запускать тесты правильно. Осталось разобрать, как их правильно писать с самого начала — и как практики, где тест появляется раньше кода, меняют и дизайн кода, и разговор с бизнесом.