Backend Meetup от OneTwoTrip: AI-генерация unit-тестов

Backend Meetup от OneTwoTrip: AI-генерация unit-тестов

18 сентября 2025 прошёл наш офлайн-митап для бэкендеров. В этой статье — презентация Никиты Тимофеева, который подробно поделился своим опытом разработки npm-пакета для систематичной AI-генерации unit-тестов.

О спикере

Никита Тимофеев — teamlead проектов ж/д, аренды авто и партнёрского API. В OneTwoTrip он более трёх лет, а до этого четыре года работал в индонезийской компании EasyPay на позициях backend dev и teamlead.

Выступление Никиты Тимофеева

Идея

Основная идея — вайбкодить unit-тесты в проектах JavaScript/Typescript, полагаясь на coverage-отчёт.

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

Использование моделей искусственного интеллекта для генерации unit-тестов усиливает эти преимущества: AI способен анализировать код, выявлять неохваченные сценарии и автоматически создавать тесты с высоким покрытием. Это значительно экономит время разработчиков, снижает человеческий фактор и позволяет сосредоточиться на логике и архитектуре продукта, а не на рутине написания тестов. В итоге комбинация unit-тестирования и AI-генерации обеспечивает более устойчивую, предсказуемую и качественную разработку программного обеспечения.

Однако даже вайбкодинг в традиционном представлении (когда на каждый отдельный случай приходится писать новый промпт и прикладывать контекст) становится рутиной — возникает идея систематизации процесса до «нажатия одной кнопки» для получения качественного результата.

Можно ли систематизировать процесс генерации кода для unit-тестов? Спойлер: да! Далее разберёмся, как это организовать.

Концепции

Концепция 1: Генерируем через веб-чат или Copilot/ContinueDEV, ну что-то ещё прямо в IDE. Это быстрый, простой и относительно бесплатный способ. Однако это не систематично и довольно муторно, потому что, если мы хотим генерировать новые unit-тесты, то, скорее всего, такой подход будет не очень удобный, и нам придётся что-то писать дополнительно.

Концепция 2: Генерируем через CLI с утилитой Keploy, которая в принципе решает проблему систематичной генерации unit-тестов. Соответственно, плюсов у неё много: это и простой запуск, и то, что утилита сама парсит coverage-отчёт. Ну и не нужно ничего писать, кроме команд в командной строке.

Однако есть и минусы: если у вас много файлов, это довольно трудоёмкий процесс, потому что для каждого придётся писать команды отдельно. Кроме того, это не npm-пакет, поэтому в проектах JavaScript на Node будет не очень удобно пользоваться. Также нужен API-токен модели. И самое неприятное — утилита корёжит исходный код, не умеет фиксить падающие тесты и кушает много input-токенов.

Концепция 3: Пишем свой npm-пакет с ai-sdk. Именно о ней мы сегодня и поговорим, потому что в итоге остановились на этом способе. Фактически мы постараемся реконструировать подход из второй концепции с Keploy и сделать его более систематичным и удобным для разработки. А ещё добавим своих фичей и избавимся от всего лишнего. Из минусов: всё так же требуется API-токен модели. Из плюсов: делаем что хотим, поэтому дальше разберём именно эту концепцию

Как это должно работать

Для начала определим требования к решению, которое будем разрабатывать в рамках последней «эталонной» концепции с ai-sdk.

  1. NodeJS и npm.
  2. CLI-интерфейс.
  3. Генерация unit-тестов в параллель.
  4. Учёт покрытия по coverage-отчёту.
  5. Учёт зависимостей проекта.
  6. Ignore-паттерны.
  7. Фиксы падающих тестов.
  8. TRUE-вайбкодинг!

Зафиксируем, что мы пишем npm-пакет, и у нас уже встроены:

  1. TypeScript.
  2. Commander для CLI.
  3. Ignore-паттерны с minimatch.
  4. JSON в Markdown с json2md для упрощения работы с контекстом для AI-моделей.

А теперь переходим к основной части

Начнём с базового контекста для промтов (код на 03:53 ролика). Он нужен для того, чтобы моделька в принципе понимала, чего мы от неё хотим. Поэтому в Markdown описываем основные требования: роль, правила, editorconfig (если находим), зависимости package.json, исходный код и расположение файла с кодом, чтобы модели было проще ориентироваться в проекте.

Как будем промтить? Код — на 04:35. В пакете ai-sdk есть специальный метод для генерации ответа в формате текста — generateText(...). Он работает достаточно просто (04:45). Фактически это обёртка вокруг HTTP-запроса, который отправляется провайдеру модели. В рамках запроса отправляем текст промта, а ответ получаем в формате текста.

