Кто я такой

В 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<SomeDto> 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 ничего не знает о том что были созданы какие-то файлы?

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

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

Все эти минусы, конечно, расстраивали. Очевидно, возникла необходимость изучения IntelliJ Platform SDK и последующего рефакторинга.


Третья версия. IntelliJ Platform. Плагин как плагин

Вооружившись документацией, попробуем решить вышеописанные проблемы.

Шаг 1. Логика создания инстанции средствами IntelliJ Platform

Логика создания инстанции - это код, который отвечает за построение инстанции тестируемого класса. Нам определенно нужно избавиться логики "ручного" разбора классов из /target на сеттеры. У IntelliJ Platform есть Program Structure Interface (PSI), который позволяет это сделать. PSI - мощный компонент IntelliJ Platform для разбора и создания кода.

Для начала - если мы разрабатываем плагин для версии IntelliJ IDEA 2019.2 или выше, согласно этой рекомендации следует подключить функциональность Java как отдельный плагин.

Для этого:

  • В resources/META-INF/plugin.xml добавляем

<depends>com.intellij.modules.java</depends>
  • В build.gradle.kts указываем

intellij {
  version.set("2022.2.2")
  type.set("IC") // Target IDE Platform
  plugins.set(listOf("com.intellij.java")) // Include java plugin
}

Далее переписываем часть, которая отвечает за генерацию логики создания инстанции:

  • Убираем логику поиска скомпилированных классов в target, попутно избавляя себя от необходимости объяснять это каждому пользователю;

  • Получаем объект, описывающий тестируемый класс;

  • Получаем его методы;

  • Собираем из этого текст метода, создающего инстанцию тестируемого класса.

В IntelliJ Platform есть инструмент PsiShortNamesCache. Согласно PSI Cookbook, с его помощью можно получить класс по краткому имени. Инструмент поможет получить объект, описывающий тестируемый класс.

Для получения инстанции PsiShortNamesCache, в метод getInstance() необходимо передать project , полученный из контекста события пользовательского действия. Параметр shortClassName получаем, преобразовав имя текущего PsiFile файла (на котором стояли, когда совершили действие). Метод PsiFile.getName()вернет имя с расширением. В данном случае нам нужно только имя.

PsiShortNamesCache psiShortNamesCache = PsiShortNamesCache.getInstance(project);
final @NotNull PsiClass[] classesByName = psiShortNamesCache
  .getClassesByName(shortClassName, GlobalSearchScope.projectScope(project));

В примере выше classesByName - это результаты поиска в PsiShortNamesCache. Массив объектов типа PsiClass. Ну а далее Ваша логика:

PsiClass.getAllMethods(); // вернет все методы, для каждого из которых
// .getName() - вернет имя
// .hasModifierProperty(PsiModifier.PUBLIC) - проверит его публичность
// .getReturnType() - вернет возвращаемый тип (нас интересует PsiType.VOID).

Вот так получаем default - значение для сеттера:

MethodSignature signature = psiMethod.getSignature(PsiSubstitutor.EMPTY);
PsiType type = signature.getParameterTypes()[0];
Object defaultValue = PsiTypesUtil.getDefaultValue(type);

Далее - творческая работа со String ????.

Результат

Как результат имеем метод, создающий инстанцию Класса для тестирования с дефолтными значениями:

private SomeTestDto getSomeTestDto() {
    final SomeTestDto expectedSomeTestDto = new SomeTestDto();
    expectedSomeTestDto.setAge(0);
    expectedSomeTestDto.setName(null);
    expectedSomeTestDto.setWeight(0);
    
    return expectedSomeTestDto;
}

Если тестируемый класс наследует другой Класс, сеттеры суперкласса также будут добавлены.

Шаг 2. Как понять, что мы в Spring Boot - приложении

Вместо того, чтобы искать pom.xml и смотреть что в нем написано, на ум пришла следующая идея: Spring Boot приложения имеют аннотацию @SpringBootApplication, которая может помочь в определении.

В IntelliJ Platform есть JavaPsiFacade. Инструмент, который позволяет найти класс (пакет, модуль и прочее) в проекте.

