{ "index": 105, "slug": "editorial-2025-02-practice-ai-code-verification", "title": "Зелёный тест не доказывает корректность AI-изменения", "excerpt": "Пошаговый способ проверить сгенерированный diff: от контракта и отрицательных сценариев до потребителя, зависимостей и решения о merge.", "contentHtml": "

Зелёный тест отвечает только на тот вопрос, который в него записали. Если тест проверяет сумму, а потребитель ждёт другое имя поля, изменение может пройти CI и сломать следующий вызов. Если preview возвращает правильный текст, но меняет входной объект, ошибка проявится у другого обработчика. Если проверка роли знает только editor, пустая роль может случайно получить доступ.

\n

Разберём учебный diff, похожий на тот, который способен предложить AI-ассистент. Цель не в том, чтобы измерить качество конкретной модели, а в том, чтобы сделать решение проверяемым: сначала назвать контракт, затем увидеть контрпример, повторить путь потребителя и только после этого обсуждать merge.

\n
Последовательность проверки AI-изменения: контракт, статический анализ, тест, review и путь потребителя
Каждый шаг проверяет отдельное свойство diff. Совокупность сигналов не становится гарантией, если они задают один и тот же вопрос.
\n

Симптом: тест зелёный, потребитель сломан

\n

Представим функцию расчёта счёта. Контракт старого кода прост: на входе два целых значения в копейках, на выходе объект с полем amountCents. Входной объект остаётся неизменным. Потребитель использует именно это имя:

\n
function renderTotal(invoice) {\n  return `${invoice.amountCents} коп.`;\n}\n\nconst invoice = toInvoice({ subtotalCents: 900, taxCents: 100 });\nrenderTotal(invoice);
\n

Сгенерированная реализация может выглядеть убедительно:

\n
function toInvoice(input) {\n  return {\n    total: input.subtotalCents + input.taxCents\n  };\n}\n\nconst result = toInvoice({ subtotalCents: 900, taxCents: 100 });\nconsole.assert(result.total === 1000);
\n

Этот assert проходит: арифметика верна. Но renderTotal читает amountCents, которого нет. Ошибка не в том, что тест написан на JavaScript. Он проверяет внутреннее промежуточное решение, а не публичную границу. Вторая ловушка — название результата: одно переименованное поле может затронуть несколько consumers, даже если функция изолированно выглядит исправной.

\n

Контракт нужно записать до запуска тестов

\n

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

\n
Минимальный контракт учебного mapper
ГраницаУсловиеКак увидеть нарушениеОграничение
ВходsubtotalCents и taxCents — целые числаПроверить тип и пример с дробным значениемНе покрывает валидацию внешнего API
РезультатЕсть только контрактное поле amountCentsПроверить ключи и вызвать consumerСовместимость старого API нужно решать отдельно
СостояниеВходной объект не меняетсяСравнить снимок до и после вызоваГлубокая мутация вложенных данных требует отдельного теста
ОтказНеверный тип или диапазон отклоняется явноПроверить исключение или согласованный результат ошибкиТочная ошибка зависит от публичного API
\n

Такая таблица отделяет проверенный факт от решения, которое ещё должен принять владелец API. Не стоит молча добавлять «удобную» нормализацию, округление или обратную совместимость: это новые свойства, а не бесплатное исправление.

\n

Сначала получить RED, затем чинить узко

\n

Ниже — самодостаточная проверка для Node.js 20 или новее. Встроенный модуль node:test стабилен начиная с Node.js 20. Сохраните реализацию в invoice.mjs, тест — в invoice.test.mjs, затем выполните команды:

\n
node --version\nnode --check invoice.mjs\nnode --test invoice.test.mjs
\n

Первый вариант реализации намеренно показывает дефект shape:

\n
// invoice.mjs\nexport function toInvoice(input) {\n  return {\n    total: input.subtotalCents + input.taxCents\n  };\n}
\n
// invoice.test.mjs\nimport test from 'node:test';\nimport assert from 'node:assert/strict';\nimport { toInvoice } from './invoice.mjs';\n\ntest('возвращает контрактную форму и не меняет вход', () => {\n  const input = { subtotalCents: 900, taxCents: 100 };\n  const before = JSON.stringify(input);\n  const result = toInvoice(input);\n\n  assert.deepEqual(result, { amountCents: 1000 });\n  assert.equal(JSON.stringify(input), before);\n});
\n

Тест должен упасть на неправильной реализации. Это полезный RED: он показывает конкретное расхождение, а не сообщает, что «AI ошибся». После исправления ожидаемый результат такой:

\n
// invoice.mjs\nexport function toInvoice(input) {\n  if (!Number.isInteger(input.subtotalCents) || !Number.isInteger(input.taxCents)) {\n    throw new TypeError('amounts must be integers');\n  }\n\n  return {\n    amountCents: input.subtotalCents + input.taxCents\n  };\n}
\n

Команда node --test invoice.test.mjs проверяет только этот модуль и один заданный контракт. Она не доказывает корректность округления, работу базы, права пользователя или совместимость всех вызовов. Эти границы должны появиться в следующих тестах, если они есть в настоящем API.

\n

Позитивного сценария недостаточно

\n

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

\n
export function canEdit(role) {\n  return role === 'editor';\n}\n\nconsole.assert(canEdit('editor') === true);\nconsole.assert(canEdit('viewer') === false);\nconsole.assert(canEdit(undefined) === false);\nconsole.assert(canEdit('admin') === false);
\n

Здесь действует правило «разрешено только явно названному значению». Но это не полноценная авторизация: пример не проверяет identity provider, tenant, токен, срок действия сессии или серверную границу. Нельзя переносить его как готовый security control. Он лишь фиксирует поведение одной чистой функции, если такая функция действительно является частью вашего контракта.

\n

Разные инструменты отвечают на разные вопросы

\n

Линтер ищет правила, которые ему известны. Компилятор проверяет синтаксис и типы в пределах настроенной системы. Unit-тест повторяет записанные входы. Интеграционный тест смотрит на соединение компонентов. Review проверяет смысл, архитектуру и стоимость изменения. Ручной путь потребителя показывает то, что реально вызывается дальше. Screenshot может подтвердить отображение, но не форму данных и не разрешение операции.

\n
Карта свидетельств для небольшого diff
СигналПодтверждаетНе подтверждаетСледующий вопрос
node --check или компиляторКод разбирается в выбранной средеБизнес-правило и runtime-данныеКакие входы нарушают контракт?
Unit-тестНазванный пример и его ожиданиеНезаписанные ветки и consumersЕсть ли отрицательный путь?
Статический анализИзвестные паттерны и часть уязвимостейНамерение команды и все зависимостиКакая проверка требует человека?
Review владельцаСоответствие задаче и границамПолное отсутствие runtime-дефектовЧто осталось вне scope?
Путь потребителяСовместимость конкретного вызоваДругие маршруты и нагрузкуКакие ещё consumers нужно найти?
\n

Пять зелёных строк в CI не превращаются в математическое доказательство. У них могут быть общие фикстуры, одинаковые предположения и один пропущенный consumer. Ценность проверки растёт, когда инструменты независимы по вопросу: shape, состояние, отказ, безопасность и путь доставки нельзя заменить пятью вариантами happy path.

\n

Проверить AI-специфические риски до review

\n

У сгенерированного кода есть обычные дефекты и дополнительные источники риска. Ассистент может сослаться на несуществующий API, принять устаревшую сигнатуру, удалить падающий тест вместо исправления причины или добавить правдоподобную, но лишнюю зависимость. Поэтому перед предметным review полезно сравнить diff с документацией проекта и отдельно посмотреть новые пакеты.

\n
  1. Ограничьте diff. Запишите изменённые файлы, ожидаемый эффект и владельца границы. Если для проверки нужно пересказывать полсистемы, разделите изменение.
  2. Сверьте публичный контракт. Найдите типы, схему, вызывающий код и обратную совместимость. Проверьте не только значение, но и имя поля, форму ошибки и порядок побочных эффектов.
  3. Запустите дешёвые проверки. Выполните проверку синтаксиса, узкие тесты и статический анализ на той версии среды, которая указана проектом. Зафиксируйте команду и результат.
  4. Добавьте контрпример. Выберите отсутствующее поле, неверный тип, чужую роль, повторный вызов или отказ зависимости — конкретный случай, который относится к границе.
  5. Проверьте зависимости. Убедитесь, что пакет существует, его версия и лицензия подходят проекту, а lockfile изменён ожидаемо. Не принимайте название из ответа модели без самостоятельного поиска.
  6. Повторите маршрут потребителя. Возьмите безопасный тестовый вход и вызовите следующий слой. Если результат расходится с unit-тестом, остановите merge и объясните расхождение.
  7. Попросите предметный review. Вопрос должен звучать как «сохраняется ли форма ответа для consumer X?» или «какое поведение нужно для отсутствующей роли?», а не как «посмотрите AI-код».
\n

Когда зелёный статус не даёт права на merge

\n

Merge следует остановить, если контракт и код описывают разные формы, отрицательный сценарий не определён, тест удалён или пропущен ради зелёного CI, новая зависимость не подтверждена, либо ручной путь потребителя расходится с unit-тестом. Это не означает автоматический rollback и не доказывает наличие инцидента. Это граница принятия решения: владелец должен либо вернуть код к контракту, либо оформить совместимое изменение, либо добавить недостающее свидетельство.

\n

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

\n

Ограничения применимости

\n

Описанный маршрут подходит для небольшого локального изменения с понятным входом и выходом. Он не заменяет threat model, тестирование распределённой системы, нагрузочные испытания, аудит лицензий, проверку секретов или ручное решение для регулируемого домена. Для миграции схемы, платежей, авторизации и работы с персональными данными нужны дополнительные владельцы и контрольные точки.

\n

Примеры вымышлены и не сообщают о production-результате. Они не измеряют вероятность ошибки AI и не доказывают, что любой дефект будет найден. Поведение Number.isInteger, встроенного test runner и команд зависит от версии Node.js; используйте версию проекта и проверяйте её через node --version. Для другого языка замените команды, но сохраните те же вопросы к контракту.

\n

Итоговый критерий готовности

\n

Небольшое AI-изменение готово к обсуждению merge, когда у него есть проверяемый контракт, проходящий позитивный и отрицательный сценарии, явная проверка побочного эффекта, просмотр потребителя и зафиксированные ограничения. Review должен отделять факт запуска теста от решения о корректности. Такой порядок не делает код безошибочным, зато не позволяет одному зелёному assert выдать локальное совпадение за доказательство всей цепочки.

\n

Проверяемые источники

" }