После всей этой вводной информации начинаем вайбкодить первые тесты (05:11). Для этого берём новый файл с кодом, для которого ещё нет unit-тестов, промтим и получаем текст. Вставляем этот текст в spec.ts файл и прогоняем.

И получаем первые проблемы (05:32).

Выясняется, что если у нас падает хоть один тест, то приходится всё перегенерировать заново. Для решения этой проблемы можно добавить дополнительные итерации генерации/прогона (например, 5 раз, пока все тесты не начнут проходить), а также мы можем добавить возможность парсить ответ модели, чтобы гонять тесты изолированно и оставлять только те, которые проходят.

Здесь нам поможет структурированный вывод (06:10). В моделях мы можем использовать generateObject(...), который генерирует ответ в формате объекта по заданной JSON-схеме с zod. Работает generateObject(...) так (код на 06:38): в наш запрос добавляются tool_choice и tools, где передаётся JSON-схема ответа. Модель при генерации ответа будет использовать переданную схему для валидации.

Далее мы пишем свою схему ответа, который ожидаем от модели (07:17), то есть сами описываем JSON-схему вывода с zod. Мы хотим получить массив импортов и массив тестов. Также указываем минимальную длину массивов от одного элемента, чтобы модель сгенерировала хоть что-то полезное.

Снова пробуем всё это применить — на 07:40 смотрим, что получилось.

Добавляем требование о том, что первый тест должен всегда проходить, и действуем так:

  1. Получаем ответ от модели с тестами и импортами.
  2. Берём первый тест (который должен всегда проходить) и кладём с импортами в файл spec.ts.
  3. Прогоняем.
  4. Если всё ок — удаляем первый тест и кладём остальные по очереди.
  5. Всё, что проходит — оставляем.

И получаем картину, как на 08:14. Что же не так?

Оказалось, что повторная генерация для новых сущностей в коде создаёт дубли unit-тестов. С этим тоже можно работать: использовать coverage-отчёт, то есть говорить модели, какие строки кода ей нужно задействовать в тестах, а какие не нужно, и разделять генерацию на стратегии.

Чуть подробнее о coverage-отчёте на 09:05, там же пример cobertura-coverage.xml. Его мы попробуем вставить как есть в сыром виде в промт и посмотрим, что нам это даст. По ходу тестирования выяснилось, что модель, правда, плохо понимает сырой формат, а ещё мы тратим много input-токенов, повышая стоимость одного запроса.

Чтобы решить эту задачу, будем парсить отчёт в список из номеров непокрытых строк кода. Делать это будем с помощью соответствующего npm-пакета @connectis/coverage-parser (10:10). Его нам посоветовал ChatGPT, правда, немного неприятно, что последний апдейт был аж 7 лет назад — но всё не так страшно.

Собираем всё во второй раз. Допиливаем стратегии и запускаем (10:30). Теперь для новых и пустых spec-файлов промтим, как уже умеем, а для наполненных spec-файлов добавляем в контекст промпта код unit-тестов, чтобы модель предлагала новые импорты. Парсим coverage-отчёт и добавляем в контекст строки, которые нужно покрыть.

И тут случается неприятность (скриншот на 10:58): выяснилось, что что-то очень сильно сжирает память. Искать виновника будем через инспектор — и обнаруживаем, что он тоже ест много памяти (11:23), и мы ничего не можем с этим сделать. Так что дальше придётся искать по ощущениям.

И виновником оказывается тот самый пакет @connectis/coverage-parser, который нам посоветовал ChatGPT для парсинга coverage (11:33). Придётся от него избавиться и писать свой парсер (11:42).

Для этого можно использовать, например, пакет fast-xml-parser. Наша задача — просто пробежаться по тегам и атрибутам в cobertura-coverage.xml. По аналогии с пакетом @connectis/coverage-parser собираем всё, где hits = 0. А дальше проверяем и учимся фиксить падающие тесты.

Пишем для этого новую стратегию фикса (12:20). То есть, если при запуске нашей утилиты какие-то unit-тесты будут падать, мы можем попросить модель пофиксить проблему. Для этого передаём в контекст промпта код unit-тестов и ошибку прогона. В качестве ответа всё по той же JSON-схеме ожидаем массив фиксов в виде объектов с кодом фикса, а также номерами строк от/до, где требуется коррекция.

И мы имеем следующее:

  • Генерируем тесты с нуля.
  • Добавляем тесты к уже имеющимся без дублирования кода.
  • Фиксим падающие тесты.

Но это ещё не всё! Дальше мы будем пытаться всё это оптимизировать.

