Инженерная практика Качество в потоке: линтеры, типы, тесты и ревью как автоматика
0%

Качество в потоке: линтеры, типы, тесты и ревью как автоматика

Качество в потоке: линтеры, типы, тесты и ревью как автоматика

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

Эта глава — про инженерную композицию ворот: какие бывают, в каком порядке их ставить, сколько каждое стоит и что делать с тестами, которые падают через раз. Виды тестирования и тест-дизайн вглубь — в треке testing, принципы кода и культура ревью — в principles, безопасность конвейера — в devops/17-security-in-pipeline.

Цена дефекта как функция момента обнаружения

Вы наверняка слышали: «ошибка в проде обходится в 100 раз дороже, чем найденная при проектировании». Цифру приписывают то Боэму (Software Engineering Economics, 1981), то несуществующему «IBM Systems Sciences Institute». Лоран Боссави в книге The Leprechauns of Software Engineering показал, что надёжного первоисточника у множителя нет — он кочует из презентации в презентацию. Не опирайтесь на «в 100 раз» в спорах: вас разоблачат. Но механизм роста стоимости реален и объясняется без магических чисел.

Потеря контекста. Тест упал через 30 секунд — вы помните, зачем писали эту строку; через две недели вы читаете собственный код как чужой, а контекст — самый дорогой ресурс отладки. Расширение радиуса поражения. Дефект в рабочей копии касается одного человека, в main — блокирует команду, в проде — задевает пользователей, данные, поддержку и иногда юристов. Стоимость координации. Исправление в проде — это не патч, а откат или хотфикс, уведомления, постмортем, проверка данных: в коде строка, в процессе человеко-дни. Работа поверх дефекта. Чем позже найден, тем больше кода написано в предположении, что всё правильно. Отсюда принцип сдвига влево (shift left): не «тестировать больше», а «тестировать раньше» — если проверка может выполниться на уровень ближе к разработчику, она должна выполняться там.

Лестница ворот

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

Уровень Время отклика Что ловит Чем платим
Редактор / LSP 0,1–2 с синтаксис, типы, неиспользуемые импорты настройка окружения у каждого
Pre-commit хук 2–5 с формат, быстрые линт-правила, секреты, большие файлы задержка каждого коммита
Локальные быстрые тесты 10–60 с регрессии в изменённом модуле дисциплина запускать
CI на PR 5–10 мин полный линт, типы, unit + интеграционные, сборка, SAST, зависимости деньги за раннеры, ожидание
Ревью человеком часы (SLA — рабочий день) замысел, имена, границы, уместность решения самое дорогое время в компании
После merge 20–60 мин e2e, нагрузочные, длинные security-сканы сложные стенды, флаки
Прод минуты–часы наблюдения всё, что зависит от реальных данных и трафика риск для пользователей

Правило чтения таблицы: проверка стоит на самой левой ступени, где она физически возможна и где её время отклика приемлемо. Поиск секретов возможен в pre-commit — значит, он там.

1. Редактор и LSP

Самое дешёвое и самое недооценённое звено. Language Server Protocol сделал так, что один и тот же анализатор (gopls, rust-analyzer, pyright, tsserver, clangd) работает в любом редакторе: красное подчёркивание за 200 мс — ворота, через которые проходит каждая строка кода в компании. Инженерное следствие: конфигурация анализаторов лежит в репозитории (pyproject.toml, tsconfig.json, .golangci.yml), а не в личных настройках редактора; иначе у каждого свой набор правил, а CI становится источником сюрпризов (Рабочее окружение разработчика, трек editors).

2. Pre-commit хук

Git-хук pre-commit запускается до создания коммита и может его отменить. Его нагрузка — проверки, которые работают только по изменённым файлам и укладываются в единицы секунд: автоформатирование с правкой на месте, быстрые линт-правила, поиск секретов (gitleaks, detect-secrets), запрет больших бинарников, синтаксис YAML/JSON, формат сообщения коммита (в commit-msg). Стандарт де-факто — фреймворк pre-commit: конфиг в репозитории, установка одной командой, изолированное окружение у каждого хука; альтернативы — lefthook и husky.

