"Скоро сказка сказывается, да не скоро дело делается" - говорится в народной пословице. Вот и мы решили не спешить со второй частью статьи по 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 и передача их в виде объектов и еще много полезных штук можно реализовать при помощи него.
При использовании нужно переопределить два метода:
supportsParameter. Определяет поддержку типа данных (строка, класс, число, аннотация над методом и т.д.). Возвращает true при удачной обработке.
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). Он предоставляет расширениям доступ к метаданным теста, управлению состоянием, конфигурации и взаимодействию с ними.
Что можно получить:
иерархия: parent, root — позволяют перемещаться по дереву тестов (например, из метода достучаться до параметров класса).
метаданные: displayName, tags, testClass, testMethod, element (отражение текущего элемента JUnit).
управление: 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. Помните, что правильно построенная архитектура тестов — это такая же важная часть проекта, как и архитектура основного кода.
Что почитать?
Первая часть по расширениям для JUnit 5 - первая часть про расширения JUnit 5.
Официальная документация JUnit 5 — JUnit 5 User Guide: Extensions.
Неплохая статья от Baeldung - A Guide to JUnit 5 Extensions.
Про аннотации и AnnotationTarget в Kotlin.
Назад к основам: Внедрение зависимости (DI).
Автор статьи: @foxcode85