JavaPsiFacade javaPsiFacade = JavaPsiFacade.getInstance(project);
PsiClass annotation = javaPsiFacade
  .findClass(Constants.SPRING_BOOT_APPLICATION, GlobalSearchScope.everythingScope(project));

Получаем psiClasses - результат поиска и проверяем, что он содержит класс, на котором стоит аннотация @SpringBootApplication.

Это и будет нашим индикатором.

Результат

Без @SpringBootApplication
Без @SpringBootApplication
Со @SpringBootApplication
Со @SpringBootApplication

Шаг 3. Создание файлов средствами IntelliJ Platform

Если сразу давать понять IntelliJ Platform то, что мы создаем файлы, то:

  • Не нужно будет обновлять проект с диска;

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

  • Будем иметь возможность отмены действия.

Создание файлов/директорий в IntelliJ Platform производим также через PSI.

Получаем parent-директорию

Для начала следует получить родительскую директорию (src). Уже от нее двигаться в сторону создания структуры для теста. В нашем случае просто идем наверх от файла (PsiFile) с Тестируемым классом до тех пор, пока не найдем нужную директорию.

Создаем структуру папок для теста

Получив директорию ( PsiDirectory ), идем из main в test, проходясь по дереву вниз и попутно создавая недостающую структуру проекта:

PsiDirectory.findSubdirectory(String dirName); // найдет дочернюю директорию (если есть)
PsiDirectory.createSubdirectory(String dirName); // создаст дочернюю директорию
PsiDirectory.add(PsiFile file); // положит файл в директорию

Создаем файлы (тестовый класс и json-ресурс)

Для создания файлов используем PsiFileFactory - еще один полезный инструмент, позволяющий создать файл. В нашем случае удобно использовать метод createFileFromText(), передавая текст Тестового класса, сгенерированный ранее:

// создаст файл .java с указанным именем из переданного текста
PsiFileFactory.createFileFromText(name, JavaFileType.INSTANCE, text); 

// то же самое для .json (меняем тип на JsonFileType.INSTANCE, передаем имя и текст ресурса)
PsiFileFactory.createFileFromText(name, JsonFileType.INSTANCE, text); 

Однако, получаем ошибку "Must not change PSI outside command or undo-transparent action".

Все потому что изменение состояния PSI должно осуществляться через WriteCommandAction(подробно).

Используем WriteCommandAction для изменений PSI

Следующим шагом оборачиваем все процедуры создания директорий/файлов в лямбду, передаваемую в WriteCommandAction.runWriteCommandAction() и получаем команду записи (кстати, отменяемую по Ctrl+Z).

Ого, бонус - удовлетворили требование "<...> Если я сгенерировал тест, а затем захотел отменить это действие <...>".

Ошибка IndexNotReadyException в процессе записи

В моем случае при попытке записи происходило исключение IndexNotReadyException. Причиной этому стало следующее (из описания ошибки в com.intellij.openapi.project.IndexNotReadyException):

If you're performing a long modal operation which leads to a root change in the middle (or otherwise causes indexing), but you need indices after that, you can call DumbService.completeJustSubmittedTasks() before performing those index queries.

Я последовал совету автора и воспользовался DumbService.completeJustSubmittedTasks(), просто разместив его вызов между созданием папок и записи файлов в них.

Результат

Файлы созданы и размещены в директориях с зеркальным расположением.

Структура проекта после создания теста
Структура проекта после создания теста

Шаг 4. Уведомлять и навигировать пользователя к созданным файлам

Результатом итерации работы плагина является созданный тестовый класс. Давайте предложим пользователю возможность быстрого перехода к нему. Добавим уведомление о том, что тестовый класс сгенерирован. Уведомление будет содержать ссылку "View", нажатие на которую будет открывать результат.

Создать NotificationGroup

Её следует описать в блоке extensions файла resources/META-INF/plugin.xml.

<extensions defaultExtensionNs="com.intellij">
  <notificationGroup id="Json Test Generator" displayType="BALLOON"/>
</extensions>