# .pre-commit-config.yaml — быстрые ворота, всё только по изменённым файлам
repos:
  - repo: https://github.com/pre-commit/pre-commit-hooks
    rev: v4.6.0
    hooks:
      - id: check-yaml
      - id: end-of-file-fixer
      - id: check-added-large-files    # блокирует случайный коммит дампа на 200 МБ
        args: ["--maxkb=512"]
  - repo: https://github.com/astral-sh/ruff-pre-commit
    rev: v0.6.9
    hooks:
      - id: ruff                       # линтер по изменённым файлам, миллисекунды
        args: ["--fix"]
      - id: ruff-format
  - repo: https://github.com/gitleaks/gitleaks
    rev: v8.19.2
    hooks:
      - id: gitleaks                   # секрет не должен попасть даже в локальную историю

Почему тяжёлые проверки в pre-commit — вредительство. Коммит должен быть дешёвой операцией: его делают десятки раз в день, в том числе «сохраниться перед экспериментом». Хук на 40 секунд превращает коммит в событие, люди начинают коммитить реже и крупнее — и вы своими руками ломаете практику мелких коммитов, на которой держится вся остальная лестница. Второй эффект: все выучивают git commit --no-verify, и ворота перестают существовать. Правило: pre-commit — до 5 секунд, всё остальное — в CI. Механика хуков — в git/08-hooks-and-automation.

3–4. Локальные тесты и CI на pull request

Ступень, которую часто пропускают: возможность запустить релевантное подмножество тестов локально за десятки секунд — выбор тестов по изменённым файлам (pytest --testmon, jest --onlyChanged), watch-режим, одна команда на всё (make test-fast). Критично, чтобы любую проверку из CI можно было воспроизвести локально одной командой: ворота, которые нельзя запустить у себя, превращают отладку в цикл «поправил — запушил — ждал 8 минут — посмотрел лог».

В CI на PR живёт основная масса автоматики: полный линт, проверка типов, unit- и интеграционные тесты, сборка артефакта, статический анализ безопасности (SAST), аудит зависимостей на уязвимости и лицензии. Устройство конвейера — тема следующей главы, CI/CD. Главный параметр ступени — время до обратной связи: 10 минут на PR приемлемо, 5 — хорошо, 20+ — люди переключаются на другие задачи, и стоимость переключения съедает выгоду от автоматизации. Удержаться в бюджете помогают параллельные джобы, кэш зависимостей и разделение на «быстрые» (блокируют merge) и «медленные» (после merge или по расписанию).

5–7. Ревью, пост-merge и прод

Ревью человеком ловит то, чего машина не понимает: уместность решения, имена и границы, протекающие абстракции, пропущенные случаи (пустой список, повторная доставка, откат), обратную совместимость контрактов (principles/11-compatibility-and-contract-evolution) и наблюдаемость. Детали — в разделе «Ревью как ворота» ниже.

После merge живёт то, что слишком дорого блокировать PR: полные e2e-сценарии, нагрузочные тесты, длинные security-сканы, сборка под все платформы, мутационное тестирование. Компромисс честный: узнаёте позже, но не платите за это временем каждого PR. Условие корректности — у падения ночного прогона есть владелец, иначе через месяц там 40 красных сборок, на которые никто не смотрит.

Прод — последние ворота: канареечный релиз (1–5 % запросов на новую версию), фича-флаги, автоматический откат по метрикам ошибок и задержек. Мониторинг здесь буквально последний тест — он проверяет гипотезу «изменение не сломало пользователей» на данных, которых нет ни на одном стенде (sre/13-release-safety, devops/04-cd-and-release-strategies).

Форматирование и линтинг: закрыть спор автоматом

