Тестирование ПО Тесты в CI: quality gates, параллелизация, флаки-тесты, покрытие
0%

Тесты в CI: quality gates, параллелизация, флаки-тесты, покрытие

Тесты в CI: quality gates, параллелизация, флаки-тесты, покрытие

Тест, который запускается только на ноутбуке автора, — не тест, а личная привычка. Он не защищает продукт: как только автор уйдёт в отпуск или уволится, привычка исчезнет вместе с ним. Тест начинает приносить пользу в тот момент, когда его запускает машина — на каждое изменение, без права забыть.

Эта статья — про инженерию вокруг тестов, а не про сами тесты. Мы уже разобрали, как писать юнит-тесты, интеграционные, E2E и нагрузочные. Теперь вопрос другой: как собрать всё это в конвейер, который даёт быстрый и доверенный сигнал, стоит разумных денег и не превращается в ритуал «перезапусти, обычно проходит».

Ключевое слово — доверие. Красная сборка должна означать «продукт сломан». Как только она начинает означать «наверное, опять инфраструктура», вы теряете всю ценность автоматизации, продолжая платить за неё полную цену.

Что вообще делает CI с тестами

Continuous Integration в исходном смысле (Кент Бек, потом Фаулер) — это практика, где каждый разработчик вливает работу в общую ветку минимум раз в день, и каждое вливание автоматически проверяется. Тесты здесь — не самоцель, а механизм проверки утверждения «главная ветка исправна».

Из этого следует всё остальное:

  • Сигнал должен быть быстрым. Если проверка идёт час, вливать раз в день физически невозможно — вы просто будете накапливать изменения.
  • Сигнал должен быть однозначным. Красный/зелёный, без «ну там два теста всегда падают, не обращай внимания».
  • Сигнал должен быть привязан к изменению. Ночной прогон, который упал после сорока смёрженных PR, — это уже не CI, это археология.

Обратите внимание на асимметрию: чем позже стадия, тем дороже её красный результат. Не потому, что баг хуже, а потому, что дороже переключение контекста у человека.

Лестница обратной связи в CI

Эмпирика, которая работает почти везде: обратная связь на 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. Асинхронные — идут после слияния, заводят задачи, а не блокируют.

Практическое правило формулируется грубо, но точно: гейтом может быть только то, что вы готовы чинить немедленно. Если на красный гейт команда реагирует «давайте временно отключим» — гейт был выбран неправильно.

Рабочий пример: 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 — вы отрезали себя от всей аналитики разом.

Параллелизация: как ужать час в семь минут

Тесты — задача, почти идеально поддающаяся распараллеливанию: каждый тест теоретически независим. На практике мешают три вещи: общее состояние (БД, файлы, порты), стоимость запуска окружения и неравномерность длительности тестов.

Проблема хвоста: наивное шардирование не помогает

Разбить 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(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.

Жизненный цикл флаки-теста

Три правила, без которых карантин превращается в свалку:

  1. У карантина есть срок и владелец. Тест без владельца через 30 дней удаляется — это честнее, чем делать вид, что сценарий покрыт.
  2. Карантин виден. Дашборд «сколько тестов в карантине» на видном месте. Растущее число — сигнал о деградации, который нельзя игнорировать.
  3. Размер карантина ограничен. Например, не более 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 xreturn 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 нужен как быстрый мерж, тестировщику — как гарантия регресса. Конфликт снимается тем, что тесты живут в одном репозитории с кодом и чинятся автором изменения.

Что почитать

Что дальше

Мы научились запускать тесты правильно. Осталось разобрать, как их правильно писать с самого начала — и как практики, где тест появляется раньше кода, меняют и дизайн кода, и разговор с бизнесом.

TDD и BDD на практике: как это выглядит в реальной работе

Нашли неточность? Выделите фрагмент текста — рядом появится жучок.

Нужен разбор именно вашей ситуации?

Статья описывает общий случай. Если у вас частный — можно разобрать его отдельно, платно. А если не хватает целого материала, предложите тему: её оплачивают вскладчину, и она выходит открытой для всех.

Доска запросов