Созданная группа уведомлений в дальнейшем будет доступна в File | Settings | Appearance & Behavior | Notifications. Конечный пользователь может изменять ее настройки на свое усмотрение.

Создать NotificationAction

Далее создадим класс - GenerateJsonTestNotificationAction наследник NotificationAction. Он будет описывать действие, которое может быть встроено в уведомление (окно всплывает справа). Реакция на нажатие реализуется путем переопределения метода actionPerformed, которое будет навигировать пользователя к созданному файлу.

Реализация может выглядеть следующим образом:

new OpenFileDescriptor(project, testClass).navigate(true);

, где testClass - инстанция VirtualFile, полученная как результат работы WriteCommandAction (см.выше).

Связываем все воедино

С помощью NotificationGroupManager получаем NotificationGroup. С ее помощью создаем Notification, которому передаем NotificationAction.

Конечным пунктом, методом notify() показываем Уведомление пользователю.

// Получаем NotificationGroup
NotificationGroup notificationGroup = NotificationGroupManager.getInstance()
  .getNotificationGroup(Constants.NOTIFICATION_GROUP);

notificationGroup
  .createNotification("JsonTest generated", NotificationType.INFORMATION) // Создаем Notification с текстом и типом уведомления (ToDo: ссылка на типы)
  .setTitle("Success") // Устанавливаем Title
  .addAction(new GenerateJsonTestNotificationAction("View", navigationFile)) // Добавляем действие и передаем обработчика
  .notify(project); // Уведомляем пользователя

Результат

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

Уведомление о создании теста
Уведомление о создании теста

Шаг 5. Опрос пользователя перед перезаписью файлов

Если повторить действие создания теста дважды, то на месте ранее сгенерированных Тестового класса и Json ресурса должны появиться новые. Если их предстоит перезаписать - спросим у пользователя, можем ли мы это сделать.

Создаем диалог

Для этого создадим Диалог TestAlreadyExistsDialog, наследник DialogWrapper. В конструкторе инициализируем его методом init().

public TestAlreadyExistsDialog(Project project) {
  super(project); // Передаем project в конструктор вышестоящего класса
  setTitle("Test Components Already Exist in the Project"); // Устанавливаем Title диалога
  init(); // Инициализируем
}

Нам предстоит переопределить метод createCenterPanel(), отвечающий за "тело" диалога. Разместим в нем пару фраз о том, что такие файлы уже были ранее сгенерированы и покажем их расположение. Для этого дополнительно передадим в диалог эти самые файлы ( VirtualFile ).

В качестве Layout я использовал java.awt.GridBagLayout. Это заняло у меня сравнительно больше времени, чем я ожидал, т.к. с Layout я столкнулся впервые.

Мне помогла документация Oracle. Вот тут конкретно про GridBagLayout.

Показываем и обрабатываем пользовательскую команду

DialogWrapper имеет метод show() или showAndGet(). Последний вернет true если пользователь согласился перезаписать файлы.

Расширяем WriteCommandAction (удаляем файлы)

Расширив WriteCommandAction удалением ненужных файлов перед созданием новых, получим единую операцию, которая:

  • Находит/Создает нужные директории для файлов;

  • Удаляет старые, если они существуют;

  • Создает новые;

  • Раскладывает файлы в директории.

Результат

Вот так простенько выглядит мой диалог:

Пользовательский диалог
Пользовательский диалог

Шаг 6. Логирование

Несколько слов о том, как и чем логировать. В IntelliJ Platform есть com.intellij.openapi.diagnostic.Logger, который нужно использовать (согласно документации).

Для того чтобы видеть log в консоли (если по умолчанию нет вкладки idea.log):

  • Запустить Tasks.intellij.runIde;

  • Help | Show Log in Explorer;

    • Это даст понимание, где именно находится файл с логом;

    • Скопируем этот путь;

  • Отредактируем configuration, запускающий Tasks.intellij.runIde;

    • Modify Options;

    • Specify logs to be shown in console;

Включение отображения лога в консоли
Включение отображения лога в консоли
  • Ниже появится секция Logs, просто добавим путь к файлу в нее;

Выбор файла с логом для отображения
Выбор файла с логом для отображения

