Представьте себе: вы отлаживаете новый баг в сложном многослойном приложении (например, на Spring). Чтобы воспроизвести проблему, приходится взаимодействовать со всей системой end-to-end: отправлять запрос на эндпоинт или что-то кликать в UI. Юнит-теста, который бы изолировал нежелательное поведение до уровня злополучного сервиса или утилиты, нет. А хотелось бы, чтобы он был: во-первых, воспроизводить баг было бы проще (особенно если UI кликает QA, а не вы), а во-вторых, его потом можно было бы легко превратить в регрессионный и улучшить стабильность системы.

Тем временем все данные, нужные для воспроизведения бага в изоляции, есть. В окне отладчика видно, что приходит в метод, и в каком состоянии находится сервис перед тем, как все пойдёт не так. Мы в Explyt RnD решили, что нечего добру пропадать, и научились перехватывать это состояние, а потом генерировать юнит-тест, который в точности его воспроизводит.

Сегодня расскажу о пройденном исследовательском пути, о том, как попробовать нашу экспериментальную фичу в плагине для IntelliJ IDEA, и о том, что у неё под капотом (спойлер: не только LLM).

Лучше один раз показать

Дабы не рассуждать о технических деталях и философии всего происходящего вслепую, сразу посмотрим на то, что у нас получилось. Тестироваться будем на достаточно популярном спринговом проекте klaw.

У нас вау-эффект от того, что мы делаем, возник на юзкейсах с веб-UI: когда прокликиваешь, например, форму в вебе и тут же получаешь юнит-тест на какой-нибудь сервис, который при этом был вызван внутри. В arrange-секции — реальные введённые данные, а assert-ы — на реальный результат вызова. Вот, например, мы заполняем форму для добавления новой команды в работающем на localhost приложении. Вызов сервиса захватывается, и генерируется юнит-тест для метода addNewTeam сервиса UsersTeamsControllerService с введёнными данными:

Веб-UI и localhost-ом дело не ограничивается. К любому JVM-процессу с открытым портом для отладки, в том числе и удалённому, можно присоединиться и воспроизвести тесты на желаемый метод. Отдельно добавили возможность запускать Run Configurations в IntelliJ IDEA: если, например, есть интеграционные тесты и хочется получить юниты за просто так. Здесь запускаем интеграционные тесты для того же сервиса и получаем тесты на все случившиеся вызовы addNewTeam:

Чтобы не отлавливать гору ненужных вызовов, можно поставить брейкпоинт в целевом методе и генерировать тесты только на исполнения, попадающие в него. Тут мы ставим точку, которая срабатывает при успешном добавлении команды, но не срабатывает при попытке добавить команду с уже существующим именем. Тест в итоге генерируется только на успешный кейс:

Научно-исследовательские и опытно-конструкторские работы

А теперь сделаем флешбек и вместе с вами погрузимся в мир RnD, где тесты автоматически генерировали ещё до того, как появились LLM и это стало мейнстримом, а слово «агент» ассоциируется не только с Cursor и Copilot, но и с патчингом байт-кода.

Полгода назад к нам, разработчикам плагина Explyt для IntelliJ IDEA, пришли коллеги из соседнего офиса. Они работают над крупной системой для логистики на Spring со сложными алгоритмами и структурами данных. Из-за особенностей системы процесс тестирования сведён в основном к интеграционным тестам, которые пишут QA, а юнит-тестов не так много. В связи с этим возникла идея получать из первых вторые, перехватывая данные из реального исполнения. Нам идея понравилась: мы поняли, что это шанс разработать уникальную фичу для плагина, которая бы по сути переворачивала пирамиду тестирования и к тому же объединяла формальные методы с LLM. В общем, отличная задача для отдела RnD. Так и начались научно-исследовательские и опытно-конструкторские работы, которыми мы с коллегами занимались на протяжении почти полугода.

План вторжения в JVM

Первый вопрос, который нам было необходимо решить — как захватывать состояние исполнения программы. Остановились на том, что наш инструмент будет отладчиком, то есть будет подключаться к приложению по протоколу JDWP (Java Debug Wire Protocol) и получать данные через JDI (Java Debug Interface). Давайте посмотрим на возможные альтернативы и на причины, почему они нам не подошли:

