JUnit 5 Extensions (часть 2): пишем умные data-провайдеры и DI-контейнер на Kotlin

от автора

«Скоро сказка сказывается, да не скоро дело делается» — говорится в народной пословице. Вот и мы решили не спешить со второй частью статьи по Junit 5 Extensions, а подойти к ней более основательно! Статья будет полезна QA-автоматизаторам, которые хотят глубже понимать работу с расширениями и выжать чуть больше из связки Kotlin + Junit5. Мы пройдем путь от простой реализации condition-выполнения тестов и источников данных для параметризованных тестов до реализации расширения Микро-DI с рекурсивной инъекцией зависимостей.

Как и в прошлой статье, сделаем акцент на практической части реализации расширений для JUnit 5. В качестве языка — Kotlin. Поэтому, достаем бутерброды, наливаем пиво кофе и приступаем!

Способы регистрации extension для JUnit 5

JUnit 5 предлагает несколько путей для регистрации расширений. Ниже кратко пройдемся по каждому их них.

Аннотация @ExtendWith

Первый, и пожалуй, самый популярный способ — регистрация через аннотацию @ExtendWith. Указываем @ExtendWith(MyExtensionClass::class)над классом или методом и JUnit сам создаст экземпляр расширения через конструктор по умолчанию. 

Если пометить аннотацией базовый класс, то расширение автоматически применится ко всем наследникам. И, конечно, можно комбинировать несколько расширений на одном классе.

@ExtendWith(SoftAssertionsExtension::class, MetricsExtension::class)

Также можно указать расширение над созданной аннотацией.

@Target(AnnotationTarget.CLASS, AnnotationTarget.FUNCTION)@Retention(AnnotationRetention.RUNTIME)@ExtendWith(TimingExtension::class)annotation class MeasureTime

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

Автоматическая регистрация

Автоматическая регистрация расширений через META-INF/servicesaka «подключил и забыл» — меньше контроля, как в случае с аннотацией или динамическим способом, для инфраструктурных штук (метрики, логирование) или ленивого тестировщика — отличный выбор!

Чтобы глобально зарегистрировать расширение:

  • пишем наш класс с мега полезным расширением, например — MySuperExtension

  • создаем по пути src/test/resources/ файл junit-platform.properties и «кладем» туда строку junit.jupiter.extensions.autodetection.enabled=true (опция для автодетекта расширений)

  • создаем по пути src/test/resources/META-INF/services файл org.junit.jupiter.api.extension.Extension

  • добавляем в созданный файл строку — com.example.infra.MySuperExtension (вместо com.example.infra.MySuperExtension — полное имя вашего класса расширения)

В файл можно добавлять несколько расширений — каждое с новой строки и обязательно с указанием полного имени класса.

Программная регистрация

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

Реализуется посредством @RegisterExtension. Поле должно быть помечено аннотацией и не быть приватным (без модификатора private). Чтобы наше поле-расширение имело доступ к жизненному циклу всего класса (@BeforeAll), его нужно «отправить» в companion object (сделать статическим). 

class MyTest {     companion object {         @JvmField // Или @JvmStatic        @RegisterExtension        val staticExtension = MyExtension("static-config")    }}

Если написать несколько расширений, то порядок выполнения такой: сначала статические в companion object, затем расширения уровня экземпляра (над методами или обычные поля @RegisterExtension). Если у вас зарегистрировано несколько расширений одного уровня, их порядок выполнения внутри этого уровня не гарантирован (зависит от reflection). Чтобы явно задать порядок выполнения — можно заюзать аннотацию @Order(n), где n — порядковый номер.

А что насчет порядка выполнения расширений JUnit 5? Если в тесте смешаны разные способы регистрации, JUnit соблюдает следующую иерархию:

  • автоматические (ServiceLoader) — запускаются самыми первыми.

  • уровень класса (Статические @RegisterExtension и @ExtendWith на классе).