По умолчанию уровень логирования DEBUG выключен. Для того, чтобы его, запустив Tasks.intellij.runIde, переходим в Help | Diagnostic Tools | Debug log settings ... .

В появившемся окне пишем путь к пакету, который хотим логировать. ОК.

Включение DEBUG лога
Включение DEBUG лога

С этого момента лог уровня DEBUG доступен в файле idea.log, который выведен в консоль.

idea.log в консоли
idea.log в консоли

Summary

После того, как все шаги были пройдены и протестированы, появилось скромное чувство удовлетворения.

Совсем другое дело:

  • Поиск сеттеров не зависит от версии Java;

  • Если файлы теста уже есть в проекте, пользователь об этом уведомлен. Теперь он имеет контроль и распоряжается их дальнейшей судьбой;

  • По завершении работы пользователь также уведомлен. Он может нажать на "View" и продолжить работать с результатом;

  • IDE сразу видит, что файлы добавлены и предлагает добавить их в GIT;

  • Пользователь имеет возможность отмены действия по нажатию Ctrl+Z.

Пример работы:

Плагин в действии
Плагин в действии

Дело за малым. Нужно избавить пользователей от ручной установки плагина из ZIP. То есть опубликовать его на MarketPlace. Гифку, приведенную выше, будем использовать для описания возможностей плагина.


Плагин на MarketPlace

При создании плагина с помощью Gradle IntelliJ Plugin, мы будем использовать несколько тасков, о которых далее пойдет речь:

  • Tasks.intellij.signPlugin  - подписывает плагин;

  • Tasks.intellij.publishPlugin  - публикует плагин на MarketPlace.

Подписываем плагин

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

OpenSSL

Для подписи используется ключ, который создается с помощью openssl. Если вы работаете на Windows и испытываете трудности с установкой openssl, то, скорее всего, он уже установлен в комплекте с GIT и располагается тут: <директория git>\usr\bin\openssl.exe.

Environment variables

Для подписи требуется certificateChain,  privateKey  и password. Чтобы не хранить их в коде, документация советует убрать их в environment variables и задать в конфигурации таска Tasks.intellij.signPlugin.

Однако, это не сработало для меня. Сертификат и ключ многострочные и если копировать их содержимое в environment variable, то оно не работает. Каждый раз при попытке подписи получаем ошибку NullPointerException: pemObject must not be null. Решение было предложено в community.

Таким образом, пользуемся обходным путем, располагая содержимое certificateChain и privateKey в файлах, на которые ссылаемся в signPlugin:

signPlugin {
    certificateChain.set(
        File(System.getenv("CERTIFICATE_CHAIN") ?: "./.keys/chain.crt")
            .readText(Charsets.UTF_8)
    )
    privateKey.set(
        File(System.getenv("PRIVATE_KEY") ?: "./.keys/private.pem")
            .readText(Charsets.UTF_8)
    )
    password.set(System.getenv("PRIVATE_KEY_PASSWORD"))
}

Добавляем файлы в .gitignore чтобы не закоммитить случайно. PRIVATE_KEY_PASSWORD можно задать в environment variables, т.к. он однострочный и проблем с ним не будет.

Проверяем. Tasks.intellij.publishPlugin проходит успешно.

Публикуем плагин

Для публикации предварительно нужно зарегистрироваться. После регистрации можно выпустить токен (как обычно, запоминаем его, иначе потеряем), который понадобится для публикации через IntelliJ IDEA (Профиль | My Tokens | Generate Token). Если планируем каждый раз заливать новую версию вручную - токен не нужен.

Первая публикация

Важно обратить внимание на то что первая публикация всегда производится вручную (клик на своем имени в правом верхнем углу | Upload plugin). Затем можно пользоваться Tasks.intellij.publishPlugin. Конфигурацию также потребуется изменить, добавив environment variables:  PRIVATE_KEY_PASSWORD  и PUBLISH_TOKEN.

Release Channels

По умолчанию плагин загружается на канал Stable. Но можно создавать другие (для организации стейджей, например). Подробнее тут.

Совместимость

