"Скоро сказка сказывается, да не скоро дело делается" - говорится в народной пословице. Вот и мы решили не спешить со второй частью статьи по 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

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

@Test
fun `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

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