Пишем плагин для IntelliJ IDEA. Ускоряем написание тестов на DTO

Кто я такой

В Java я недавно. Работаю Java-разработчиком около года при общем 10-летнем стаже в АльфаСтрахование. Этому году предшествовали годы разработки на ABAP и полгода обучения на Javarush.

Что я делаю

Мой род деятельности связан с backend разработкой — я занимаюсь API АльфаСтрахование. Продажи полисов компании осуществляются в т.ч. через сеть страховых агентов, которые используют API для оформления страхового продукта:

  • производят расчет;

  • оформляют Полис;

  • производят оплату;

  • печатают оригинал Полиса и т.п.

API построен из множества микросервисов и его возможности непрерывно расширяются (добавляются новые продукты).

Разрабатывая что-то новое, мы должны быть уверены в том, что не сломали что-то существующее. Поэтому у нас принято писать Unit-тесты. Уровень покрытия, согласно Quality Gate, должен быть не менее 85%, но на самом деле, его можно было поднять и выше, т.к. зачастую покрытие не ниже 95%. Такой сравнительно высокий уровень покрытия требует определенных временных затрат.

Причем тут плагин

Один из важных для специфики микросервисной архитектуры, и, одновременно, самых скучных, на мой взгляд, аспектов написания тестов — тестирование сериализации и десериализации DTO на входе и на выходе API. Точнее было бы сказать так: не самой сериализации/десериализации, с которой и так прекрасно справляется Jackson, а ее настроек — форматы дат, Enum’ов и прочих элементов.

Кратко о том, как это происходит у нас:

  • Создается тестовый класс;

  • Аннотируется как @JsonTest ;

  • В тестовый класс инжектится (@Autowired) JacksonTester <типизируемый тестируемым DTO>;

  • В resources подкладывается ожидаемый Json, который:

    • Хотим получить как результат сериализации;

    • Из которого хотим создать инстанцию в процессе десериализации;

  • Пишется метод, создающий инстанцию (которую будем сериализовывать);

  • Пишется метод, проверяющий, что сериализация привела к результату, который описан в json-ресурсе;

Очевидно, это выглядит несложной задачей, но, когда модель достаточно обширная, ловишь себя на мысли, что сосредоточен не на тестовых данных, а на том, как ловчее избавить себя от написания boilerplate-кода. Как это сделать? Поискать готовое решение (плагин).

А если его нет? Тогда — написать плагин.

Требования

Если бы я формулировал User-story уже после того, как плагин был загружен на marketplace, я бы написал так:

  1. «Я как разработчик хочу кликать правой кнопкой в редакторе класса, который хочу протестировать. Далее переходить в меню Generate …, нажимать кнопку и генерировать @JsonTest»;

    • Тестовый класс должен содержать методы, проверяющие сериализацию и десериализацию DTO (Тестируемого класса);

    • А еще чтобы инстанция Тестируемого класса сама собиралась в Тестовом классе. То есть нужно найти все сеттеры и вызвать их (только не передавать тестовые данные — я ведь хочу хоть что-то контролировать сам);

  2. «А еще чтобы .json-ресурс для сравнения сам создавался по нужному пути»;

  3. «А еще чтобы это было доступно только для Spring Boot — проектов, чтобы не мозолило глаза, когда это не применимо».

Но плагинов я писать не умею, поэтому:

  • Пункт №1 пока что избыточен (мне ведь для себя);

  • Собрать инстанцию — значит нужно поискать сеттеры (отложим, но ненадолго);

  • Json-ресурс это просто файл, т.е. тут никаких сложностей;

  • Ограничение на Spring Boot — проекты это логично, но «я же для себя, зачем это усложнение, ведь я и сам знаю как это использовать».

Уточненные требования

По результатам всех размышлений, запомнил изначальные требования для «будущего себя», а потом сформулировал текущие:

«Я как разработчик хочу скрипт, который сгенерирует мне тест, а я напишу для него тестовые данные».

Первая версия. Скрипт «на коленке»

Вооружившись кофе и некоторыми знаниями полученными не так давно, я написал такой скрипт:

  • На вход скрипта запрашиваем путь к папке с DTO, для которых хотим получить тесты;

  • По переданному пути находим файлы с расширением .java, имя которых завершается на «dto» (регистр не важен);

  • Читаем шаблон (приведен ниже) с @JsonTest (в котором уже написаны @Test методы и все необходимое, в т.ч. импорты);

  • Заменяем placeholders на нужные нам имена (имя класса, пакета, т.д.);

  • Создаем новые файлы с помощью java.nio :

    • Тестовый класс с методами, проверяющими сериализацию и десериализацию, а также методом, который возвращает инстанцию для тестирования;

    • Json-ресурс с пустым »{}». Ведь это тестовые данные, которые я бы хотел заполнять сам.

  • Располагаем файлы в «зеркальных» пакетах директории src/test/java (исходя из того, как расположен класс, на который сгенерирован тест), чтобы обращаться к ресурсам по именам.

