«Скоро сказка сказывается, да не скоро дело делается» — говорится в народной пословице. Вот и мы решили не спешить со второй частью статьи по 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
И теперь можно свободно «инжектить» в тестовый метод временную директорию.
@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). Он предоставляет расширениям доступ к метаданным теста, управлению состоянием, конфигурации и взаимодействию с ними.
Что можно получить:
-
иерархия: 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
ссылка на оригинал статьи https://habr.com/ru/articles/1024120/