Оптимизация

Первая тема оптимизации — параметры промтинга (13:39). Проблема в том, что модель может отвечать одинаковыми ответами или буксовать (то есть не возвращает ничего полезного). Чтобы решить это, корректируем поведение модели следующими параметрами:

  • Seed
  • Temperature
  • TopP
  • TopK

Seed нужен для установки начальной точки генерации. Последние три рассматриваем на 14:05. Если объяснить поверхностно, при каждом поиске у нас собирается дерево из векторов output-токенов. И мы можем корректировать учёт и использование этих ветвей за счёт следующих параметров:

  • Temperature, где значение 0,1 считается приемлемым для генерации кода (то есть 10% вероятности; ограничивает или расширяет общую креативность модели без обрезания);
  • TopK с оптимальным значением 20 для кода (обрезает все маловероятные совпадения по убыванию, и как только набирается 20 токенов, остальные мы не берём);
  • TopP, где 0,5 также является оптимальным значением для генерации кода (суммирует вероятности совпадений).

Как только набирается необходимый процент, остальное обрезается. Коррекция этих параметров поможет генерировать результат немного разнообразнее.

Пример:

  1. Пробуем сгенерировать unit-тесты с параметрами Temperature = 0,1 и TopP = 0,5.
  2. Модель плохо справилась с задачей.
  3. Корректируем параметры Temperature = 0,2 и TopP = 0,6, пробуем заново.
  4. Ответ всё так же не повышает процент покрытия.
  5. Корректируем параметры Temperature = 0,3 и TopP = 0,7 — и запускаем ещё раз.
  6. Повторяем n раз.

Далее применяем все эти параметры — пример использования на 15:48. Мы можем корректировать значения параметров для каждой новой итерации прогона генерации unit-тестов в рамках конкретной стратегии. Но важно учитывать, что не все модели умеют работать с параметрами одинаково. Например, openai/gpt-4o может гибко работать с параметром temperature, а openai/gpt-5 не поддерживает любые значения temperature, кроме 1.

Теперь поговорим про стабильность генерации по покрытию (16:45). Иногда модель ничего не может сделать, когда передаётся очень много непокрытых номеров строк кода. Чтобы решить эту проблему, просто ограничиваем список непокрытых строк кода до 5 на каждый промт. И при каждой итерации просто меняем номера строк, пока не наберётся желаемый процент покрытия.

Структурирование контекста (17:01) — тоже достаточно неочевидная проблема. Модель иногда теряется в базовом контексте, и тут хорошо помогает разделение задачи и контекста заголовками H1, а всё, что внутри, обозначается заголовками H2. Также помогает нумерация сегментов контекста.

После упрощаем контекст (17:28). Тут проблема в том, что при добавлении тестов к имеющимся приходится отправлять весь код тестов. Это усложняет задачу для модели и тратит input-токены, а вся польза — учёт существующих импортов и хелперов в коде. Решение такое: парсим код тестов в AST, извлекаем импорты и хелперы.

AST — Abstract Syntax Tree (18:07). Для его парсинга есть решение — пакет recast. Если по-простому, он пробегается по коду и собирает дерево из нод деклараций, вызовов и операций. У нас задача простая, нам нужно просто пройти по верхнему слою дерева нод. Всё, что относится к импортам и хелперам, собираем. Что является вызовами — пропускаем, так как, вероятно, это сами тесты. А всё собранное кладём в контекст промпта.

Стоимость и рациональность

Попробуем разобраться, сколько стоит генерация тестов. Оценим на основе небольшого куска кода (18:47), в котором имеются 3 функции: счётчик через замыкание, функция-кастер для типизации и Promise-обертка вокруг setTimeout с возможностью ручного аборта. По дефолту целимся в покрытие 80%.

  • Gpt-5-mini справилась за 2 итерации: 80%. Тратит 0,01$ и 105 секунд.
  • Gpt-5: 1 итерация: 80%. Тратит ~0,05$ и 69 секунд.
  • Gemini-2.5-flash: 1 итерация: 82%. Тратит при этом 0$ и 21 секунды.
  • Claude-sonnet-4: 1 итерация: 82%. Тратит 0,01$ и 33 секунды.

Никита обычно начинает с Gemini-2.5-flash, потому что это бесплатно (до ограничения по rate-лимиту), дальше, если эта модель не помогает, идёт в Gpt-5-mini, а дальше уже в Claude-sonnet-4 или Gpt-5.

А посмотреть проект в профиле Никиты на Github можно по этой ссылке: https://github.com/n1k1t/unit-generator.

1
Начать дискуссию