12k
All articles

Автоматические проверки, которые снижают нагрузку на код-ревью

Автоматизируйте проверку кода с помощью OpenAPI, dependency-cruiser и мутационного тестирования Stryker. Настройте CI для поиска ошибок и снижения нагрузки на ревью.

OpenReplay Team
OpenReplay Team
Автоматические проверки, которые снижают нагрузку на код-ревью

Автоматическое код-ревью в детерминированной форме — это набор CI-проверок, которые валят pull request, если код нарушает правило. Ревьюеры тратят время на архитектуру, замысел изменений и их соответствие продукту, а не на механические ошибки.

Сегодня агенты для написания кода открывают pull request’ы быстрее, чем кто-либо успевает читать их построчно. Если вы тимлид, вы либо одобряете диффы не глядя, либо становитесь узким местом. Долго не продержится ни то, ни другое.

Решение — поручить механические проблемы машинам, а человеческое внимание оставить для всего остального. Эти проверки стоит запускать независимо от того, кто написал код. В статье разобраны три такие проверки и две дешёвые дополнительные. Также показано, как встроить всё это в CI, а в конце — чего ни одна из них не поймает. Предполагается, что линтинг, форматирование и модульные тесты у вас уже настроены. Если нет, начните с этой настройки ESLint и Prettier.

ПроверкаЧто ловитЧто ревьюеру больше не нужно проверятьИнструмент
Генерация кода по контрактуПоля, которых API никогда не возвращаетСуществует ли каждое поле ответаHey API openapi-ts
Границы слоёвИмпорты, пересекающие архитектурные слоиНаправлен ли каждый импорт в правильную сторонуdependency-cruiser
Мутационное тестированиеТесты, которые выполняют код, но ничего в нём не проверяютДействительно ли каждый новый тест проверяет поведениеStryker
Мёртвый кодНеиспользуемые файлы, экспорты и зависимостиНе осталось ли что-то «сиротой»Knip
ДублированиеСкопированные блоки кодаНет ли уже где-то такой логикиjscpd

Ключевые выводы

  • Если TypeScript-клиент сгенерирован из OpenAPI-спецификации, обращение к полю, которого API никогда не возвращает (например, user.fullName), становится ошибкой компиляции, а не комментарием на ревью.
  • Правило forbidden в dependency-cruiser с уровнем error валит CI, когда сервис импортирует компонент. Если запускать его с репортером err-long, рядом с каждым нарушением выводится comment правила, который подсказывает разработчику, куда перенести импорт.
  • Покрытие строк показывает, что код выполнялся. Мутационное тестирование показывает, падает ли хоть один тест, если этот код намеренно сломать.
  • Используйте mutation score как детектор и не задавайте thresholds.break в Stryker: из-за эквивалентных мутантов жёсткий порог вводит в заблуждение.
  • Вводите новые правила как предупреждения на изменённых файлах. Те, что ловят реальные баги, переводите в ошибки, а обязательной делайте джобу только после того, как она стабилизировалась.

Генерируйте API-клиент из OpenAPI-спецификации

Если TypeScript-клиент сгенерирован из OpenAPI-спецификации, обращение к полю, которого API никогда не возвращает (например, user.fullName), становится ошибкой компиляции, а не комментарием на ревью. Написанный вручную интерфейс может незаметно разойтись с бэкендом. Сгенерированный — нет.

Тип, написанный вручную, компилируется, даже если он неверен:

// Hand-written: compiles, but the backend never sends fullName
interface User { id: string; fullName: string }
declare const user: User;

const label = user.fullName;

Если же сгенерировать тип из спецификации, то же самое обращение упадёт на этапе компиляции:

// Generated from the spec
type User = { id: string; firstName: string; lastName: string };
declare const user: User;

const label = user.fullName;
// error TS2339: Property 'fullName' does not exist on type 'User'.
const fixed = `${user.firstName} ${user.lastName}`;