Должен отметить, что это заработало. Скрипт находил DTO, генерировал для них тестовые классы, а в последствии загружал скомпилированные классы из target, получал их сеттеры и создавал инстанцию с default-значениями. Я написал развесистый ReadMe.

Как генерируется тестовый класс

Тут все достаточно просто. Так как код у нас boilerplate, то он и не меняется от теста к тесту (за исключением имен переменных/классов, пакетов и метода, строящего инстанцию). Забегая вперед, скажу, что логика генерации Тестового класса с помощью шаблона так и осталась неизменной: плейсхолдеры (элементы вида %classname%) заменяются на реальные имена, в зависимости от обрабатываемого DTO. Ниже я привел пример шаблона и того, что из него в результате получается.

Универсальный шаблон теста

%package%

import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.autoconfigure.json.JsonTest;
import org.springframework.boot.test.json.JacksonTester;

import java.io.IOException;

import static org.assertj.core.api.Assertions.assertThat;


@JsonTest
class %classname%JsonTest {

    @Autowired
    private JacksonTester<%classname%> jacksonTester;

    @Test
    void shouldDeserializeFromJson() throws IOException {
        final %classname% %variable-name% = get%classname%();
        System.out.println(jacksonTester.write(%variable-name%).getJson()); // ToDo: remove this line!
        assertThat(jacksonTester.read("%json-file-name%"))
                .usingRecursiveComparison()
                .isEqualTo(%variable-name%);
    }

    @Test
    void shouldSerializeToJson() throws IOException {
        final %classname% %variable-name% = get%classname%();

        assertThat(jacksonTester.write(%variable-name%))
                .isStrictlyEqualToJson("%json-file-name%");
    }

    private %classname% get%classname%() {
        final %classname% %variable-name% = new %classname%();
%creation-logics%
        return %variable-name%;
    }
}

Сгенерированный тестовый класс

package com.example.demo;

import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.autoconfigure.json.JsonTest;
import org.springframework.boot.test.json.JacksonTester;

import java.io.IOException;

import static org.assertj.core.api.Assertions.assertThat;


@JsonTest
class SomeDtoJsonTest {

    @Autowired
    private JacksonTester jacksonTester;

    @Test
    void shouldDeserializeFromJson() throws IOException {
        final SomeDto expectedSomeDto = getSomeDto();
        System.out.println(jacksonTester.write(expectedSomeDto).getJson()); // ToDo: remove this line!
        assertThat(jacksonTester.read("some-dto.json"))
                .usingRecursiveComparison()
                .isEqualTo(expectedSomeDto);
    }

    @Test
    void shouldSerializeToJson() throws IOException {
        final SomeDto expectedSomeDto = getSomeDto();

        assertThat(jacksonTester.write(expectedSomeDto))
                .isStrictlyEqualToJson("some-dto.json");
    }

    private SomeDto getSomeDto() {
        final SomeDto expectedSomeDto = new SomeDto();
		expectedSomeDto.setName(null);
		expectedSomeDto.setAge(0);

        return expectedSomeDto;
    }
}

Почему .json-ресурс пустой?

Как я уже упоминал ранее, ресурс .json (в примере выше это файл «some-dto.json») — пустой (точнее, внутри него написано »{}»). Я не стал заполнять его данными, т.к. по своей сути он является эталоном, к которому мы приводим результаты сериализации и который используем для проверки корректности десериализации.

Важно подготовить его вручную, иначе зачем разработчик вообще нужен потеряем интеллектуальную составляющую тестирования (т.е. рискуем не обратить внимания на потенциальные ошибки).

Обратная связь

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

Как Вы думаете, какое единственное пожелание от коллег я получил? :) Все верно, «А давай плагином, чтоб не нужно было вот это вот все что ты там описал в ReadMe».

Очевидно, что что-то, принесенное в массы, должно быть user-friendly и интуитивно понятным. Когда у тебя в инструкции написано «скопируй путь отсюда, вставь сюда, скомпилируй (не забудь, иначе сеттеров не будет), нажми кнопку в Idea» — это отстой.

Вторая версия. Скрипт в обертке плагина

Вооружившись Google-ом, пытаемся  понять, как это вообще можно сделать. C этого момента я буду приводить ссылки на документацию IntelliJ Platform, т.к. в конечном счете именно там я нашел ответы на все возникшие вопросы.

С чего начать?

JetBrains имеет готовое решение для удобной разработки плагинов, Gradle IntelliJ Plugin. Удобное решение, управляющее процессом разработки. Поставляет исчерпывающий набор команд для Gradle (от сборки до публикации).

