Тестирование ПО Тесты в 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 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 нужен как быстрый мерж, тестировщику — как гарантия регресса. Конфликт снимается тем, что тесты живут в одном репозитории с кодом и чинятся автором изменения.

Что почитать

Что дальше

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

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

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

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

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

Доска запросов
Дальше