Инструменты вроде Hey API openapi-ts читают OpenAPI-документ и генерируют типы и клиент. После того как клиент сгенерирован, ревьюить нужно спецификацию. Относитесь к сгенерированному коду как к артефакту сборки: не правьте его вручную и не ревьюйте построчно.

Типы исчезают после компиляции, поэтому для ответов, которым нельзя слепо доверять, добавьте валидацию в рантайме, например с помощью Zod. Всё это работает только при наличии авторитетной спецификации, которую бэкенд поддерживает в актуальном состоянии. Полная настройка описана в статье о создании типобезопасных API-клиентов с OpenAPI и TypeScript.

Контролируйте границы слоёв с помощью dependency-cruiser

dependency-cruiser проверяет каждый импорт в JavaScript- или TypeScript-проекте на соответствие правилам forbidden из конфигурационного файла. Нарушения выводятся в формате, очень похожем на вывод ESLint. Любое нарушение правила с уровнем error приводит к ненулевому коду завершения команды, и CI-джоба падает. Так вопрос «а точно ли этот сервис должен импортировать компонент?» исчезает из списка задач ревьюера.

npm install --save-dev dependency-cruiser
npx dependency-cruiser --init

--init создаёт .dependency-cruiser.js или .dependency-cruiser.cjs, если в package.json указано "type": "module". В созданном файле уже есть набор правил по умолчанию. Они отмечают циклические импорты, модули-сироты, пакеты, которые импортируются, но не указаны в package.json, а также продакшен-код, подтягивающий dev-зависимости. Добавьте рядом с ними правило для слоёв:

module.exports = {
  forbidden: [
    {
      name: "services-not-to-components",
      comment:
        "Services must not import UI. Move shared types to src/types or next to the service.",
      severity: "error",
      from: { path: "^src/services" },
      to: { path: "^src/components" },
    },
  ],
};

Пишите comment как инструкцию, а не как ярлык. Репортер по умолчанию err его не выводит, а репортер err-long печатает его при каждом нарушении. Так разработчик или агент, вызвавший падение, видит не только ошибку, но и способ её исправить:

npx dependency-cruiser --output-type err-long src

eslint-plugin-boundaries решает ту же задачу внутри ESLint, если вы предпочитаете держать это в линтере. В oxlint нет встроенного правила для архитектурных слоёв, но его правило no-restricted-imports умеет блокировать импорты по шаблону.

Проверяйте мутационным тестированием, ловят ли тесты поломки

Мутационное тестирование проверяет качество тестов: покрытие строк показывает, что код выполнялся, а мутационное тестирование — падает ли хоть один тест, если этот код намеренно изменить. Набор тестов может давать 100% покрытия и при этом почти ничего не проверять — мутационное тестирование позволяет это выявить.

Stryker вносит в исходный код небольшие изменения; каждая изменённая версия называется мутантом. Затем он прогоняет тесты на каждом мутанте. Мутант считается убитым, если хотя бы один тест на нём падает, и выжившим, если весь набор тестов по-прежнему проходит.

В примере из документации Stryker выражение return user.age >= 18 мутируется четырьмя способами: > 18, < 18, return false и return true.

export function isUserOldEnough(user: { age: number }): boolean {
  return user.age >= 18;
}

// Weak: 100% line coverage, two survivors
it("lets adults in", () => {
  expect(isUserOldEnough({ age: 25 })).toBe(true);
});

// Added: boundary cases
it("lets in someone who is exactly 18", () => {
  expect(isUserOldEnough({ age: 18 })).toBe(true);
});
it("keeps out someone who is 17", () => {
  expect(isUserOldEnough({ age: 17 })).toBe(false);
});

Тест, который проверяет только 25-летнего пользователя, проходит и на мутанте > 18, и на мутанте return true, поэтому оба выживают, хотя покрытие строк составляет 100%. Этот тест убивает только < 18 и return false. Случай с возрастом 18 убивает > 18, а случай с возрастом 17 — return true. С тремя тестами убиты все мутанты.