Приведенная статья описывает настройку плагина в build.gradle.kts .

Что собираемся делать?

Задача следующая: есть работающий код, нужно перенести его в плагин. Самое время вспомнить изначальные требования, первым пунктом которых был »<...> хочу кликать правой кнопкой по Классу, который хочу протестировать. Далее переходить в меню Generate <...> и генерировать @JsonTest <...>».

Создаем пользовательское действие

Пользовательские действия, которые мы хотим обрабатывать в плагине, описываются наследниками абстрактного класса com.intellij.openapi.actionSystem.AnAction . (подробно).

После создания такого класса, Вам предстоит переопределить пару методов:

  • update(AnActionEvent event) — метод, позволяющий скрыть/показать орган управления, запускающий действие;

  • actionPerformed(AnActionEvent event) — метод, описывающий реакцию на действие.

Оба метода на входе имеют инстанцию AnActionEvent, из которой в дальнейшем будем получать все необходимое.

Регистрируем действие и добавляем пункт в меню Generate … 

Действие регистрируется в файле resources/META-INF/plugin.xml . Естественно, IDE увидит, что Ваш наследник  AnAction  еще не зарегистрирован, и предложит это сделать. (подробно).

На данном этапе нам предстоит:

  • Назвать действие (будет отображаться в пункте меню);

  • Написать к нему описание;

  • Выбрать группу, в которую его необходимо добавить (в моем примере это GenerateGroup(Generate)). Тут могут возникнуть сложности, т.к. групп достаточно много (придется поискать);

  • Можно добавить shortcuts.

В результате в вышеописанном файле будет создана секция «actions», которая будет содержать action, который мы зарегистрировали.

Проверим, что пункт появился

Для того, чтобы наше действие отображалось в меню Generate, вспомним про метод update(AnActionEvent event) и пока что безусловно скажем что действие должно отображаться:

event.getPresentation().setVisible(true);

Gradle Intellij Plugin позволяет запустить локальную инстанцию Intellij Idea, для этого выполняем Tasks.intellij.runIde. Инстанция IDE будет загружена с Вашим плагином внутри — можно искать пункт меню, который был добавлен.

Перенесем код скрипта в плагин

AnActionEvent помогает найти текущий проект и текущий файл. Важно понимать различия между VirtualFile (подробно) и PsiFile (подробно).

DataContext получаем из event, приходящего в параметры переопределенного метода.

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

VirtualFile currentFile = VIRTUAL_FILE.getData(event.dataContext);
PsiFile psiFile = PSI_FILE.getData(event.getDataContext());
Project project = PROJECT.getData(event.getDataContext());

Таким образом, имея в запасе 3 строчки, указанные выше, я немного переписал скрипт, который:

  • Исходя из текущего расположения Тестируемого класса понимал, куда следует положить тестовый класс и Json-ресурс;

  • Далее делал все то же самое, также с помощью java.nio.

Соберем плагин и установим его

В build.gradle.kts не забываем указать актуальную версию. Сборка плагина осуществляется с помощью Tasks.intellij.buildPlugin, результатом которой получаем .zip в директории build/distributions. Его можно установить в IntelliJ IDEA и пользоваться плагином, не выкладывая на MarketPlace. Settings | Plugins | Install Plugin from Disk.

Обратная связь

Как это часто бывает, «у меня локально все работает». Тем не менее, пользователи имеют другое мнение.

  • Генерация логики создания инстанции (поиска сеттеров в скомпилированных классах) периодически разваливалась, когда Тестируемые классы были скомпилированы на более новой версии Java, чем та, на которой написан плагин;

  • Исходя из специфики работы в Команде, мною была выбрана не универсальная логика определения Spring Boot — проекта. Она основывалась на чтении pom.xml. А если Gradle?

  • IntelliJ IDEA ничего не знает о том, что плагин создал файлы в проекте → нужно обновлять проект с диска, чтобы файлы отобразились в дереве проекта;

    • Попытка принудительного обновления проекта программно привела к тому, что IntelliJ IDEA перестала предлагать добавить эти файлы в GIT;

  • Usability. Если я сгенерировал тест, а затем захотел отменить это действие? Мне нужна возможность отмены изменений по Ctrl+Z. Но как это сделать, если Idea ничего не знает о том что были созданы какие-то файлы?

  • Пользователя нужно как-то уведомлять о том, что все прошло успешно. В идеале навигировать на созданный тестовый класс (т.е. открывать его редактор);

  • Если пользователь дважды сгенерирует тест — предыдущий результат нужно перезаписать. Необходимо затребовать подтверждение перезаписи от пользователя (не говоря уж о том, что выполнение падает с ошибкой, если файл уже существует

    © Habrahabr.ru