Спор о стиле — самый дешёвый способ потратить дорогое время. Его закрывают один раз, выбрав форматтер без настроек или с минимумом настроек: gofmt (Go), rustfmt (Rust), Prettier (JS/TS/CSS/Markdown), black или ruff format (Python), ktlint (Kotlin), clang-format (C/C++). Ценность здесь не красота, а отсутствие предмета для спора и чистые диффы. Разницу часто путают: форматтер переписывает код, не меняя смысла (отступы, переносы, кавычки), он идемпотентен, и спорить с ним бессмысленно; линтер ищет подозрительные конструкции — неиспользуемая переменная, сравнение с None через ==, забытый await, мутируемый аргумент по умолчанию, недостижимый код. Часть его правил про стиль, но самая ценная часть — про баги. Отсюда метафора: линтер — это исполняемое код-ревью; каждое правило — замечание, которое кто-то однажды написал вручную, а теперь оно ставится автоматически и одинаково для всех.

Правило двух споров: если правило обсуждали на ревью дважды — оно должно стать линт-правилом или пунктом в документе о стиле. Третьего обсуждения быть не должно.

Тактика внедрения в проект, где «всё красное»: включать правила порциями, использовать baseline (зафиксировать текущие нарушения и запретить новые), форматировать файлы «по касанию» либо одним коммитом с записью в .git-blame-ignore-revs, чтобы не сломать git blame. Про стандарты — principles/09-code-review-and-standards.

Типы как ворота

Статическая типизация проверяет все пути исполнения, а не только покрытые тестами. Это её главное преимущество перед тестами — и её главное ограничение: она проверяет форму, а не смысл. Дёшево ловятся: обращение к несуществующему полю и опечатки в именах; null там, где значение обязательно; несовпадение сигнатур после рефакторинга (переименовали поле — компилятор показал все 40 мест); забытая ветка в разборе union-типа (exhaustiveness checking); перепутанные аргументы одного примитивного типа, если завести отдельные типы (UserId вместо str). Не ловятся: неверная бизнес-логика, гонки, неправильный порядок вызовов, утечки ресурсов, производительность. Тип говорит «функция принимает Order», но не говорит «этот Order уже оплачен».

Постепенная типизация — реальный сценарий большинства проектов: mypy и strict в TypeScript включаются по модулям, сначала новый код и границы (публичный API, доступ к БД). Практика: strict = true как база, список исключений (ignore_errors для легаси) в том же конфиге — и требование, чтобы список только сокращался; плюс линт-правило, запрещающее any/Any в новом коде. Как типы заменяют часть паттернов и часть тестов — design-patterns/13-types-instead-of-patterns и ddd/11-modeling-with-types.

Тесты в потоке: пирамида как экономика, а не догма

Пирамида тестов (Майк Кон, Succeeding with Agile, 2009) обычно подаётся как заповедь: «много unit, меньше интеграционных, совсем мало e2e». Полезнее читать её как экономическую модель. У теста две характеристики: стоимость прогона (время, инфраструктура, хрупкость) и достоверность сигнала. Быстрые и дешёвые можно запускать на каждое сохранение файла — поэтому их много; дорогие и достоверные запускаются редко — поэтому их мало. Форма пирамиды — следствие, а не цель.

Антипаттерн «мороженое» (ice cream cone) — перевёрнутая пирамида: гора медленных UI-тестов и почти нет быстрых; конвейер идёт 50 минут, половина падений — флаки, красному статусу никто не верит. Возникает обычно не по глупости, а исторически: систему покрывали снаружи, потому что внутрь было не подступиться. Лечение — не «удалить e2e», а сделать код тестируемым и переносить проверки вниз, оставив наверху 5–15 критичных пользовательских сценариев.

Расписание, которое из этого следует: при сохранении файла — тесты изменённого модуля и типы; на каждый push и PR — все unit, ключевые интеграционные, линт, сборка, SAST; после merge в main — e2e критичных сценариев, сборка образа, деплой на стенд и smoke; ночью — полный e2e-регресс, нагрузочные, мутационные и сканы зависимостей; перед релизом — прогон на проде-подобных данных с проверкой миграций и отката. Глубина — testing/12-automation-strategy, testing/13-tests-in-ci, principles/06-testing-principles.

Покрытие: что метрика измеряет на самом деле

