Генерація тестів здається ідеальною роботою для AI-асистента: нудна, шаблонна і легко перевіряється. Попросіть будь-яку сучасну модель «написати юніт-тести для цього класу» — і за секунди отримаєте акуратний файл із десятком зелених тестів. Проблема в тому, що зелені тести — не мета. Тести існують, щоб падати, коли код неправильний, а згенеровані AI тести часто не падають. У гайді пояснюємо, чому так відбувається і як побудувати процес, у якому згенеровані тести справді підвищують виявлення дефектів, а не лише відсоток покриття.
Головна проблема: тести-дзеркала реалізації
Коли модель генерує тести, читаючи реалізацію, вона схильна фіксувати те, що код робить, а не те, що він має робити. Якщо у функції баг, згенерований тест стверджує багову поведінку. Покриття росте, впевненість росте, а баг тепер захищений тестом.
Типові ознаки малоцінних згенерованих тестів:
- Асерти, що переказують реалізацію (
expect(calc(2)).toBe(2 * RATE), скопійоване з коду). - Надмірні моки, тож тест лише перевіряє, що моки викликалися в певному порядку.
- Лише щасливі сценарії; жодних меж, null, порожніх колекцій чи помилок.
- Snapshot-тести великих об'єктів, які ніхто не читає.
- Тести, що проходять, навіть якщо видалити тіло методу.
Останню ознаку можна виміряти, і саме це вимірювання — ключ до всього процесу.
Уроки TestGen-LLM від Meta
Meta опублікувала один із найінформативніших промислових звітів про генерацію тестів за допомогою LLM. Їхній інструмент TestGen-LLM генерував додаткові тести для наявних Kotlin-класів тестів в Instagram і Facebook, але моделі не довіряв. Кожен кандидат мав пройти серію фільтрів: зібратися, стабільно проходити в кількох запусках (щоб відсіяти нестабільні тести) і підвищити покриття. Лише тоді його пропонували інженерам як дифф.
Цифри показові. Зі згенерованих тестів приблизно 75% збиралися коректно, 57% стабільно проходили, а 25% підвищували покриття. Під час test-a-thon у Meta інженери прийняли до продакшену 73% рекомендованих покращень (Alshahwan et al., 2024).
Урок: більшість сирих генерацій некорисні, і це нормально, якщо автоматичні фільтри їх відкидають. Розглядайте модель як генератор кандидатів і ставте детерміновані перевірки між нею і вашим репозиторієм.
Мутаційне тестування: критерій якості, що має значення
Покриття показує, які рядки виконувалися. Мутаційне тестування показує, чи помітили б тести, якби ці рядки були неправильними. Мутаційний інструмент вносить у код невеликі зміни — замінює > на >=, підміняє значення, що повертається, видаляє виклик — і запускає тести. Якщо тести все одно проходять, мутант «вижив» — і ви знайшли прогалину.
Для більшості екосистем є зрілі інструменти:
| Мова | Інструмент |
|---|---|
| Java, Kotlin (JVM) | PIT (pitest) |
| JavaScript, TypeScript, C#, Scala | Stryker |
| Python | mutmut, Cosmic Ray |
| Go | go-mutesting, Gremlins |
Mutation score — значно краща ціль для AI-тестів, ніж покриття, бо її важко «накрутити»: тест без асертів не вбиває жодного мутанта. Дослідження, що поєднують LLM із мутаційним тестуванням — коли моделі повертають мутантів, що вижили, і просять тест, який їх уб'є, — показують помітне покращення виявлення дефектів порівняно з генерацією, орієнтованою на покриття. Сьогодні це практичний процес, який можна запустити з будь-яким агентом.
Процес, що працює
Крок 1: Специфікація до тестів
Не просіть модель «протестувати цей клас». Дайте їй очікувану поведінку:
Напиши JUnit 5 тести для DiscountService.calculate(order).
Бізнес-правила (джерело істини — НЕ виводь правила з реалізації):
- Замовлення >= 1000 грн отримують знижку 5%; >= 5000 грн — 10%.
- Учасники програми лояльності отримують додаткові 2% після знижки за обсяг.
- Сумарна знижка ніколи не перевищує 15%.
- Замовлення з будь-яким акційним товаром не отримують знижки за обсяг.
- Суми — BigDecimal, округлення HALF_UP до 2 знаків.
Додай граничні значення (999.99, 1000.00, 4999.99, 5000.00),
порожнє замовлення і null-клієнта. Використовуй AssertJ. Без моків для value objects.
Коли специфікація й реалізація розходяться, тест падає — саме цього ми й хочемо. Або в коді баг, або специфікація застаріла, і вирішує людина.
Крок 2: Запустити, відфільтрувати, залишити лише валідних кандидатів
Автоматизуйте фільтри TestGen-LLM:
- Компілюється — інакше відкинути або один раз повернути моделі помилку компілятора.
- Стабільно проходить — запустити 3–5 разів; нестабільні тести відкинути.
- Падає з правильної причини — для кожного тесту, що падає, людина перевіряє, хто неправий: код чи тест.
Крок 3: Мутаційне тестування і цикл по мутантах, що вижили
# JVM: запустити PIT для одного пакета
./gradlew pitest -Ppitest.targetClasses='com.shop.discount.*'
# TypeScript: запустити Stryker для однієї директорії
npx stryker run --mutate "src/discount/**/*.ts"
Повертайте агенту мутантів, що вижили: «Цей мутант вижив: у DiscountService.java:42 >= замінено на >. Напиши тест, що падає на мутанті й проходить на оригіналі». Повторюйте, доки mutation score не перестане зростати або не досягне вашого порогу.
Крок 4: Рев'ю, як для продакшен-коду
Згенеровані тести стають частиною кодової бази, і їх доведеться підтримувати. Перевіряйте читабельність, назви, що описують поведінку (appliesTenPercentAtExactlyFiveThousand), відсутність надмірних моків і те, що кожен тест має одну причину впасти.
Характеризаційні тести для легасі
Для легасі-коду без специфікації проблема «дзеркала реалізації» стає перевагою. Характеризаційні тести — термін Майкла Фезерса — свідомо фіксують поточну поведінку, разом із багами, щоб безпечно рефакторити. LLM у цьому чудові:
- Попросіть агента перелічити класи вхідних даних за гілками коду.
- Згенеруйте тести, що записують поточні результати, зокрема несподівані.
- Позначайте підозрілі результати коментарем на кшталт
// CHARACTERIZATION: схоже на баг, див. #123. - Мутаційним тестуванням переконайтеся, що набір справді фіксує поведінку.
Це основа будь-якої міграції чи модернізації; див. AI для модернізації легасі-коду.
Промпти й патерни для різних стеків
Java/Kotlin зі Spring. Спершу просіть звичайні юніт-тести доменних класів; зрізи @WebMvcTest чи @DataJpaTest — лише там, де потрібно. Явно забороніть @SpringBootTest для юніт-тестів: моделі його обожнюють, а набори через нього стають повільними. Для Kotlin вкажіть фреймворк (JUnit 5 + AssertJ або Kotest) і MockK замість Mockito. Налаштування описане в нашому гайді зі Spring Boot на Kotlin.
TypeScript. Вкажіть Vitest чи Jest, testing-library для React-компонентів і правило «тестуй поведінку, видиму користувачу, а не деталі реалізації». Просіть таблиці test.each для граничних значень — моделі генерують читабельні таблиці, що дешево покривають багато випадків.
Property-based тести. Попросіть модель запропонувати інваріанти («сума ніколи не від'ємна», «знижка ніколи не більша за 15%») і реалізувати їх через jqwik, property testing у Kotest, fast-check чи Hypothesis. LLM добре знаходять інваріанти, про які люди забувають.
Агентна генерація тестів у масштабі
З кодовим агентом і швидкою командою тестів генерацію можна запускати фоновою задачею для цілого модуля: агент обирає найменш покритий клас, пише тести за специфікацією або характеризує поведінку, проганяє фільтри й мутаційні тести та відкриває невеликий PR на кожен клас. Тримайте PR маленькими й придатними до рев'ю, як описано в найкращих практиках агентного кодування, і плануйте запуски поза робочими годинами, якщо ресурс CI обмежений. Про запуск агентів у пайплайнах — у статті AI-агенти в CI/CD.
Критерій якості в CI
Згенеровані тести мають проходити ті самі шлюзи, що й фільтри генератора, щоразу, коли вони змінюються. Практичне налаштування:
# .github/workflows/test-quality.yml (фрагмент)
- name: Юніт-тести, три запуски для виявлення нестабільності
run: for i in 1 2 3; do ./gradlew test --rerun-tasks || exit 1; done
- name: Мутаційне тестування змінених модулів
run: ./gradlew pitest -Ppitest.mutationThreshold=70
Встановлюйте поріг mutation score для кожного модуля окремо, а не глобально: легасі-модулі можуть стартувати з 40% і поступово зростати, а новий доменний код — тримати 75–80%. Якщо повне мутаційне тестування надто повільне для кожного пул-реквесту, запускайте його вночі, а збірку валіть лише при зниженні показника, а не на модулях, що ще не досягли цілі. Так mutation score стає храповиком: згенеровані тести можуть лише підвищувати його.
Метрики для відстеження
- Mutation score для модулів зі згенерованими тестами — до і після.
- Пропущені дефекти — баги в продакшені в областях зі згенерованими тестами.
- Частка нестабільних тестів — згенеровані тести не мають робити CI менш надійним.
- Час виконання набору — стежте за повільними інтеграційними тестами, замаскованими під юніт-тести.
- Час рев'ю PR з тестами — якщо рев'юери витрачають більше, ніж забрало б написання вручну, змініть процес.
Оцінювання самого генератора тестів — невелика задача на evals; підхід зі статті evals для LLM застосовується напряму.
FAQ
Чи варто AI генерувати тести для коду, який він щойно написав? Можна, але тести поділятимуть хибні уявлення коду. Пишіть або переглядайте специфікацію самі чи генеруйте тести в новій сесії лише зі специфікації, не показуючи реалізацію.
Чи 100% покриття — добра ціль для згенерованих тестів? Ні. Це заохочує тести тривіальних геттерів і згенерованого коду. Цільтеся у високий mutation score для бізнес-логіки.
Як уникнути крихких тестів? Забороніть моки value objects і внутрішніх співпрацівників, тестуйте через публічні API і відхиляйте snapshot-тести, якщо результат не малий і не змістовний.
Чи працює це для інтеграційних тестів? Так, особливо з Testcontainers і зрозумілими фікстурами. Дайте агенту docker-compose і робочий приклад тесту для наслідування.
Джерела
- Alshahwan et al. (2024). Automated Unit Test Improvement using Large Language Models at Meta.
- PIT mutation testing for the JVM.
- Stryker Mutator.
- Petrović, G., Ivanković, M. (2018). State of Mutation Testing at Google. ICSE-SEIP.
- Michael Feathers. Working Effectively with Legacy Code. Prentice Hall, 2004.
- Anthropic. Claude Code best practices: write tests, commit; code, iterate, commit.