Автоматические проверки, которые снижают нагрузку на код-ревью
Автоматизируйте проверку кода с помощью OpenAPI, dependency-cruiser и мутационного тестирования Stryker. Настройте CI для поиска ошибок и снижения нагрузки на ревью.
Автоматическое код-ревью в детерминированной форме — это набор 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 только изменённые исходные файлы, исключая файлы тестов.
Вводите новые правила постепенно:
- Добавляйте каждое новое правило как предупреждение. В dependency-cruiser
severity: "warn"сообщает о нарушениях, но не валит джобу. Это делает толькоerror. - Запускайте проверки только на изменённых файлах.
- Переводите предупреждение в ошибку, как только оно поймает реальный баг, а правила, которые генерируют только шум, удаляйте.
- Делайте джобу обязательной только после того, как она стабилизировалась.
Стоит добавить ещё две дешёвые автоматические проверки: 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 в конфигурационном файле) перестаёт считать это ошибкой. Мутанты в коде, который не выполняет ни один тест, помечаются как не имеющие покрытия, что прямо указывает на недостающие тесты.