Выжившие мутанты обычно появляются из-за нескольких узнаваемых паттернов слабых тестов:

  • Проверка того, что функция была вызвана, без проверки аргументов вызова.
  • Проверка того, что результат существует, или его длины, без проверки содержимого.
  • Закоммиченный снапшот, который никто не сверил с ожидаемым результатом.
  • Вычисление ожидаемого значения по той же логике, что и в тестируемом коде.
  • Проверка того, что возвращает мок, вместо того, что код с этим делает.

Выживший мутант указывает на поведение, которое не зафиксировано ни одним тестом. Однако некоторые мутанты эквивалентны исходному коду, поэтому mutation score лучше использовать как детектор, а не как порог «прошёл/не прошёл». Кроме того, жёсткий порог поощряет тесты, написанные ради убийства мутантов, а не ради проверки поведения. Параметр Stryker thresholds.break валит сборку, если оценка ниже заданной. По умолчанию он равен null — так его и оставьте. Вместо этого разбирайте выживших мутантов в PR.

Чтобы прогоны были короткими, мутируйте только файлы, изменённые в PR: npx stryker run --mutate <comma-separated files>. Инкрементальный режим (--incremental) сохраняет результаты последнего прогона в reports/stryker-incremental.json и переиспользует всё, что возможно, но в CI это экономит время, только если файл кешируется между прогонами. Также установите плагин тест-раннера для вашего фреймворка (@stryker-mutator/vitest-runner или @stryker-mutator/jest-runner). Без него Stryker откатывается на command runner, который запускает весь набор тестов для каждого мутанта. О той стороне качества тестов, которая требует суждения, — что вообще заслуживает теста — читайте в нашей статье о тестировании в эпоху ИИ.

Запускайте каждую проверку отдельной обязательной CI-джобой

Проверка, которую можно пропустить, ничего не гарантирует. Выделите каждой проверке собственную джобу, чтобы падение было видно сразу, а затем отметьте каждую джобу как обязательную проверку статуса (required status check) в правилах защиты ветки или ruleset. Если вы раньше не работали с GitHub Actions, начните с этого практического руководства по GitHub Actions.

on: pull_request
jobs:
  lint:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v7
      - uses: actions/setup-node@v7
        with: { node-version: 22, cache: npm }
      - run: npm ci
      - run: npx eslint . && npx prettier --check .
      - run: npx dependency-cruiser --output-type err-long src
  typecheck:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v7
      - uses: actions/setup-node@v7
        with: { node-version: 22, cache: npm }
      - run: npm ci
      - run: npx tsc --noEmit
  test:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v7
      - uses: actions/setup-node@v7
        with: { node-version: 22, cache: npm }
      - run: npm ci
      - run: npm test
  mutation:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v7
        with: { fetch-depth: 0 }
      - uses: actions/setup-node@v7
        with: { node-version: 22, cache: npm }
      - run: npm ci
      - name: Mutate changed files only
        run: |
          FILES=$(git diff --name-only --diff-filter=AMR "origin/${{ github.base_ref }}...HEAD" -- ':(glob)src/**/*.ts' ':(glob)src/**/*.tsx' \
            | grep -vE '\.(test|spec)\.tsx?$' | paste -sd, -)
          if [ -z "$FILES" ]; then echo "No source changes"; exit 0; fi
          npx stryker run --mutate "$FILES"
  build:
    needs: [lint, typecheck, test, mutation]
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v7
      - uses: actions/setup-node@v7
        with: { node-version: 22, cache: npm }
      - run: npm ci
      - run: npm run build

Джоба mutation загружает полную историю (fetch-depth: 0), чтобы получить дифф относительно базовой ветки. Затем она передаёт в Stryker только изменённые исходные файлы, исключая файлы тестов.