JVM Agent. Первое, что часто приходит на ум, когда речь идёт о внедрении в работу JVM — это инструментирование. Трассировщики, профилировщики, измерители тестового покрытия и многие другие подобные инструменты часто основаны на JVM‑агентах. Можно сделать своего JVM‑агента и запатчить байт‑код как вздумается. Так, мы могли бы добавить код, собирающий дампы, в определённые точки программы. Опыт написания таких вещей у нашей команды был, но этот путь показался более сложным. Дело в том, что байт‑код подменяется один раз при компиляции, и поэтому в рантайме мы уже не можем маневрировать и использовать различные алгоритмы в зависимости от ситуации. А в нашей задаче, как мы увидим, многое завязано именно на эвристики.

AOP (Aspect‑Oriented Programming). Парадигма аспектно‑ориентированного программирования также подразумевает добавление новой функциональности к коду, но более «казуальным» способом. Фреймворки вроде AspectJ позволяют прямо в Java коде определить логику, которая будет выполняться в join‑point‑aх (например, перед вызовом определённых методов). Этот вариант в какой‑то момент (когда мы уже долго возились с JDI) предложил CTO, с которым мы взаимодействовали: он попробовал логировать все аргументы метода через AspectJ, а потом просить LLM генерировать тест по логу. Это работало на примерах, где все аргументы либо простые, либо сериализуемые, но не масштабировалось. Нам хотелось сделать более‑менее универсальный инструмент, который бы мог собирать и воссоздавать нетривиальные объекты.

XDebugger API. Так как мы разрабатываем плагин для IntelliJ IDEA, у нас был большой соблазн использовать API отладчика, которое предоставляет IntelliJ Platform. Через XDebugger API можно реализовать какие‑нибудь кастомные брейкпоинты или вообще сделать отладчик для своего языка. Мы ошибочно поддались соблазну — API оказалось сложным и недостаточно гибким для наших задач. К примеру, нам нужно было ставить «невидимые» брейкпоинты для сбора данных. Убрать «красный кружок» удалось, а вот сделать так, чтобы наши брейкпоинты не появлялись в списках всех брейкпоинтов — уже нет. Точнее, мы поняли, что легче использовать JDI напрямую, чем делать костыли.

Взаимодействовать с дебаггером через JDI API несложно. Например, вот так добавляются и обрабатываются брейкпоинты:

// Подключаемся к отлаживаемой JVM по JDWP
val vm = attach("127.0.0.1", "5005")
val erm = vm.eventRequestManager()

// Важный момент: надо дождаться, когда нужный класс будет загружен в JVM,
// и только после этого ставить брейкпоинт
val prep = erm.createClassPrepareRequest().apply {
    addClassFilter("TargetClass")
    enable()
}

val queue = vm.eventQueue()
while (true) {
    val set = queue.remove()
    for (e in set) {
        when (e) {

            // Дождались загрузки класса, можем ставить брейкпоинт
            is ClassPrepareEvent -> {
                val target = e.referenceType()
                val method = target.methodsByName("targetMethod").first()
                val loc = method.allLineLocations().first() // первая исполняемая строка в методе

                erm.createBreakpointRequest(loc).apply {
                    addCountFilter(1) // сработает один раз
                    enable()
                }
                println("[jdi-bp] Брейкпоинт установлен в ${target.name()}:${loc.lineNumber()}")
            }

            // Когда брейкпоинт сработал — выводим имя аргумента
            is BreakpointEvent -> {
                val frame = e.thread().frame(0)
                println("[jdi-bp] Остановились на строке ${e.location().lineNumber()}")

                for (v in frame.visibleVariables()) {
                    if (v.name() == "arg") {
                        val value: Value? = frame.getValue(v)
                        println("[jdi-bp] arg=$value")
                    }
                }
            }
        }
    }
    // Разрешаем отлаживаемому процессу продолжить выполнение
    set.resume()
}


// Функция подключения к JVM по JDWP
private fun attach(host: String, port: String): VirtualMachine {
    val connector: AttachingConnector =
        Bootstrap.virtualMachineManager().attachingConnectors().first {
            it.name() == "com.sun.jdi.SocketAttach"
        }
    val args: MutableMap<String, Connector.Argument> = connector.defaultArguments()
    args["hostname"]!!.setValue(host)
    args["port"]!!.setValue(port)
    return connector.attach(args)
}

В идеальном мире…

Став отладчиком, мы получили доступ к большому количеству информации о состоянии исполнения. Во время отладки в IDE видно стек вызовов, все аргументы, структуру объектов, но всё это — обёртка над функциями JDI, и все те же данные мы могли использовать в своём инструменте. Итак, программа исполняется в реальном окружении, мы стоим в брейкпоинте в самом начале целевого метода (method under test). Что нам понадобится?