Покрытие (line/branch coverage) измеряет ровно одно: какие строки были исполнены во время прогона тестов. Оно не измеряет, проверялись ли результаты: тест, который вызывает функцию и не делает ни одного assert, даёт 100 % покрытия этой функции. Отсюда правильное употребление — покрытие как детектор непокрытого, а не цель. Модуль с 12 % — надёжный сигнал «сюда никто не заглядывал»; 85 % не значат ничего конкретного. Ровно это формулирует Мартин Фаулер в TestCoverage. А когда «80 %» становится обязательной целью, срабатывает закон Гудхарта: появляются тесты, дёргающие геттеры и «покрывающие» сгенерированный код, — покрытие растёт, качество нет, время прогона растёт вместе с покрытием.

Практичная замена — coverage на diff вместо coverage на проект: «строки, добавленные или изменённые в этом PR, покрыты не менее чем на N %». Такое требование не требует похода в легаси, не даёт ухудшать ситуацию новым кодом и даёт осмысленный разговор на ревью (инструменты: diff-cover, patch-статусы в Codecov). Полезное дополнение — мутационное тестирование (mutmut, Stryker, PIT): оно портит код и смотрит, заметят ли тесты. Дорого, поэтому ночью и по ключевым модулям, зато отвечает на вопрос «а тесты вообще что-нибудь проверяют?».

Флаки-тесты: главный убийца доверия к CI

Флаки-тест (flaky) — тест, который на одном и том же коде даёт то зелёный, то красный результат. Это не мелкая неприятность, а системный риск: как только доля флаки переваливает за порог заметности, команда перестаёт читать красный статус и начинает жать «перезапустить» — и с этого момента ваши ворота декорация. Google в публикации Flaky Tests at Google сообщает, что около 16 % их тестов имеют ту или иную степень нестабильности, — и это у компании с образцовой инфраструктурой. Источники, примерно по частоте: время (sleep(0.5) вместо ожидания условия; зависимость от текущей даты, границы суток, часового пояса); порядок выполнения (тест А оставил запись в БД, тест Б на неё наткнулся); общее состояние (глобальные переменные, синглтоны, кэши, одна БД на всех); сеть и внешние сервисы (реальные HTTP-запросы, таймауты, rate limit); гонки (тест ждёт фоновый поток, который иногда не успевает); недетерминированные данные (генераторы без зерна, порядок обхода множеств, UUID в ожидаемых значениях).

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

"""Было: тест зависит от текущего времени, случайности и порядка выполнения."""
def test_discount_flaky():
    # «сегодня» меняется каждый день, random без зерна раз в N прогонов даёт
    # граничный случай, а глобальный кэш переживает тест и влияет на соседей
    order = make_order(created_at=datetime.now(), amount=random.randint(100, 10_000))
    assert calculate_discount(order) > 0   # «хоть что-нибудь» ничего не гарантирует
"""Стало: время заморожено, зерно зафиксировано, состояние изолировано."""
import random
from datetime import datetime, timezone

import pytest
from freezegun import freeze_time

FIXED_NOW = datetime(2026, 3, 15, 12, 0, tzinfo=timezone.utc)  # одна точка правды о «сейчас»


@pytest.fixture(autouse=True)
def deterministic_env(monkeypatch, tmp_path):
    """Изолирует общее состояние: своё зерно, своя директория, чистые кэши."""
    random.seed(20260315)                               # воспроизводимая последовательность
    monkeypatch.setenv("TZ", "UTC")                     # одинаковый часовой пояс везде
    monkeypatch.setenv("APP_CACHE_DIR", str(tmp_path))  # своя папка на каждый тест
    clear_all_caches()
    yield
    clear_all_caches()                                  # не оставляем наследства соседям


@freeze_time(FIXED_NOW)                                 # тест не зависит от календаря
@pytest.mark.parametrize("amount,expected", [(100, 0), (5_000, 250), (10_000, 1_000)])
def test_discount_deterministic(amount, expected):
    """Проверяем конкретные границы, а не «что-нибудь больше нуля»."""
    assert calculate_discount(make_order(created_at=FIXED_NOW, amount=amount)) == expected

