{ "index": 105, "slug": "editorial-2025-02-practice-ai-code-verification", "title": "Зелёный тест не доказывает корректность AI-изменения", "excerpt": "Пошаговый способ проверить сгенерированный diff: от контракта и отрицательных сценариев до потребителя, зависимостей и решения о merge.", "contentHtml": "
Зелёный тест отвечает только на тот вопрос, который в него записали. Если тест проверяет сумму, а потребитель ждёт другое имя поля, изменение может пройти CI и сломать следующий вызов. Если preview возвращает правильный текст, но меняет входной объект, ошибка проявится у другого обработчика. Если проверка роли знает только editor, пустая роль может случайно получить доступ.
Разберём учебный diff, похожий на тот, который способен предложить AI-ассистент. Цель не в том, чтобы измерить качество конкретной модели, а в том, чтобы сделать решение проверяемым: сначала назвать контракт, затем увидеть контрпример, повторить путь потребителя и только после этого обсуждать merge.
\nПредставим функцию расчёта счёта. Контракт старого кода прост: на входе два целых значения в копейках, на выходе объект с полем amountCents. Входной объект остаётся неизменным. Потребитель использует именно это имя:
function renderTotal(invoice) {\n return `${invoice.amountCents} коп.`;\n}\n\nconst invoice = toInvoice({ subtotalCents: 900, taxCents: 100 });\nrenderTotal(invoice);\nСгенерированная реализация может выглядеть убедительно:
\nfunction 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| Граница | Условие | Как увидеть нарушение | Ограничение |
|---|---|---|---|
| Вход | subtotalCents и taxCents — целые числа | Проверить тип и пример с дробным значением | Не покрывает валидацию внешнего API |
| Результат | Есть только контрактное поле amountCents | Проверить ключи и вызвать consumer | Совместимость старого API нужно решать отдельно |
| Состояние | Входной объект не меняется | Сравнить снимок до и после вызова | Глубокая мутация вложенных данных требует отдельного теста |
| Отказ | Неверный тип или диапазон отклоняется явно | Проверить исключение или согласованный результат ошибки | Точная ошибка зависит от публичного API |
Такая таблица отделяет проверенный факт от решения, которое ещё должен принять владелец API. Не стоит молча добавлять «удобную» нормализацию, округление или обратную совместимость: это новые свойства, а не бесплатное исправление.
\nНиже — самодостаточная проверка для Node.js 20 или новее. Встроенный модуль node:test стабилен начиная с Node.js 20. Сохраните реализацию в invoice.mjs, тест — в invoice.test.mjs, затем выполните команды:
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.
Отрицательный сценарий выбирают из риска, а не добавляют для количества. Для mapper это может быть дробная сумма, отсутствующее поле и неизменность входа. Для проверки доступа — неизвестная роль и отсутствие роли. Для зависимости — пакет с неверным именем, неожиданная версия или новый транзитивный компонент. Сначала задайте ожидаемое поведение, иначе тест закрепит случайное решение.
\nexport 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Линтер ищет правила, которые ему известны. Компилятор проверяет синтаксис и типы в пределах настроенной системы. Unit-тест повторяет записанные входы. Интеграционный тест смотрит на соединение компонентов. Review проверяет смысл, архитектуру и стоимость изменения. Ручной путь потребителя показывает то, что реально вызывается дальше. Screenshot может подтвердить отображение, но не форму данных и не разрешение операции.
\n| Сигнал | Подтверждает | Не подтверждает | Следующий вопрос |
|---|---|---|---|
node --check или компилятор | Код разбирается в выбранной среде | Бизнес-правило и runtime-данные | Какие входы нарушают контракт? |
| Unit-тест | Названный пример и его ожидание | Незаписанные ветки и consumers | Есть ли отрицательный путь? |
| Статический анализ | Известные паттерны и часть уязвимостей | Намерение команды и все зависимости | Какая проверка требует человека? |
| Review владельца | Соответствие задаче и границам | Полное отсутствие runtime-дефектов | Что осталось вне scope? |
| Путь потребителя | Совместимость конкретного вызова | Другие маршруты и нагрузку | Какие ещё consumers нужно найти? |
Пять зелёных строк в CI не превращаются в математическое доказательство. У них могут быть общие фикстуры, одинаковые предположения и один пропущенный consumer. Ценность проверки растёт, когда инструменты независимы по вопросу: shape, состояние, отказ, безопасность и путь доставки нельзя заменить пятью вариантами happy path.
\nУ сгенерированного кода есть обычные дефекты и дополнительные источники риска. Ассистент может сослаться на несуществующий API, принять устаревшую сигнатуру, удалить падающий тест вместо исправления причины или добавить правдоподобную, но лишнюю зависимость. Поэтому перед предметным review полезно сравнить diff с документацией проекта и отдельно посмотреть новые пакеты.
\nMerge следует остановить, если контракт и код описывают разные формы, отрицательный сценарий не определён, тест удалён или пропущен ради зелёного CI, новая зависимость не подтверждена, либо ручной путь потребителя расходится с unit-тестом. Это не означает автоматический rollback и не доказывает наличие инцидента. Это граница принятия решения: владелец должен либо вернуть код к контракту, либо оформить совместимое изменение, либо добавить недостающее свидетельство.
\nПолезная запись решения занимает несколько строк: что изменилось, какой риск проверен, какой риск остался, кто его принимает и каким условием можно закрыть остаток. Если ответ невозможно сформулировать без слов «должно работать», проверка ещё не закончена.
\nОписанный маршрут подходит для небольшого локального изменения с понятным входом и выходом. Он не заменяет threat model, тестирование распределённой системы, нагрузочные испытания, аудит лицензий, проверку секретов или ручное решение для регулируемого домена. Для миграции схемы, платежей, авторизации и работы с персональными данными нужны дополнительные владельцы и контрольные точки.
\nПримеры вымышлены и не сообщают о production-результате. Они не измеряют вероятность ошибки AI и не доказывают, что любой дефект будет найден. Поведение Number.isInteger, встроенного test runner и команд зависит от версии Node.js; используйте версию проекта и проверяйте её через node --version. Для другого языка замените команды, но сохраните те же вопросы к контракту.
Небольшое AI-изменение готово к обсуждению merge, когда у него есть проверяемый контракт, проходящий позитивный и отрицательный сценарии, явная проверка побочного эффекта, просмотр потребителя и зафиксированные ограничения. Review должен отделять факт запуска теста от решения о корректности. Такой порядок не делает код безошибочным, зато не позволяет одному зелёному assert выдать локальное совпадение за доказательство всей цепочки.
\nnode:test и команда запуска тестов; пример требует Node.js 20+.