Экземпляр класса. Тест, который мы будем генерировать, должен в Act-секции (в структуре Arrange-Act-Assert) вызвать метод у объекта в таком же состоянии, как в рантайме. Состояние объекта — это по сути значения его полей. Отлично, JDI позволяет нам ходить по структуре объектов, и мы можем сделать рекурсивный дамп.

Аргументы метода. Если метод принимает аргументы, то с ними нужно проделать то же самое.

Возвращаемое значение. Хороший тест должен иметь и Assert-секцию, иначе его смысл неясен (разве что набить тестовое покрытие для повышения KPI). Для того, чтобы её получить, поставим брейкпоинты ещё и во все return-ы метода. Не забываем и про ошибочное завершение: добавляем брейкпоинт на все исключения (это можно сделать, так же как и в IDE). Собираем возвращаемые значения и исключения в дамп так же, как мы делали с this и аргументами.

Мы собрали деревья объекта this, аргументов метода и возвращаемого значения: листья — это примитивы, а ветви — поля. Как из этого получить код? Ведь чтобы создать объект, нужно вызвать ряд конструкторов, а поля, скорее всего вообще приватные, и не у всех их есть сеттеры. Мы пришли к вечному и экзистенциальному вопросу для всех формальных методов генерации тестов…

Но это не идеальный мир

Если вы не против, приведу краткую историческую справку. До распространения LLM для автоматической генерации тестов применялись только формальные методы, например, символьное исполнение или фаззинг. Эти методы анализируют поведение программы, и, можно сказать, оперируют данными, а не кодом, как это делают LLM. Символьное исполнение способно (как минимум, теоретически, но сейчас не об этом) формально исследовать все краевые случаи и выдать входные данные, которые их воспроизводят. А фаззинг позволяет (а вот это точно) перебрать огромное количество входных данных и «обстрелять» ими вашу программу. Если захочется узнать о формальных методах побольше, можно посмотреть, например, записи докладов Дмитрия Мордвинова и Дмитрия Иванова о символьном исполнении и Максима Пелевина о фаззинге. Оба метода работают, но, как и в нашем случае, выдают «сырые» данные и не предоставляют информации о том, как восстановить объекты с точки зрения ООП. Это глобальная нерешённая проблема. Есть много эвристик на этот счёт, и автор статьи даже делал магистерскую с их разбором, где предложил yet another one. А сейчас в игру вступили ещё и LLM, которые, возможно, могут эту игру перевернуть.

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

Делаем сказку былью

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

Самая простая эвристика — для распространённых и не очень сложных классов сразу же подставлять сигнатуры конструкторов и билдеров в код теста. Например, если мы видим объект LocalDate с определёнными значениями полей дня, месяца и года, то в тест можно добавить соответствующий вызов метода LocalDate.of. Аналогично поступаем со многими другими классами из стандартных библиотек Java, Kotlin и Spring.

В случае пользовательских классов мы заранее не знаем, какими конструкторами создавать объекты, но можем воспользоваться нашими суперсилами отладчика.

Помимо брейкпоинтов в целевом методе добавим брейкпоинты ещё и в конструкторах и методах вроде getInstance, чтобы определить, как this был создан в реальности. Также поищем частые паттерны: к примеру, простые конструкторы, у которых список аргументов «зеркалит» все поля. Такое часто встречается в Spring сервисах.

Ещё один распространённый случай — большие, сериализуемые DTO. В случае с приложением для логистики, на котором мы обкатывались, таких объектов с огромными Map-ами внутри было много. Чтобы избежать генерации тестовых методов длиной в тысячи строк, мы стали отлавливать подобные объекты, сериализовать их и класть json-ы с сериализованными объектами в ресурсы неподалёку от тестов.

И наконец, часть объектов мы мокируем. Поскольку в центре нашего внимания юнит-тесты, создавать настоящие репозитории и прочие не имеющие отношения к делу компоненты не нужно — требуется только понять, как они взаимодействуют с целевым объектом. Здесь мы снова используем наше преимущество дебаггера и расставляем брейкпоинты в точках выхода из методов классов, которые мы считаем кандидатами в моки. На выходе собираем дескрипторы полученных реальных значений и затем создаём в тесте мок с таким же поведением.

Приходится использовать различные способы создания объектов в тестах
Приходится использовать различные способы создания объектов в тестах