Три приёма убирают большую часть флаки: время как зависимость — передавать источник времени в код (clock: Callable[[], datetime]), а не звать datetime.now() внутри бизнес-логики; ожидание условия вместо sleepwait_until(lambda: order.status == "paid", timeout=5); изоляция состояния — своя схема БД или транзакция с откатом на каждый тест. Ловить флаки помогает провокация: случайный порядок (pytest -p randomly), параллельный запуск, стократный прогон.

Политика карантина

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

  1. Обнаружение. CI хранит историю прогонов; тест, менявший результат на неизменённом коде более N раз за неделю, автоматически помечается нестабильным.
  2. Карантин. Тест уходит в отдельный набор: продолжает выполняться и собирать статистику, но не блокирует merge. Одновременно заводится тикет.
  3. Владелец и срок. У тикета есть конкретный человек и дедлайн (скажем, 14 дней). Карантин без владельца и срока — это удаление теста, только медленное и с самообманом. По истечении срока тест либо починен и возвращён, либо удалён — с записью, что перестало проверяться и чем компенсировано.
  4. Лимит. Не более X тестов в карантине одновременно; упёрлись — останавливаем фичи и чиним. Тот же механизм, что error budget в sre/03-error-budget. Ретраи допустимы как временная мера при двух условиях: каждый пишется в метрику, а тест с ретраями автоматически считается нестабильным.

Ревью как ворота

Самый сильный предиктор эффективности ревью — размер изменения. Классическое исследование ревью в Cisco (SmartBear) показало резкое падение плотности найденных дефектов после ~200–400 строк за заход. В работе Modern Code Review: A Case Study at Google (ICSE-SEIP, 2018) медианное изменение — около 24 строк, а медианное время до первого ответа ревьюера — меньше рабочего дня: задачи режут на куски, которые можно вдумчиво прочитать. Практические правила:

  • PR до 200–400 строк содержательного диффа. Сгенерированный код, локализация и вендоринг — отдельными коммитами с пометкой «не читать построчно». Один PR — одно намерение: рефакторинг и изменение поведения не смешиваются, иначе ревьюер не отличит одно от другого.
  • Автор — первый ревьюер. Прочитайте собственный дифф целиком и сами прокомментируйте места, где возникли бы вопросы. Это снимает половину замечаний и экономит цикл.
  • SLA на ревью. Первый ответ — в течение рабочего дня. Ревью чужого PR важнее, чем начать свою следующую задачу: пока PR ждёт, работа лежит в незавершённом производстве и стареет.
  • Отделяйте блокирующее от необязательного (nit:), и помните, что машина проверяет форму, а человек — замысел: если ревьюер пишет про пробелы, чинить надо не ревьюера, а конвейер.

Подробнее — principles/09-code-review-and-standards и git/pull-request.

Ворота на ветке: техническая реализация процесса

Договорённости, которые не проверяются машиной, — это пожелания. Инструменты хостинга превращают их в правила. Branch protection — запрет прямого push в main, обязательный PR, запрет force-push и удаления ветки. Required status checks — явный список проверок, без которых кнопка merge неактивна; список держат коротким, каждая обязательная проверка — налог на каждый PR. Требования к ревью — минимум N апрувов, обязательный апрув владельца кода (CODEOWNERS), сброс апрувов при новом push. Линейная история или merge-коммиты — вопрос вкуса, но решение одно на репозиторий и зафиксировано настройкой, а не устной традицией.

Отдельно стоит merge queue: она решает проблему семантического конфликта, когда два PR зелёные по отдельности, но вместе ломают main (один переименовал метод, другой добавил вызов старого имени). Очередь собирает и тестирует именно ту комбинацию, которая получится после слияния, и вливает, только если она зелёная; без очереди на активном репозитории main краснеет регулярно. Механика совместной работы — git/10-collaboration, стратегии ветвления — Контроль версий как инженерная практика.

Метрики здоровья ворот