После upload'а у плагина появится собственная страница. На вкладке Versions, помимо списка опубликованных версий с подробностями, можно (на самом деле нужно) ознакомиться с Compatibility verification для каждой из них. Верификация будет проведена автоматически, исходя из версий IDE, указанных в build.gradle.kts  для таска Tasks.intellij.patchPluginXml (изменяет resources/META-INF/plugin.xml).

patchPluginXml {
    sinceBuild.set("222")
    untilBuild.set("223.*")
}

Можно запланировать верификацию и для других версий, выбрав интересующую версию и нажав Schedule Verification.

Планирование верификации для версии IntelliJ IDEA
Планирование верификации для версии IntelliJ IDEA

Результаты проверок показаны списком и выделены цветом:

Результаты верификации
Результаты верификации

Про совместимость подробно описано тут.

Гайдлайны оформления главной страницы

Есть классное описание того, как оформлять страницу. Как делать нужно и как не нужно. Советую обязательно ознакомиться.

Logo

Хотелось бы придумать простенький логотип, но к сожалению у меня не хватило фантазии. С логотипом мне помог ребенок. Я объяснил ему суть происходящего, после чего получил целый поток драфт-вариантов логотипа.

Вот, кстати, требования к логотипу. Рисовал в каком-то бесплатном векторном онлайн-редакторе.


Заключение

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

Страница плагина: Json Test Generator - IntelliJ IDEs Plugin | Marketplace (jetbrains.com)

Отдельное спасибо сыну за логотип, супруге, которая терпит мои зависания у компьютера до поздней ночи и Ване, моему коллеге, который с энтузиазмом помогал тестировать решение.