  • уровень экземпляра (Поля @RegisterExtension и @ExtendWith на методах).

ExecutionCondition

Со способами регистрации расширений мы разобрались, теперь набросаем что-нибудь небольшое и полезное.

Итак, в вашем проекте есть тесты, помеченные аннотацией XFail с параметром bugId (номером таски в Jira, например) и иногда возникает потребность временно отключить их, но над каждым аннотацию @Disabled не повесишь. Хотелось бы отключать их для «ночного прогона», передавая env параметр.

Сказано — сделано! Для «приготовления» нашего расширения понадобится: @RegisterExtension — 1шт., ExecutionCondition — 1шт., Автоматизатор — 1шт. Но сначала немного про ExecutionCondition.

Интерфейс ExecutionCondition — это «база» JUnit 5 для программного управления запуском тестов. В отличие от аннотации @Disabled, расширения, использующие этот интерфейс, позволяют оценивать контекст (окружение, системные свойства, время или внешние API) прямо перед выполнением теста.

Но вернемся к нашему «рецепту»… Проверяем наличие аннотации XFail в проекте.

@Target(AnnotationTarget.CLASS, AnnotationTarget.FUNCTION)@Retention(AnnotationRetention.RUNTIME)annotation class XFail(val bugId: String)

Затем реализуем сам extension класс.

class IgnoreTestExecutionExtension : ExecutionCondition {    override fun evaluateExecutionCondition(context: ExtensionContext): ConditionEvaluationResult {        val xFailedTest = context.element.map { it.getAnnotation(XFail::class.java) }         return if (xFailedTest.isPresent) {            ConditionEvaluationResult.disabled("Test skipped due to XFail: ${xFailedTest.get().bugId}")        } else {            ConditionEvaluationResult.enabled("All other tests are allowed")        }    }}

Теперь дело за малым — «прикручиваем» в базовом классе динамическую регистрацию при помощи аннотации @RegisterExtension

class BaseTest {     companion object {         // Добавляем env переменную        private val TEST_SKIP_FAILED: Boolean? = System.getenv("TEST_SKIP_FAILED")?.toBooleanStrictOrNull()         // https://mvnrepository.com/artifact/io.github.microutils/kotlin-logging        private val logger = KotlinLogging.logger { }         @JvmStatic        @RegisterExtension        val setupSkippedTestsExecution =            if (TEST_SKIP_FAILED != null && TEST_SKIP_FAILED == true) {                IgnoreTestExecutionExtension()            } else BeforeAllCallback { logger.debug("✅ Skipped test execution OFF!") }    }}

Вуаля! В зависимости от переменной TEST_SKIP_FAILED наши временно «сломанные» тесты с аннотацией XFail будут запускаться и в логе отобразится — «Skipped test execution OFF!», или нет.

Единственное НО! ExecutionCondition при использовании @TestFactory (динамических тестов) не может выборочно отключать отдельные тесты внутри этой фабрики. Это связано с тем, что они не имеют своего ExtensionContext и не поддерживают стандартный жизненный цикл. Также избегайте слишком сложной логики в evaluateExecutionCondition, например, сетевых запросов, т.к. evaluateExecutionCondition выполняется перед каждым тестом, это может замедлить прогон.

ArgumentsProvider и AnnotationConsumer

Двигаемся дальше. Каждый, кто писал тесты хоть раз, сталкивался с @ValueSource или @MethodSource, которые позволяют передавать данные для теста. И все бы хорошо, но когда данные нужно тянуть из БД, специфических JSON-файлов или генерировать динамически — эти «парни» быстро упираются в потолок. Для решения проблемы нам пригодятся ArgumentsProvider и AnnotationConsumer.

ArgumentsProvider — фундамент для создания кастомных источников данных. Реализует один метод — provideArguments, который возвращает Stream, где каждый элемент — это набор аргументов для одного запуска @ParameterizedTest. 

Важный момент! Если метод provideArguments вернет пустой стрим, JUnit просто не запустит ни одного экземпляра теста. А если внутри метода произойдет Runtime-исключение (вы добавили обработку ошибки try/catch для парсинга JSON например), дерево тестов не построится. В этом случае в IDE или логах сборщика вы увидите сообщение No tests found (или Test discovery failed), так как инициализация параметров рухнула до начала выполнения логики.

AnnotationConsumer — это своего рода «клей» между кастомной аннотацией и провайдером данных. Хотите передать источник в виде json или yaml файла? А может указать url api, откуда нужно подтянуть данные? Вам к AnnotationConsumer.

В качестве примера реализуем свой источник данных — @JsonSource. Для начала создадим маркерную аннотацию, чтобы связать ее с нашим будущим провайдером.

@Target(AnnotationTarget.FUNCTION)@Retention(AnnotationRetention.RUNTIME)@ArgumentsSource(JsonArgumentsProvider::class) // Связка с логикойannotation class JsonSource(val fileName: String, val type: KClass<*>)

Не забываем добавить Jackson для сериализации данных, обращая внимание на совместимость версии Kotlin и Jackson (таблицу совместимости смотрим тут — https://github.com/FasterXML/jackson-module-kotlin)

testImplementation("com.fasterxml.jackson.module:jackson-module-kotlin:2.16.2")

Затем реализовываем само расширение.

class JsonArgumentsProvider : ArgumentsProvider, AnnotationConsumer<JsonSource> {     private val mapper = jacksonObjectMapper()    private lateinit var filePath: String    private lateinit var targetType: Class<*>     override fun accept(annotation: JsonSource) {        this.filePath = annotation.fileName        this.targetType = annotation.type.java    }     override fun provideArguments(context: ExtensionContext): Stream<out Arguments> {        // закрываем стрим через .use сразу после парсинга        val dataList: List<Any> = getInputStream(filePath).use { inputStream ->            val listType = mapper.typeFactory.constructCollectionType(List::class.java, targetType)            mapper.readValue(inputStream, listType)        }        return dataList.stream().map { Arguments.of(it) }    }     private fun getInputStream(path: String) =        Thread.currentThread().contextClassLoader.getResourceAsStream(path)            ?: File(path).inputStream()}

И добавляем тест для проверки расширения.

[  { "id": 1, "name": "Admin", "role": "ADMIN" },  { "id": 2, "name": "Tester", "role": "QA" }]

И добавляем тест для проверки расширения.

data class User(val id: Int, val name: String, val role: String) @ParameterizedTest(name = "Проверка пользователя: {0}")@JsonSource("users.json", type = User::class)@DisplayName("Должен загрузить пользователей из JSON и передать в тест")fun `should load users from json file`(user: User) {    assertNotNull(user)    assertTrue(user.id > 0, "ID пользователя должен быть положительным")}

Отлично! Мы реализовали наш кастомный источник данных для теста. Что еще можно придумать? В качестве «домашнего задания» можно реализовать свой @CsvListSource, где в csv-файле будут заголовки колонок, а в расширении — можно их сопоставить, чтобы передавать в тест только необходимые данные. 

Двигаемся дальше и переходим к ParameterResolver.

ParameterResolver

ParameterResolver — один из самых часто реализуемых интерфейсов при создании расширений для JUnit 5. Он позволяет динамически подставлять значения в тестовые методы. Генерация данных, чтение конфигов из YAML/JSON/CSV и передача их в виде объектов и еще много полезных штук можно реализовать при помощи него.

При использовании нужно переопределить два метода: 

  1. supportsParameter. Определяет поддержку типа данных (строка, класс, число, аннотация над методом и т.д.). Возвращает true при удачной обработке.

  2. resolveParameter. Возвращает готовый объект, который и попадает в тестовый метод.

Для закрепления на практике напишем расширение, которое создает временную папку на диске перед тестом, добавляет путь к ней в параметры метода, а затем удаляет её после окончания теста. Берем ParameterResolver, а также добавляем наших «старых знакомых» из первой части — BeforeEachCallback и AfterEachCallback.

class TestFolderExtension : BeforeEachCallback, AfterEachCallback, ParameterResolver {     private companion object {        val NAMESPACE = ExtensionContext.Namespace.create(TestFolderExtension::class.java)        const val FOLDER_KEY = "TEMP_FOLDER"    }     // 1. Создаем временную папку перед тестом    override fun beforeEach(context: ExtensionContext) {        val tempDir = Files.createTempDirectory("junit_test_")        context.getStore(NAMESPACE).put(FOLDER_KEY, tempDir)    }     // 2. Разрешаем внедрение параметра типа Path    override fun supportsParameter(pc: ParameterContext, ec: ExtensionContext): Boolean {        return pc.parameter.type == Path::class.java && pc.isAnnotated(TempFolder::class.java)    }     override fun resolveParameter(pc: ParameterContext, ec: ExtensionContext): Any {        return ec.getStore(NAMESPACE).get(FOLDER_KEY, Path::class.java)    }     // 3. Удаляем папку и всё её содержимое после теста рекурсивно    override fun afterEach(context: ExtensionContext) {        val store = context.getStore(NAMESPACE)        val tempDir = store.remove(FOLDER_KEY, Path::class.java)        tempDir?.toFile()?.deleteRecursively()    }}

Похожий механизм уже реализован в JUnit 5 — аннотация @TempDir. Чтобы все заработало как у сына маминой подруги в JUnit5, достаточно добавить аннотацию.

@Target(AnnotationTarget.VALUE_PARAMETER, AnnotationTarget.FIELD)@Retention(AnnotationRetention.RUNTIME)@ExtendWith(TestFolderExtension::class)annotation class TempFolder

И теперь можно свободно «инжектить» в тестовый метод временную директорию.

@Testfun `should write and read file in temp folder`(@TempFolder tempDir: Path) {    val testFile = tempDir.resolve("hello.txt")    Files.writeString(testFile, "JUnit 5 Extension Power!")     assertTrue(Files.exists(testFile))    println("Working in: ${tempDir.toAbsolutePath()}")}

ParameterResolver, ExecutionCondition, ArgumentsProvider, AnnotationConsumer и прочие интерфейсы конечно помогают решать интересные задачи, но основное действующее лицо (серый кардинал, центр управления полетами, сердце extensions — называть можно как угодно…) здесь — ExtensionContext. Чтобы лучше понимать механику работы расширений, остановимся на нем подробнее.

ExtensionContext

Итак. ExtensionContext — это фундаментальный интерфейс в JUnit 5, который служит «контекстом выполнения» для расширений (Extensions). Он предоставляет расширениям доступ к метаданным теста, управлению состоянием, конфигурации и взаимодействию с ними.

Что можно получить:

  1. иерархия: parent, root — позволяют перемещаться по дереву тестов (например, из метода достучаться до параметров класса).

  2. метаданные: displayName, tags, testClass, testMethod, element (отражение текущего элемента JUnit).

  3. управление: publishReportEntry — для публикации кастомных данных в отчеты.

ExtensionContext.Store

ExtensionContext.Store — это потокобезопасное хранилище (по сути, Map), предназначенное для хранения состояния расширения. Ключевая особенность Store — его иерархическая изоляция: каждое хранилище привязано к конкретному узлу в дереве выполнения тестов (класс, метод, вложенный контейнер).

При параллельном запуске методы одного класса могут выполняться в разных потоках. Поскольку каждый метод имеет свой ExtensionContext, данные в Store уровня метода будут изолированы. Но, так как в один Store могут писать разные расширения, тут — то нам и пригодится — ExtensionContext.Namespace.

ExtensionContext.Namespace

ExtensionContext.Namespace — механизм изоляции данных внутри хранилища (Store). Если Store — это своего рода Map, то Namespace — это уникальный идентификатор или «группировка», которая гарантирует, что данные твоего расширения не перемешаются с данными других расширений.

Зачем нужен Namespace? Представь, что у тебя есть два разных расширения: одно управляет временными папками (TestFolderExtension), а другое — базой данных (DbExtension). Оба хотят сохранить в Store объект с ключом «ID». Без Namespace второе расширение перезаписало бы данные первого. С Namespace каждое расширение работает в своей «песочнице».

val NAMESPACE = ExtensionContext.Namespace.create(AnyExtension::class.java)

Кратко, механика работы Store + Namespace такая:

  • создаем уникальный Namespace (обычно на основе класса вашего расширения), затем запрашиваем Store у текущего контекста, передавая этот Namespace — context.getStore(myNamespace).

  • при получении данных из Store — store.get("key")JUnit ищет значение в текущем контексте. Если не находит — идет к родителю (parent context) и ищет там под тем же нэймспейсом. Так поиск продолжается до самого Root. Это позволяет методам видеть данные, заданные на уровне класса.

  • при записи store.put("key", value) Store всегда пишет данные только в текущий узел, это обеспечивает изоляцию.

  • когда выполнение узла (метода/класса) завершается, JUnit уничтожает его Store. Перед уничтожением JUnit триггерит AutoCloseable.close() для всех подходящих объектов в этом хранилище.

Кстати, AutoCloseable (автоматическое закрытие ресурсов) это одна из киллер-фич. Если положить в Store объект, реализующий AutoCloseable, JUnit автоматически вызовет close() при завершении жизненного цикла этого контекста.

Немного нарастив мышечную массу «скилл», проапгрейдим наше расширение для создания временных директорий. Добавим хук JUnit 5 для предотвращения утечки ресурсов и «избавления» от AfterEachCallback.

@OptIn(ExperimentalStdlibApi::class)class TempDirectoryResource(val path: Path) : AutoCloseable {            override fun close() {        path.toFile().deleteRecursively()    }}

Также используем context.uniqueId, дабы каждый тест получит свою папку, даже при глубокой вложенности или параллелизме.

override fun beforeEach(context: ExtensionContext) {    val tempDir = createTempDirectory("junit_test_")    val resource = TempDirectoryResource(tempDir)    context.getStore(NAMESPACE).put(context.uniqueId, resource)}

В итоге, код нашего расширения будет выглядеть так:

class TestFolderExtension : BeforeEachCallback, ParameterResolver {     private companion object {        val NAMESPACE: ExtensionContext.Namespace =            ExtensionContext.Namespace.create(TestFolderExtension::class.java)        const val FOLDER_KEY = "TEMP_FOLDER"    }     @OptIn(ExperimentalStdlibApi::class)    class TempDirectoryResource(val path: Path) : AutoCloseable {        override fun close() {            path.toFile().deleteRecursively()        }    }     override fun beforeEach(context: ExtensionContext) {        val tempDir = createTempDirectory("junit_test_")        val resource = TempDirectoryResource(tempDir)        context.getStore(NAMESPACE).put(FOLDER_KEY, resource)    }     override fun supportsParameter(pc: ParameterContext, ec: ExtensionContext): Boolean {        return pc.parameter.type == Path::class.java && pc.isAnnotated(TempFolder::class.java)    }     override fun resolveParameter(pc: ParameterContext, ec: ExtensionContext): Any {        // getOrComputeIfAbsent — для правильной обработки.        // Если ресурса нет (например, это вызов в конструкторе), он создастся.        // Если есть — вернется существующий.        return ec.getStore(NAMESPACE)            .getOrComputeIfAbsent(                FOLDER_KEY,                { TempDirectoryResource(createTempDirectory("junit_test_")) },                TempDirectoryResource::class.java            ).path    }}

Где еще можно применить AutoCloseable? Например реализовать закрытие файла при ошибке чтения, автоматическое закрытие БД. Все ограничивается только вашей фантазией. 

Dependency Injection и TestInstancePostProcessor

Немного прокачавшись в написании расширений, приступаем, пожалуй, к самой интересной и сложной части нашей статьи — Dependency Injection. 

Dependency Injection (далее DI) в переводе с английского — инъекция зависимостей. Или как «говорит» нам поисковик — паттерн проектирования, при котором объект не создает свои зависимости (вспомогательные объекты) сам, а получает их извне. «Не нужон мне ваш DI. Для чего он мне в тестировании?» — скажет юный QA Automation Engineer. Ответим! «Клятвенно заверяем, что времени DI берет самую малость, а пользы от энтого, между прочим, целый вагон!…»

А именно:

  • легко подставлять моки при написании юнит-тестов, так как компоненты не создают зависимости жестко через new

  • настройка тестов aka Externalized Configuration вынесена за пределы кода. Ты можешь менять поведение (например, переключать URL стенда), не пересобирая проект

  • объекты становятся более гибкими и их легче использовать в других частях фреймворка

Ну и само собой, улучшается читаемость кода! Тем более, что «из коробки» JUnit 5 реализует Dependency Injection через механизм ParameterResolver. Это позволяет внедрять зависимости в конструктор тестового класса, в сами тестовые методы, а также в методы жизненного цикла (например, @BeforeEach или @AfterAll). Однако этот подход не позволяет внедрять зависимости напрямую в поля класса (Field Injection), что часто требуется при работе с фреймворками вроде Spring или при написании собственных сложных расширений.

Для начала напишем что-нибудь простое, например FieldInjection и поможет нам в этом TestInstancePostProcessor.

TestInstancePostProcessor

Интерфейс TestInstancePostProcessor позволяет вмешаться в жизненный цикл тестового класса сразу после того, как его экземпляр был создан. Сие означает, что инъекция в поля произойдет после того, как отработал конструктор (и все связанные с ним ParameterResolver), но до начала выполнения методов жизненного цикла экземпляра (таких как @BeforeEach). Это идеальное место для Field Injection: мы «начиняем» уже существующий объект зависимостями перед тем, как они понадобятся в тестах.

Интерфейс содержит один метод: postProcessTestInstance(testInstance: Any, context: ExtensionContext). Вы получаете прямой доступ к testInstance (объекту вашего теста) и можете использовать рефлексию для инициализации его полей.

Микро-DI а-ля FieldInjection

В тестовых проектах (фреймворках) нужно читать данные из настроек и «инжектить» их в тестовые классы или методы — хост БД, базовый URL для тестирования API и т.д. из-за этого тесты часто страдают от «захардкоженных» строк. 

Дабы облегчить себе работу, напишем небольшое расширение, которое будет внедрять значение из конфиг-файла (.properties) или env переменной в поля класса, то есть реализуем механизм Field Injection.

Не откладывая дело в долгий ящик, набросаем аннотацию @Property с двумя параметрами — key — значение, которое будем искать,file — файл properties с дефолтным значением.

@Target(AnnotationTarget.FIELD)@Retention(AnnotationRetention.RUNTIME)annotation class Property(val key: String, val file: String = "test.properties")

Теперь набросаем код расширения, которое сначала будет искать env переменную, и если не найдет — приступит к поиску в указанном файле с настройками. Для простоты примера — опустим конвертацию типов значения, оставив только String.

class PropertyInjectionExtension : TestInstancePostProcessor {     private companion object {        // Создаем уникальное пространство имен для нашего расширения        val NAMESPACE: ExtensionContext.Namespace =            ExtensionContext.Namespace.create(PropertyInjectionExtension::class.java)    }     override fun postProcessTestInstance(testInstance: Any, context: ExtensionContext) {        // 1. Используем AnnotationSupport — он сам найдет все поля с аннотацией,        // пройдясь по всей иерархии классов (включая родительские).        AnnotationSupport.findAnnotatedFields(testInstance.javaClass, Property::class.java).forEach { field ->            val annotation = field.getAnnotation(Property::class.java)             // 2. Fail-fast проверка типа            if (field.type != String::class.java) {                throw IllegalStateException("Поле ${field.name} в ${testInstance.javaClass.simpleName} должно быть String")            }             // 3. Инъекция значения            val value = loadValue(annotation, context) // Добавили context для кэширования             field.isAccessible = true            field.set(testInstance, value)        }    }     private fun loadValue(anno: Property, context: ExtensionContext): String {        // Сначала ENV — это стандарт де-факто для CI/CD        System.getenv(anno.key)?.let { return it }         // Кэшируем Properties в Store, чтобы не перечитывать файл для каждого поля        val props = context            .getStore(NAMESPACE)            .getOrComputeIfAbsent(anno.file) { loadProperties(anno.file) } as Properties         return props.getProperty(anno.key)            ?: throw IllegalStateException("Ключ '${anno.key}' не найден в '${anno.file}'")    }     private fun loadProperties(fileName: String): Properties {        val stream = javaClass.classLoader?.getResourceAsStream(fileName)            ?: if (File(fileName).exists()) FileInputStream(fileName) else null         return stream.use { s -> Properties().apply { load(s) } }    }}

Накидаем простой тест для проверки работы PropertyInjectionExtension, добавив переменную окружения REACT_APP_PORT_PROXY со значением 1081 (можно в build.gradle.kts добавить в tasks.test -> environment(«REACT_APP_PORT_PROXY», «1081»)).

@ExtendWith(PropertyInjectionExtension::class)class PropertyInjectionExtensionTests {     @Property("REACT_APP_PORT_PROXY")    lateinit var proxyPort: String     @Property("selenide.version", "gradle.properties")    lateinit var selenideVersion: String     @Test    fun `should write and read file in temp folder`() {        assertEquals(proxyPort, "1081")        assertEquals(selenideVersion, "7.3.1")    }}    

Singleton-based DI aka SpringBoot Bean на минималках

Параметры из конфига читаются, но что если нужно «заинжектить» не просто значение, а какой-нибудь класс (мок, микросервис и т.д.)? Тем более, если класс в конструкторе сам содержит ссылку на другой объект.

FieldInjection уже не подойдет, тут нужно что-то по-серьезнее! Понадобится простой IoC-контейнер, что-то вроде механизма @Autowired в SpringBoot. Но когда нас это останавливало? 

Как и театр, который начинается с вешалки, нашей отправной точкой (как обычно) станет создание аннотации — @Inject

@Target(AnnotationTarget.FIELD)@Retention(AnnotationRetention.RUNTIME)annotation class Inject

Далее, для примера напишем несколько классов-микросервисов — LogService и AnalyticsService, с условием, что AnalyticsService будет содержать в конструкторе зависимость в виде LogService.

// Cервис без зависимостейclass LogService {    fun log(message: String) = "LOG: $message"} // Сервис, который требует LogService в конструктореclass AnalyticsService(private val logger: LogService) {    fun sendAnalytics(event: String): String {        return "${logger.log(event)} -> Sent to Analytics"    }}

А теперь самое интересное — код нашего микро-DI расширения, которое будет рекурсивно проходиться по зависимостям и собирать цепочки объектов (например: AnalyticsService -> LogService). 

Важное уточнение! Этот пример демонстрирует прежде всего механику работы TestInstancePostProcessor и Store, а не является заменой полноценным IoC-контейнерам вроде Koin или Spring.

class DependencyInjectionExtension : TestInstancePostProcessor {     private companion object {        val NAMESPACE = ExtensionContext.Namespace.create(DependencyInjectionExtension::class.java)    }     override fun postProcessTestInstance(testInstance: Any, context: ExtensionContext) {        val store = context.getStore(NAMESPACE)         // Ищем поля с @Inject во всей иерархии        AnnotationSupport.findAnnotatedFields(testInstance.javaClass, Inject::class.java)            .forEach { field ->                // Рекурсивно получаем или создаем инстанс                val bean = getOrCreateBean(field.type, store)                 field.isAccessible = true                field.set(testInstance, bean)            }    }     private fun getOrCreateBean(clazz: Class<*>, store: ExtensionContext.Store): Any {        // Если бин уже есть в Store — возвращаем его        return store.getOrComputeIfAbsent(clazz) {            createInstance(clazz, store)        }    }     private fun createInstance(clazz: Class<*>, store: ExtensionContext.Store): Any {        // Проверяем, что это не интерфейс и не абстрактный класс        if (clazz.isInterface || Modifier.isAbstract(clazz.modifiers)) {            throw IllegalStateException("Не удалось внедрить ${clazz.name}: интерфейсы и абстрактные классы не поддерживаются")        }         // Берем публичный конструктор.        // Если их несколько — наш микро-DI не должен гадать, какой из них правильный.        val constructors = clazz.constructors        if (constructors.size != 1) {            throw IllegalStateException("Класс ${clazz.name} должен иметь ровно один публичный конструктор для автоматической инъекции")        }         val constructor = constructors.first()         // 3. Рекурсивно собираем зависимости        val args = constructor.parameterTypes.map { paramType ->            try {                getOrCreateBean(paramType, store)            } catch (e: Exception) {                throw IllegalStateException("Ошибка при создании зависимости ${paramType.name} для ${clazz.name}", e)            }        }.toTypedArray()         // 4. Создаем объект. Если упадет — мы получим честный стек-трейс причины.        return constructor.newInstance(*args)    }}

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

Теперь проверим работу нашего расширения, набросав небольшой тест.

@ExtendWith(DependencyInjectionExtension::class)class DependencyInjectionSimpleTest {     @Inject    lateinit var analyticsService: AnalyticsService     @Test    fun `should inject recursive dependency via constructor`() {        // Проверяем, что поле проинициализировано        assertNotNull(analyticsService)         // Проверяем работоспособность цепочки вызовов        val result = analyticsService.sendAnalytics("UserLogin")         // Если LogService не заинжектился, мы получили ошибку при создании класса        assertEquals("LOG: UserLogin -> Sent to Analytics", result)    }}

Вот мы и построили наш микро-DI — но все же это только фундамент. Что еще можно улучшить? Да много чего! 

Например: 

  • добавить цикл жизни бинов (PostConstruct и PreDestroy)

  • сделать интеграцию с моками, научив наш DI автоматически @Mock от Mockito, если реальная реализация сервиса не найдена

  • реализовать поддержку инъекции по интерфейсу, чтобы можно было подменять реализации в тестах

Важно отметить несколько моментов для нашего «супер-микро-DI-расширения». 

  • если у класса несколько конструкторов, стоит добавить логику выбора (например, искать тот, что помечен специальной аннотацией, или самый длинный)

  • остерегайтесь циклических зависимостей. Если сервис A требует в конструкторе сервис B, а тот требует A — рекурсия уйдет в бесконечный цикл и вызовет StackOverflowError. «Взрослые» фреймворки (например, как Spring) решают это построением графа зависимостей (Dependency Graph) и очередями инициализации. В качестве совета — можно добавить Set<Class<*>> в аргументы рекурсии, чтобы отслеживать уже создаваемые классы и разрывать цикл.

Вывод

Бутерброды съедены, пиво кофе выпито, а наша статья закончена. Мы разобрали ключевые механизмы, которые превращают JUnit5 из банального тест-раннера в гибкую платформу: регистрация расширений, управление состояние и жизненным циклом при помощи ExtensionContext, параметризацию тестов и даже одним глазком заглянули в реализацию Dependency Injection.

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

И хотя тема расширений для JUnit5 еще содержит достаточное количество неизведанных мест — мы намеренно оставляем это «поле» открытым для вашего самостоятельного изучения. Надеемся, что представленная база станет хорошим подспорьем для дальнейшего погружения в тему. Экспериментируйте, изучайте документацию и не бойтесь заглядывать под капот JUnit5. Помните, что правильно построенная архитектура тестов — это такая же важная часть проекта, как и архитектура основного кода.

Что почитать?

  1. Первая часть по расширениям для JUnit 5 — первая часть про расширения JUnit 5.

  2. Официальная документация JUnit 5 — JUnit 5 User Guide: Extensions.

  3. Неплохая статья от Baeldung — A Guide to JUnit 5 Extensions.

  4. Про аннотации и AnnotationTarget в Kotlin.

  5. Назад к основам: Внедрение зависимости (DI).

Автор статьи: @foxcode85

ссылка на оригинал статьи https://habr.com/ru/articles/1024120/