Ворота — тоже система, и у неё есть свои показатели:

  • Время до обратной связи — p50 и p95 длительности обязательного конвейера на PR; смотреть надо p95, длинный хвост и создаёт ощущение «CI тормозит».
  • Доля красных сборок на main и время восстановления зелёного: если main красный дольше 15–20 минут, вся команда работает на сломанном фундаменте.
  • Flake rate — доля прогонов, где результат теста изменился без изменения кода; плюс размер карантина и возраст самого старого тикета в нём.
  • Доля падений по вине инфраструктуры отдельно от падений по вине кода: если раннеры падают в 5 % случаев, никакая политика «не перезапускать» не выживет.
  • Время PR в ожидании ревью и распределение размеров PR — не среднее, хвост «PR на 2000 строк» виден только по распределению.

Интегральный внешний индикатор — четыре метрики DORA: медленный конвейер удлиняет lead time, флаки снижают частоту поставки, отсутствие быстрых ворот повышает долю неудачных изменений (project-management/07-dora-and-engineering-metrics). Оговорка: эти метрики — для диагностики системы, а не для оценки людей. «Покрытие по разработчику» или «число замечаний на ревью» как KPI порождают имитацию вместо качества.

Типичные ошибки

  • Сорокаминутный CI на PR. Умножается на число PR в день и на стоимость переключения контекста. Лечится профилированием конвейера, кэшем, параллелизмом, шардированием тестов и переносом медленного в пост-merge.
  • Блокирующие ворота без владельца. Проверка, которая падает, но никому не принадлежит, проходит стадии «странно» → «наверное, флака» → «жми перезапуск» → «давайте отключим».
  • «Отключим тест, он мешает.» Удаление без записи, что именно перестало проверяться, — скрытая потеря покрытия рисков. Бесполезный тест удаляют решением с обоснованием, а не жестом раздражения.
  • Ворота, которые нельзя запустить локально. Проверка, воспроизводимая только на раннере, превращает отладку в игру с восьмиминутным ходом: всё, что делает CI, должно запускаться одной командой на ноутбуке — в контейнере, если нужно окружение.
  • Ревью на 2000 строк. Апрув будет, дефекты — нет. Честно большое изменение режут на серию PR (сначала механическое переименование, потом смысловое) либо разбирают очно, по экрану.
  • Дублирование проверок на всех уровнях. Один и тот же линт в хуке, в CI на PR и в пост-merge — тройная оплата за один сигнал; у проверки должно быть одно «домашнее» место.
  • Ворота без права на исключение. Механизм обхода (хотфикс мимо очереди) должен существовать, быть заметным — лог, уведомление, автотикет — и применяться редко: ворота без аварийного выхода люди выламывают вместе с косяком.

Мини-итог

  • Качество — не этап и не отдел, а лестница ворот, где каждая ступень дороже предыдущей и ловит то, что предыдущая не могла; проверку ставим на самой левой ступени, где она возможна.
  • Pre-commit — до 5 секунд; тяжёлое туда класть нельзя, сломаете практику мелких коммитов.
  • Спор о стиле закрывается форматтером; правило, обсуждённое дважды, становится линт-правилом. Типы проверяют все пути исполнения, но только форму, а не смысл, и вводятся постепенно.
  • Пирамида тестов — экономическая модель, а не догма; «мороженое» лечится тестируемостью кода. Покрытие — детектор непокрытого, а не цель; на diff оно полезнее, чем на проект.
  • Флаки убивают доверие к воротам: детерминизация, карантин с владельцем, сроком и лимитом, ретраи — только с метрикой. Ревью работает на маленьких PR и с SLA, а branch protection, required checks и merge queue — техническая реализация договорённостей.
  • Измеряйте здоровье самих ворот: p95 времени отклика, красный main, flake rate, размер PR.

Источники

Что дальше

CI/CD — как из этих ворот собирается конвейер, доводящий изменение до продакшена: анатомия стадий, артефакт, который собирают один раз, фича-флаги и измерение результата.

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

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

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

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