Copyright © 2022 JetBrains s.r.o.
IntelliJ IDEA and the IntelliJ IDEA logo are registered trademarks of JetBrains s.r.o.

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


  1. ris58h
    19.12.2022 15:15

    Спасибо. Есть несколько вопросов:

    1. Зачем публиковать плагин, который нужен только вашей компании на Marketplace, когда есть Custom Plugin Repository?

    2. Рассматривали вариант не писать плагин, а воспользоваться DynamicTest и @TestFactory? Сами тесты генерировать на основе json описания.


    1. nosbuka Автор
      19.12.2022 17:10

      Здравствуйте.

      1. Задача, которую он решает, хоть и возникла в моей организации, но все же не является специфической конкретно для нее. Поэтому он может быть применен не только там где я работаю. А если он может быть полезен где-то еще, то стоит опубликовать. Я так размышлял.

      2. Вариант не рассматривал, но суть вопроса уловил. По всей видимости, Вы о снижении кол-ва кода, которого можно добиться с помощью комбинации DynamicTest и @TestFactory. Обязательно попробую, спасибо.


      1. ris58h
        19.12.2022 22:54
        +1

        Вы о снижении кол-ва кода

        В конечном итоге, да.

        Я мог понять задачу неправильно, но достаточно было бы директории с JSON-ами вида my.package.ClassName.json (или иерархии директорий - дело вкуса), и одной TestFactory, которая бы генерировала динамические тесты для каждого класса в рантайме.

        Так и кода меньше писать (точнее вообще не надо писать, кроме TestFactory один раз). И плагин никакой не нужен (ну или action вида Create test JSON можно сделать для совсем ленивых).

        Минус подхода - нет явной связи между классом и тестом в коде. Например, find usages не покажет что класс в каком-то тесте используется и разработчик может, допустим, переименовать класс, но забыть переименовать JSON. Тест потом упадёт, конечно, и проблема всплывёт, но не сразу.


  1. GbrtR
    19.12.2022 17:20

    Насколько я понял, формат шаблона это какое-то собственное изобретение?

    Почему бы не взять более универсальное и мощное решение, это бы позволило много где ещё поиспользовать этот плагин.


  1. poxvuibr
    19.12.2022 18:21

    Спасибо за статью! У меня есть пара вопросов, которые хотелось бы обсудить, но не про плагин, а про подход.

    Я часто встречаюсь с мнением, что такие тесты вооще не нужны, потому что работоспособность сериализации и десериализации постоянно проверяется QA в процессе тестирования системы и разработчиками в процессе разработки (они это через сваггер делают). Это мнение мне неблизко, но всё-таки мне очень интересно, как вы убедили разработчиков, что такие тесты нужны.

    И ещё интересно как контролируется, что разработчик не забыл написать тест. Я так понимаю покрытие тут не поможет, потому что генерированный ломбоком код всё равно туда не попадает. Да и в любом случае, скорее всего геттеры и сеттеры покрываются какими-то соседними тестами. Остаётся только надеяться на то, что другие разработчики будут внимательны и не дадут смёржить пул реквест без такого теста?


    1. mosinnik
      19.12.2022 19:57

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

      Так что считают именно покрытие, а не забыл/не забыл. Более того думаю, что разрабы как раз покрывают тестами сериализацию только, чтобы получить заветные проценты на "дешевых" тестах, а не на сложных, где логика тестируется. Пакеджи с dto и обвязой вокруг них вообще должны игнорироваться при высчитывании покрытия, но почему-то это редко кто настраивает


      1. poxvuibr
        19.12.2022 23:39
        +1

        Пакеджи с dto и обвязой вокруг них вообще должны игнорироваться при высчитывании покрытия, но почему-то это редко кто настраивает

        В примере используется Lombok, его можно настроить, чтобы он ставил на сгенерированные dto анотации *Generated . Тогда jacoco проигнорирует сгеренерированный код. Затраты небольшие, специально пакеты указывать не надо. Мне кажется эту фичу часто используют


    1. nosbuka Автор
      19.12.2022 22:04

      Спасибо за интересный вопрос.

      мне очень интересно, как вы убедили разработчиков, что такие тесты нужны

      Так как я и есть один из разработчиков - меня даже и убеждать не пришлось :). На самом деле просто слишком высока цена ошибки. Ведь API внешний, им пользуются много потребителей. Они разрабатывают своё ПО для интеграции с нами. Стоит что-то пропустить и затем либо вечно об этом помнить, чтобы никому ничего не сломать, либо долгая процедура - договориться с потребителями. Оба пути неприятные - костыли либо репутационные риски. Поэтому пока тест не написал, считай - не проверил.

      Да и в любом случае, скорее всего геттеры и сеттеры покрываются какими-то соседними тестами.

      Полностью согласен, покрываются соседними тестами, статический анализатор может пропустить. Да, как Вы верно сказали, остается code review.


      1. poxvuibr
        19.12.2022 23:41

        Стоит что-то пропустить и затем либо вечно об этом помнить, чтобы никому ничего не сломать

        Давайте может с вами немного подискутируем? Для того, чтобы ничего не сломать по идее должны быть интеграционные тесты, которые проверяют интеграцию целиком. И так как раз вся сериализация и десериализация тоже проверяется. Может быть тесты с аннотацией JsonTest излишни, раз есть такие интеграционные тесты?


        1. nosbuka Автор
          20.12.2022 07:14

          Безусловно, интеграционными тестами мы также можем закрыть эту задачу.

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

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


  1. BugM
    19.12.2022 23:20

    А что должен тестировать этот тест? Какие ошибки он может поймать? Каких ошибок вы вообще в этом коде ожидаете?


    1. nosbuka Автор
      20.12.2022 06:23

      Такие тесты (@JsonTest) проверяют корректность настройки Jackson’а, которую мы реализуем в соответствии с контрактом.

      Пример:
      Мы описываем DTO, настраиваем: 
      * форматы дат (@JsonFormat);
      * имена полей (@JsonProperty);
      * алиасы (@JsonAlias) также иногда настраиваем для десериализации;
      * DTO может использовать ENUM’ы, которые сериализуются не “as-is”, а, к примеру MALE/FEMALE должны в итоге выглядеть как мужской/женский и т.п (@JsonValue).

      Когда мы все это настроили, то проверяем, что результат соответствует требуемому (согласно постановке/договоренностям) контракту. То есть результат сериализации мы сравниваем с эталонным JSON, который рассчитываем получить, а десериализации - с объектом, который из него собирается.