Вводите новые правила постепенно:

  1. Добавляйте каждое новое правило как предупреждение. В dependency-cruiser severity: "warn" сообщает о нарушениях, но не валит джобу. Это делает только error.
  2. Запускайте проверки только на изменённых файлах.
  3. Переводите предупреждение в ошибку, как только оно поймает реальный баг, а правила, которые генерируют только шум, удаляйте.
  4. Делайте джобу обязательной только после того, как она стабилизировалась.

Стоит добавить ещё две дешёвые автоматические проверки: Knip для мёртвого кода и jscpd для дублирования. Knip находит файлы, которые никто не импортирует, экспорты, которые никто не использует, и пакеты, которые больше не нужны. jscpd отмечает дублирующиеся блоки кода.

Чего не скажут автоматические проверки при код-ревью?

Детерминированные проверки не скажут, нужна ли вообще эта фича, не появилась ли абстракция слишком рано и понимает ли кто-нибудь в команде этот код. Это вопросы суждения, и для них нужен человек.

Pull request, прошедший все детерминированные проверки, доказал лишь одно: в нём нет тех дефектов, которые эти проверки ищут. О человеческой стороне ревью читайте в нашей статье о тестировании в эпоху ИИ.

Начните с контракта

Каждый механический вопрос, переданный машине, возвращает время на ревью для вопросов, на которые может ответить только человек. Если в этом спринте вы добавите всего одну проверку, генерируйте API-клиент из спецификации: это устраняет целый класс дефектов ещё до того, как кто-то их напишет. Следующим шагом добавьте мутационное тестирование на диффе PR в виде джобы, которая только предупреждает, и разбирайте выживших мутантов.

Часто задаваемые вопросы

Как добавить правила dependency-cruiser в существующую кодовую базу, где уже сотни нарушений?

Зафиксируйте существующие нарушения как baseline и валите сборку только на новых. Начиная с dependency-cruiser 18.3.0 рекомендуемый способ — опция --baseline. По умолчанию она записывает файл .dependency-cruiser-known-violations.json в корень проекта, а запуск с --ignore-known понижает каждое перечисленное там нарушение до уровня ignore. Любой новый импорт, нарушающий то же правило, по-прежнему валит проверку. Поскольку при сопоставлении уровень серьёзности не учитывается, изменение уровня правила не затрагивает его записи в baseline.

Работает ли мутационное тестирование Stryker с Vitest?

Да. StrykerJS добавил поддержку Vitest в версии 7.0 через плагин @stryker-mutator/vitest-runner, а для проектов на Jest используется @stryker-mutator/jest-runner. Vitest-раннер всегда использует анализ покрытия perTest и игнорирует любое заданное значение coverageAnalysis. Vitest Browser Mode не поддерживается. Stryker запускает тесты в одном потоке на воркер и распараллеливает работу между собственными воркерами.

Что такое эквивалентный мутант в мутационном тестировании?

Эквивалентный мутант изменяет исходный код, не меняя поведения программы, поэтому ни один тест не сможет его убить. Классический пример — замена i < length на i != length в цикле, который считает от нуля с шагом один: оба варианта останавливаются в одной и той же точке. Поскольку эквивалентные мутанты всегда выживают, 100-процентный mutation score часто недостижим, и выживших мутантов должен разбирать человек.

Что происходит, когда Stryker запускается на изменённых файлах, для которых нет тестов?

По умолчанию Stryker завершается с ошибкой No tests found, если при начальном пробном прогоне (dry run) не выполнился ни один тест, — и CI-джоба, мутирующая только дифф PR, падает. Начиная с StrykerJS 7.0 флаг --allowEmpty (или allowEmpty: true в конфигурационном файле) перестаёт считать это ошибкой. Мутанты в коде, который не выполняет ни один тест, помечаются как не имеющие покрытия, что прямо указывает на недостающие тесты.

Understand every bug

Uncover frustrations, understand bugs and fix slowdowns like never before with OpenReplay — self-hosted, with full data ownership.

Star on GitHub

We use cookies to improve your experience. By using our site, you accept cookies.