Что делать, если ИИ пишет тесты-пустышки

Постер: что делать, если ИИ пишет тесты-пустышки — 100% покрытие, 97.8% mutation, а баг в проде

Коротко

TL;DR: ИИ-агент пишет тесты, которые проходят и покрывают 100% строк, но ничего не проверяют — happy-path и ассерты-пустышки. Mutation testing вскрывает часть таких: намеренно ломает код и смотрит, упадёт ли тест. Но и mutation врёт. Ниже — на реальном примере моего проекта dev-pomogator: где зелёные тесты обманывали и что ловило баг на самом деле.

Почему покрытие врёт — и ИИ делает это хуже

Покрытие меряет «строка выполнилась», а не «поведение проверено». Можно прогнать каждую строку и не проверить ни одного результата.

С ИИ-генераторами разрыв становится нагляднее. В исследовании MutGen у LLM-сгенерированных сьютов бывает 100% покрытия при 4% mutation score — каждая строка отрабатывает, а 96% внесённых поломок проходят насквозь.

Это не единичный курьёз:

  • Copilot генерил до 92% сломанных тестов без готового сьюта под рукой (Elhaji et al., AST'24).
  • Агенты склонны переусердствовать с моками — тест проверяет мок, а не код (разбор 1.2 млн коммитов).

Отсюда идея: нужен «тест для тестов». Им и работает mutation testing.

Как выглядит пустышка

Прежде чем ловить — надо узнавать в лицо. ИИ пишет пустышки не со зла: он оптимизирует под «зелёное» и покрытие, а не под «поймать баг», и охотнее пишет тесты, которые легко написать, а не те, которые нужно. LLM-тесты предсказуемо болеют слабыми ассертами (Ouédraogo et al.). Вот три самых частых вида — с примерами из каталога на реальном коде.

1. Ассерт-пропускалка. Проверка есть, но пропускает почти всё. toBeDefined() зелёный для null, 0, "", {} и объекта неправильной формы. В одном только dev-pomogator таких — 33 штуки.

// ПУСТЫШКА — пройдёт, даже если форма ответа сломана
const user = await fetchUser(42);
expect(user).toBeDefined();
expect(user.name).toBeTruthy();

// КРЕПКО — ловит любую регрессию формы
expect(user).toEqual({ id: 42, name: 'Alice', email: 'alice@example.com' });

2. Тавтология. Тест считает ожидаемое той же формулой, что и код. Поменяй в проде price * (pct / 100) на price * pct / 100 — ошибка применится к обеим сторонам, тест останется зелёным.

// ПУСТЫШКА — expected пересчитан логикой самого теста
const expected = price * (pct / 100);
expect(calculateDiscount(price, pct)).toBe(expected);

// КРЕПКО — число, которое ты посчитал бы на бумаге
expect(calculateDiscount(100, 15)).toBe(15);

3. Игрушечный вход. Тривиальный вход против сложного пайплайна. Реальный кейс dev-pomogator: тест «tsx-runner запускает хук» скармливал раннеру console.log("OK") — не хук. Тест зелёный, а настоящие хуки с локальными импортами были сломаны (коммит 97a7c86).

// ПУСТЫШКА — "console.log(OK)" не трогает ни резолвер типов, ни импорты
const r = spawnSync('node', ['tsx-runner.js', 'console.log("OK")']);
expect(r.status).toBe(0);

// КРЕПКО — реальный хук с локальным импортом (настоящий режим отказа)
const r = spawnSync('node', ['tsx-runner.js', realHookWithLocalImport]);
expect(r.stdout).toContain('HOOK_OK_TOKEN');

Быстрая самопроверка на каждый тест: «упадёт ли он, если подменить весь прод-код на return null Если нет — перед тобой пустышка. Четвёртый частый вид — фикстура, выдумывающая поле, которого нет в реальном артефакте — разберём ниже отдельно.

Как работает mutation testing за 30 секунд

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

  • Killed — тест упал, поломку поймали. Хорошо.
  • Survived — тесты зелёные при сломанном коде. Дыра.

Доля пойманных = mutation score. У Stryker дефолтные пороги — 80% high / 60% low.

Но метрика шумная: часть мутантов эквивалентны (поведение не меняют, убить нельзя) — их около 30%, поэтому Stryker прямо советует не гнаться за 100%.

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

Но и mutation врёт — мой кейс с дублями

Высокий mutation score обманывает тоже. Разберу на живом баге из dev-pomogator — по шагам, потому что вся соль в деталях.

У меня есть дашборд, который показывает мои git-worktree (параллельные рабочие копии одного проекта). В какой-то момент он начал показывать каждую копию по 5 раз — с одинаковой «последней активностью». Разные сессии, а данные один в один.

Причина тонкая. Код решал «это git-репозиторий?» по наличию .git. Но у worktree .gitне папка, а файл-указатель. Проверка «.git существует?» отвечала «да» и для настоящего репо, и для worktree. Каждый worktree засчитывался отдельным репозиторием, а список перемножался сам на себя: 5 копий × 5 = дубли.

А теперь ударь по нервам. У этого кода было 100% покрытия строк, 97.8% mutation score и 10 зелёных unit-тестов. Всё зелёное, метрики отличные. Баг жил в проде месяцами (постмортем). Почему mutation не мог его поймать?

Потому что фикс — это добавить проверку строже: не «.git существует», а «.git — это папка» (.exists().is_dir()). А mutation работает наоборот: ломает уже написанный код и смотрит, заметит ли тест. Недостающей проверки в коде не было — ломать было нечего. Он ловит потерю поведения, но слеп к отсутствию нужного.

Поймал баг не процент, а инвариант на результат: «один и тот же worktree не может оказаться в списке дважды». Конкретно это тест «1 main + 2 linked worktree → ровно 3 ряда, не 9» — одна строка assert, что worktree_path не повторяется. Четыре таких регресс-теста красные на старом коде и зелёные на фиксе. Это свойство ответа, а не отдельной строки кода — поэтому оно и увидело то, что покрытие и мутации проспали (правило проекта).

Тесты против выдуманных данных

Вторая ловушка ИИ-тестов: они проверяют модель против собственных выдуманных фикстур, а не против реального артефакта.

У меня было 359 зелёных тестов и honesty-gate поверх. Прогон против реального файла прогонов cucumber показал 40 passed / 10 pending / 29 undefined — вместо отрапортованных фейком «79 passed». Фикстура клала поле, которого настоящий cucumber не шлёт (разбор).

Похожий случай: тест сверял поле code, а находки лежат в finding_code. Шесть из шести тестов зелёные — находки не считались ни разу (cli.ts:140).

Зелёный тест на выдуманных данных проверяет не код, а вашу фантазию о коде.

Как я это ловлю: BDD и mutation в тандеме

В dev-pomogator тесты — BDD-first: поведение описано .feature-сценариями на Cucumber. Mutation работает поверх них в связке.

Схема простая: Stryker мутирует продакшн-код скила, а убивать мутантов обязаны BDD-сценарии через официальный @stryker-mutator/cucumber-runner. Сломали код → сценарий @feature7 должен покраснеть (stryker.bdd.config.mjs).

Прямых работ «mutation + BDD/Cucumber» в литературе почти нет — это скорее инженерная практика, чем описанная методология. Рядом стоит property-based testing: один PBT-тест по mutation-killing ≈ 50 unit.

Но проценту я не доверяю как гейту. На BDD-прогоне mutation score гулял 68–83% на байт-идентичном коде — 48 мутантов меняли вердикт между двумя одинаковыми запусками (аудит).

Поэтому реальный гейт — verify-kill: внедрить мутанта → прогнать сценарий → откатить. Killed засчитывается, только если конкретный сценарий реально упал под конкретным мутантом. По факту убийства, а не по проценту (verify-kill.ts).

Как встроить это в готовый пайплайн

Если у тебя уже есть тесты и CI, добавить mutation testing можно так, чтобы не утонуть. Сначала про механику: мутатор — это CLI-тул (Stryker для JS/.NET, mutmut для Python). Ставишь пакетом, показываешь на папку с исходниками — и он ломает код и прогоняет твой уже существующий тест-сьют как есть. Отдельного «включи мутацию на этот тест» атрибута нет — берётся весь набор. Минимальный прогон — три команды:

# JS/TS — Stryker поверх существующего vitest/jest
npm i -D @stryker-mutator/core
npx stryker init          # один раз: создаёт stryker.conf
npx stryker run           # ломает код, гоняет твои тесты; читай раздел "Survived"

# Python — mutmut поверх pytest
pip install mutmut
mutmut run                # ломает код, гоняет pytest
mutmut results            # список выживших мутантов

Оговорка по стекам: как помечены сами тесты — зависит от языка (это тест-фреймворк, не мутатор). В JS/TS — функции describe/it/test, в Python — имена test_*, в Gofunc TestXxx в файле _test.go. Атрибуты обязательны только в .NET: [Fact] / [Test] / [TestMethod].

Отдельно у Stryker.NET есть директивы ([ExcludeFromCodeCoverage], // Stryker disable), но они, наоборот, исключают код из мутации.

  1. Не вся база сразу. Прогон дорогой. Начни с 1–2 самых критичных модулей — где баг больнее всего (парсинг, деньги, права доступа). Остальное потом.
  2. Инкрементально в CI. Не гоняй по всему коду на каждый PR — Stryker --incremental мутирует только изменённое, сокращая прогон с ~30 минут до пары минут. Ровно так делает Google — мутации в код-ревью на diff, а не по всей базе.
  3. Сначала отчёт, потом гейт. Первые недели просто смотри на выживших мутантов, не блокируя мёрж. Привыкнешь к сигналу — включишь блокировку.
  4. Гейть по факту, не по проценту. Выживший мутант в модуле оплаты — блок; в форматтере логов — можно жить. Процент по всей базе гейтить бессмысленно (он шумный, см. выше).
  5. Добавь output-инварианты. Это уже не про мутатор, а про то, как ты пишешь сами тесты. Вместо «результат равен вот такому точному значению» проверяй свойство, которое обязано держаться всегда: «в списке нет дублей», «сколько на входе — столько на выходе», «результат отсортирован». Это обычный assert внутри теста, отдельных пакетов не надо. Зачем: такие проверки ловят баги композиции (как дубли worktree выше) — те, что мутатор структурно не видит, потому что ломается не строка, а стык функций.

Правило простое: mutation testing — не рубильник «включил на всё», а прицельный слой на том, что реально болит.

Как честно жить с mutation score

Число легко превратить в маркетинговую пыль. Пара правил, чтобы этого не делать:

  • Не «57% по продукту». Мой real-code гейт даёт MSI 57.08%, но он мутирует ровно 3 модуля (парсер / агрегатор / инжектор id), а не весь проект (аудит). Число всегда с привязкой к конфигу.
  • Это диапазон, не константа. На шумном гейте честнее писать «68–83%», чем выбрать самый удачный прогон.
  • Mutation — слой, не гейт-в-вакууме. Он не заменяет инварианты и прогон против реального артефакта — он их дополняет.

Как индустрия: Google встраивает мутации инкрементально в код-ревью (фильтруют шум, не гонят по всей базе); Meta генерит мутантов LLM — по их собственным данным, независимо не проверено.

Не хочешь руками? Мой скил strong-tests

Всё, что выше, я собрал в скил для агентов-кодеров — strong-tests. Он не просто пишет тесты, а доказывает, что они ловят баги: гоняет mutation testing, подсказывает инварианты и property-based тесты и прогоняет результат через 12-пунктовый чек-лист крепкости (скил).

Работает в четырёх режимах:

  • Greenfield — пишешь новый код, скил сразу даёт под него крепкие тесты (property-based + инварианты).
  • Audit — натравливаешь на существующие тесты, он ищет 8 типов пустышек: моки вместо кода, тавтологии вроде x === x, ассерты, которые всегда true.
  • Mutation-feedback — крутит цикл «прогнал мутации → разобрал выживших → усилил тест», пока kill rate не дорастёт до порога.
  • JiT — хук ловит момент, когда ты пишешь код, и подсовывает инвариант-тест прямо там. Ровно приём, который Meta показала как 4× детект багов.

Понимает четыре стека и сам подбирает тулинг: TS (vitest + Stryker + fast-check), Python (pytest + mutmut + Hypothesis), .NET (xUnit/NUnit + Stryker.NET), Go.

Честно про результат: на собственном детекторе скил поднял mutation score с 51.08% до 56.83% за 10 точечных «киллеров» (лог). Не 90% — потому что остаток это в основном эквивалентные мутанты в regex, которых убить нельзя. Цифра честная, с привязкой к конкретному конфигу.

Сердце скила — 12-пунктовый чек-лист: ≥5 инвариантов на функцию, ≥3 категории входов, ручной «мутационный гутчек» (мысленно поменяй > на >= — поймает ли тест?). Даже без мутатора этот чек-лист уже вытаскивает половину пустышек.

И главное: скил открытый, под MIT — бери и вкручивай в своего агента, покупать ничего не надо: github.com/stgmt/dev-pomogator.

Итог: чеклист против пустышек

Если тесты за вас пишет ИИ:

  • Не верьте покрытию — 100% строк ничего не гарантируют.
  • Прогоните mutation — но помните, что и высокий score врёт (кейс с дублями).
  • Добавьте output-инварианты — «на входе N, на выходе ровно N» ловит то, что мутатор структурно не может.
  • Сверяйтесь с реальным артефактом, а не с выдуманной фикстурой.
  • Гейт по факту (упал ли сценарий под мутантом), а не по проценту.

Свежий взгляд на тему — «Mutation testing for the agentic era» от Trail of Bits. Код всех кейсов выше открыт: github.com/stgmt/dev-pomogator.

FAQ

Чем mutation testing отличается от покрытия? Покрытие говорит, какие строки выполнились. Mutation ломает код и проверяет, заметит ли это хоть один тест. Можно иметь 100% покрытия и 4% mutation score (MutGen).

Если mutation score высокий, тесты хорошие? Не обязательно. В моём кейсе было 97.8% mutation и баг месяцами в проде — правильный фикс был добавлением проверки, а мутатор ловит только потерю существующего поведения.

С чем сочетать mutation testing? С output-инвариантами, прогоном против реального артефакта и BDD-сценариями. В dev-pomogator Stryker мутирует прод-код, а убивают мутантов Cucumber-сценарии (конфиг).

Стоит гнаться за 100% mutation score? Нет — около 30% мутантов эквивалентны (убить нельзя), сам Stryker советует не гнаться. Полезнее детерминированный гейт «упал ли тест под мутантом».

Пост из Telegram-канала Аи Помогатор

Read more

GPT-5.6 vs Claude Fable 5: разбор и сравнение — баннер с 4 живыми примерами

GPT-5.6 vs Claude Fable 5: разбор и сравнение

Коротко TL;DR: GPT-5.6 вышла в паблик 9 июля — ChatGPT, Codex, API. На живых примерах с X счёт с Claude Fable 5 примерно равный: 5.6 берёт 3D и интерактивность, Fable — рендер и законченность. В API у 5.6 контекст 1.05M, но сколько дадут на подписке — OpenAI не

By Аи Помогатор
Баннер: Claude Code без остановки — анти-стоп харнесс на хуках; терминал с Ran 13 stop hooks и заблокированными стопами

Claude Code без остановки: анти-стоп харнесс на хуках

Вкратце: связка Stop-хуков не даёт Claude Code свернуться, пока работа реально не сделана — ловит враньё про «готово» и гонит доделывать. Один заскриненный прогон — 5 часов 9 минут и 719 тысяч токенов; автономность стабильно держит 6 часов+.

By Аи Помогатор
Баннер поста про Claude Fable 5 и запрет модели

Claude Fable 5: запрет за 3 дня, баги и когда вернётся

Вкратце: Claude Fable 5 — публичная модель класса Mythos выше Opus. Вышла 9 июня, через 3 дня США отключили её и Mythos 5 по нацбезопасности. Бенчмарки топ, но это заявки Anthropic; на практике 2× цена Opus и баги тул-юза. 30 июня контроль сняли — доступ восстанавливают с 1 июля.

By Аи Помогатор

Claude Code на Windows: фикс зависшего claude update

Коротко TL;DR: Встроенный claude update на Windows зависает: его HTTP-клиент отваливается по таймауту на загрузке бинарника ~240 МБ (issue #37801). Решение — качать claude.exe напрямую через curl -L -C - с резюмом, сверять SHA256 по официальному манифесту и активировать копией поверх .local\bin\claude.exe. Практический гайд: как обновить

By Аи Помогатор
AI ПомогаторТарифы | Документация API | Регистрация