В итоге после применения всех эвристик мы имеем дело уже не с сырым дампом памяти, а с моделью, в которой объектам сопоставлены ещё и способы их создания. Такую модель можно конвертировать в код теста, причём не LLM, а механически, алгоритмом.

Дальше действовать будет ИИ

Всё время, пока мы прокачивали наш отладчик, ИИ стоял в стороне. Кажется, что даже код теста получается генерировать «дедовскими» методами, и этот код не состоит целиком из вызовов Reflection API. В этот момент можно было бы остановиться и начать писать научную статью. Но мы работаем не в НИИ, и платят нам за продуктовые фичи, а не за proof-of-concept. Получающиеся тесты в прод никто не пропустит: хоть в них и нет рефлексии, код далёк от стандартов. Как адекватно назвать переменные, да и сами тесты, мы не знаем, а часть функциональности наверняка можно вынести в переиспользуемые утилиты или просто сократить. Здесь и наступает звёздный час LLM: отрефакторить код, подражая стилю проекта — очень хорошая задача для них. А если что-то перестало компилироваться — применяем quickfixes в IntelliJ IDEA или снова просим помощи у LLM. Теперь есть, что показать пользователю, чтобы было не очень стыдно.

Есть и ещё одно неочевидное применение LLM, которое помогло нашему инструменту получить «визу» для релиза. Экспериментальная система, завязанная на эвристики, скорее всего, не безотказна. У руководства возникло желание обезопасить пользователя от возможного отсутствия результата и добавить fallback-режим. «Вернуть хотя бы какой-то непустой результат для произвольной задачи» — это то, что большие языковые модели тоже любят делать. Мы выполнили пожелание: если что-то пойдёт не так, то мы сохраняем собранные модели данных, сериализуем их в промпт и отдаём LLM для генерации теста как есть, без предварительно синтезированного кода. Прямо текстом:

Given an instance:
    Object[io.aiven.klaw.service.UsersTeamsControllerService]#-1 {
        authenticationType:
            String("db")
        ssoEnabled:
            Boolean(false)
        kwInstallationType:
            String("onpremise")

... подробно описываем всё, что знаем об объекте

When calling the method addNewTeam with arguments:
    Arg 1:
        Object[io.aiven.klaw.model.requests.TeamModel]#30 {
            teamname:
                String("Testteam")
            teamphone:
                String("0031003423432")
            tenantId:
                Int(101)
        }
    Arg 2:
        Boolean(true)

Then the method should return:
    Object[io.aiven.klaw.model.ApiResponse]#29 {
        success:
            Boolean(true)
        errCode:
            // Unknown yet value, try to derive
        message:
            String("success")
    }

В итоге даже оказалось, что в некоторых случаях тесты, сгенерированные с нуля, получаются аккуратнее и содержательнее отрефакторенных. Неожиданный поворот и основа для нового исследования и бенчмарков.

Заключение

Computer Science сейчас переживает интересные времена: с одной стороны тут есть мы, аспиранты и кандидаты, придумывающие новые подходы в своих диссертациях, а с другой — LLM, которым так и хочется отдать на откуп как можно большую часть сложной задачи. При этом, скорее всего, ИИ с ней неплохо справится. По моему мнению, наше исследование хорошо вписалось в эти реалии. Мы сами прошли через огромное количество подводных камней и разработали алгоритмы, сводящие задачу к удобному для ИИ представлению. Языковые модели же сделали последний шаг: шаг от экспериментального прототипа к продуктовой фиче. С помощью LLM получилось подстелить соломку и закрыть те белые пятна, для которых нет надёжных алгоритмов.

Попробовать генерацию тестов по исполнению можно в свежей версии плагина Explyt для IntelliJ IDEA. Подробный туториал по фиче доступен в документации. Будем рады услышать ваше мнение в комментариях, а также получить багрепорты и фичреквесты в GitHub Issues и чате с командой плагина. Предупреждаем: фича недавно вышла из кузницы RnD и пока в бете. Тем ценнее ваш фидбек.

А ещё если вы планируете 20 октября посетить конференцию Heisenbug в Санкт-Петербурге, то высказаться можно будет и вживую: на нашем стенде и на докладе кандидата технических наук и ex-JetBrains Даниила Степанова. Он руководил разработкой фичи и написал десятки тысяч строк кода, поэтому расскажет обо всём в красках. До скорой встречи!

Комментарии (0)