Привет. В Рунете материала по JUnit 5 Extensions сегодня немного, и довольно часто он ограничивается переводом документации (в редких случаях - постов с зарубежный ресурсов). Поэтому было решено исправить сей недостаток.
В небольшом цикле статей я расскажу о практических аспектах применения расширений JUnit 5, которые позволяют довольно элегантно решать многие задачи в проектах без использования дополнительных библиотек.
В качестве языка программирования я выберу Kotlin.
Дисклеймер
Оговорюсь сразу, в этой статье не будет: архитектуры JUnit 5, сравнения JUnit 5 с JUnit 4, примеров работы со сторонними расширениями, особенностей работы с kotlin reflection, а также мемов (...но это не точно!).
"И зачем ты тогда написал сей опус"- скажет типичный читатель "хабра", и будет отчасти прав. Но не спешите размахивать шпагами, господа тестировщики! Основной акцент будет сделан именно на практическом использовании, а если хочется "базы", то ее всегда можно найти в документации.
Само собой, статья предназначена для новичков, поэтому "Звездам тяжелого металла" aka "Матерым автоматизаторам и SDET-ам" будет немного скучно и, возможно, они даже найдут тут ошибки (а, возможно, и не одну...)
Краткая справка
JUnit 5 Extensions - мощное средство, которое позволяет существенно менять работу жизненного цикла тестов. Постобработка результатов, внедрение зависимостей, обработка исключений, кастомные источники данных для параметризированных тестов и еще множество фишек предлагает нам сей механизм расширений в JUnit 5.
Проще говоря, если вам нужно: сделать аннотацию с источником данных из json, записать результат выполнения теста в TMS или вдруг захотелось ограничить выполнение тестов по понедельникам (день-то тяжелый!!!) то вы нашли то, что надо!
Работает это примерно так - в процессе выполнения JUnit доходит до определённой фазы, которая называется extension point (точка расширения), и если присутствуют зарегистрированные расширения, которые могут быть выполнены на данном шаге, то JUnit запускает их. Соответственно, если есть желание запустить свой "extension с преферансом и куртизанками", то надо создать класс, который реализует определенный интерфейс, предоставляемый JUnit 5 Extension API.

Приступаем!
Перед тем как приступить к написанию собственных расширений для JUnit5, нужно подключить зависимости. Берем Gradle (или Maven, если мсье/мадам знает толк в извращениях) и в файл build.gradle.kts добавляем:
testImplementation(platform("org.junit:junit-bom:5.10.0")) testImplementation("org.junit.jupiter:junit-jupiter") testImplementation("org.junit.jupiter:junit-jupiter-api:5.10.0")
Конечно, версию пактов указываем самую свежую, собираем проект командой - ./gradlew clean build и выдыхаем. Первый шаг сделан! На "всякий пожарный" - ссылка на официальную доку Gradle, так как при переходе в Gradle с Groovy на Kotlin некоторые моменты могут быть не понятны.
Коллбэки жизненного цикла
Итак, ты начинающий инженер по автоматизации тестирования, и у тебя на проекте имеется:
Allure - 1 шт.
RestAssured - 1 шт.
TestRail - 1 шт.
Тимлид - 1 шт.
Ставится задача - обновлять статус тест-кейса в TMS TestRail после выполнения теста. Сказано - сделано! И как театр начинается с вешалки, наш проект начнется с класса для расширения.
// Класс для расширения TestRail class TestRailExtension { private val testRailHost = "https://rails.yourcompany.tech/index.php?/api/v2" }
И сразу же добавим в проект enum class с описанием статусов. Посмотреть их можно у себя в TMS.
// Определение enum класса для статусов тест-кейса enum class TestCaseStatus(val id: Int) { PASSED(1), FAILED(2) }
Теперь дело за аннотацией. И тут два пути - создать свою или использовать уже имеющуюся (например TMSLink в Allure). Выбираем второй вариант и пишем класс для нашего расширения в котором сразу указываем ссылку на TestRail API, попутно заменив rails.yourcompany.tech - на свой домен.
Аннотация TMSLink - позволяет добавлять ссылки на тест-кейсы в TMS (в данном случае в TestRail). Чтобы ссылка в Allure отчете заработала, надо создать файл allure.properties в resources и прописать там:
allure.link.tms.pattern=https://youcompany.tech/projects/777/tests/{}
...где https://youcompany.tech/projects/777/tests/ - ссылка на тест-кейс, а {} - будут заменять номер кейса в TMS-ке.
Теперь плавно переходим к LifeCycle Callbacks. Junit 5 предоставляет большое многообразие интерфейсов для работы с его жизненным циклом: от внедрения зависимостей в тестовый класс и параметров в его метод при помощи TestInstancePostProcessor и ParameterResolver до условного выполнения методов с ExecutionCondition и пользовательской обработки исключений с TestExecutionExceptionHandler. Но нам пока пригодятся - коллбэки жизненного цикла:
BeforeAllCallback и AfterAllCallback - выполняются до и после выполнения всех тестовых методов
BeforeEachCallBack и AfterEachCallback - выполняются до и после каждого тестового метода
BeforeTestExecutionCallback и AfterTestExecutionCallback - выполняются непосредственно до и сразу после тестового метода и после BeforeEachCallBack и AfterEachCallback соответственно.
Мы хотим отлавливать результат после каждого теста и следовательно нам подходит AfterEachCallback, но что если наши коллеги-джедаи добавят в класс метод с аннотацией @AfterEach? Это может аффектить код. Поэтому смело берем - AfterTestExecutionCallback
class TestRailExtension : AfterTestExecutionCallback { private val testRailHost = "https://rails.yourcompany.tech/index.php?/api/v2" private val setOfCases = HashMap<String, Int>() override fun afterTestExecution(context: ExtensionContext) { val annotation: TmsLink? = context.element.get().getAnnotation(TmsLink::class.java) val caseStatus = when (!context.executionException.isPresent) { true -> TestCaseStatus.PASSED.id else -> TestCaseStatus.FAILED.id } annotation?.let { setOfCases[it.value] = caseStatus } } }
...наследуемся и переопределяем метод afterTestExecution. Не забываем про "хэшмапу" для хранения результатов, которая хранит записи в формате "ключ-значение", в нашем случае - "номер кейса - статус выполнения". Еще хэшмапа отличается тем, что если добавлять значения с одинаковыми ключами, то они будут затираться, а нам ведь не нужны разные результаты для одного кейса?..
Остается найти аннотацию @TMSLink в каждом тестовом методе и узнать результат его прохождения, который нам вернет - context.executionException.isPresent. Вообще, при помощи ExecutionContext можно получить много чего интересного, но об этом в следующей части статьи.
После того, как все тесты отработают, нужно будет обновить их статусы. Для этого наследуемся от AfterAllCallback и переопределяем метод afterAll.
AfterTestExecutionCallback и AfterAllCallback
class TestRailExtension : AfterTestExecutionCallback, AfterAllCallback { private val testRailHost = "https://rails.yourcompany.tech/index.php?/api/v2" private val setOfCases = HashMap<String, Int>() private var testRunID = 91 override fun afterTestExecution(context: ExtensionContext) { val annotation: TmsLink? = context.element.get().getAnnotation(TmsLink::class.java) val caseStatus = when (!context.executionException.isPresent) { true -> TestCaseStatus.PASSED.id else -> TestCaseStatus.FAILED.id } annotation?.let { setOfCases[it.value] = caseStatus } } private fun setCaseStatus(caseStatus: Int, caseId: String) { RestAssured.given() .auth().preemptive().basic("YOUR_EMAIL", "YOUR_PASSWORD") .contentType(ContentType.JSON) .body(hashMapOf("status_id" to caseStatus)) .post("$testRailHost/add_result_for_case/$testRunID/$caseId") } override fun afterAll(context: ExtensionContext?) { setOfCases.forEach { (caseId, caseStatus) -> setCaseStatus(caseStatus, caseId) } } }
Кстати, testRunID можно узнать зайдя непосредственно в сам TestRun, он будет указан в левом углу, также его можно получить из запроса, предварительно открыв "какой-нибудь chrome devtools" и обновив статус кейса в тест-ране. Project ID - берем там же.

Примера ради "накидываем" простенький тестовый класс и навешиваем над оным аннотацию @ExtendWith(TestRailExtension::class), чтобы наше расширение заработало.
@ExtendWith(TestRailExtension::class) class HabrDemo { @Test @TmsLink("1595") fun `demo test one`() = assertTrue(true) }
Стартуем. Проверяем обновленный статус у кейса. Показываем код. Радуемся, но правда не долго. Тимлид явно не доволен, ибо желает, чтобы перед запуском тестового класса создавался новый TestRun в TestRails.
Чтобы перед выполнением тестов создать TestRun - понадобится BeforeAllCallback. Добавляем еще несколько штрихов, а именно методы createTestRun и beforeAll и выдаем на-гора финальный код:
Финальный код класса
class TestRailExtension : AfterTestExecutionCallback, AfterAllCallback, BeforeAllCallback { private val testRailHost = "https://rails.yourcompany.tech/index.php?/api/v2" private val setOfCases = HashMap<String, Int>() private var testRunID = 91 private val projectId = 2 private val localDatetime = LocalDateTime.now() .format(DateTimeFormatter.ofPattern("dd-MM-yyyy HH:mm")) override fun afterTestExecution(context: ExtensionContext) { val annotation: TmsLink? = context.element.get().getAnnotation(TmsLink::class.java) val caseStatus = when (!context.executionException.isPresent) { true -> TestCaseStatus.PASSED.id else -> TestCaseStatus.FAILED.id } annotation?.let { setOfCases[it.value] = caseStatus } } private fun setCaseStatus(caseStatus: Int, caseId: String) { RestAssured.given() .auth().preemptive().basic("YOUR_EMAIL", "YOUR_PASSWORD") .contentType(ContentType.JSON) .body(hashMapOf("status_id" to caseStatus)) .post("$testRailHost/add_result_for_case/$testRunID/$caseId") } private fun createTestRun(projectId: Int, testCases: List<Int>): Int { val requestBody = hashMapOf( "name" to "New test run $localDatetime", "include_all" to false, "case_ids" to testCases ) val response = RestAssured.given() .auth().preemptive().basic("YOUR_EMAIL", "YOUR_PASSWORD") .contentType(ContentType.JSON) .body(requestBody) .post("$testRailHost/add_run/$projectId") return response.body.jsonPath().getString("id").toInt() } override fun afterAll(context: ExtensionContext) { setOfCases.forEach { (caseId, caseStatus) -> setCaseStatus(caseStatus, caseId) } } override fun beforeAll(context: ExtensionContext) { val cases = arrayListOf<Int>() context.requiredTestClass.declaredMethods.forEach { method -> cases.addAll( method.annotations.filterIsInstance<TmsLink>().map { it.value.toInt() } ) } if (cases.isNotEmpty()) { testRunID = createTestRun(projectId, cases) } } }

Важно отметить, что метод createTestRun создает новый тест-ран и возвращает его "айдишник". Более подробно об этом читаем тут. Итак! TestRun создается, статусы в нем обновляются, а тимлид более не желает нашей крови. И тут тимлид заявляет: "Здесь есть более подходящее решение! Погугли эту тему".
Убираем шампанское и возвращаемся к чертежной доске клавиатуре...
Test Watcher и обработка результатов теста
Интерфейс TestWatcher предоставляет более богатый API для обработки результатов тестов, нежели чем LifeCycleCallbacks. С помощью него можно "отлавливать" не только упавшие и/или успешные тесты, но и пропущенные, прерванные, что собственно нам и нужно.
После прочтения документации TestRail API расширяем enum-класс со статусами
enum class TestCaseStatus(val id: Int) { PASSED(1), BLOCKED(2), RETEST(4), FAILED(5) }
TestWatcher предоставляет следующие методы для работы с результатами тестов:
testSuccessful(context: ExtensionContext) - обработка результатов успешно пройденного теста
testFailed(context: ExtensionContext, cause: Throwable)- обработка результатов упавшего теста
testAborted(context: ExtensionContext, cause: Throwable) - обработка результатов отмененного теста
testDisabled(context: ExtensionContext, reason: Optional) - обработка результатов пропущенного теста
Кроме контекста, в методах встречаются еще 2 параметра, которые могут пригодится - cause: Throwable и reason: Optional. Первый позволяет получить инфу об ошибке, а второй - о причине пропуска теста (аннотация @Disabled("Cause text...")).
Переписываем TestWatcherExtension - наследуемся от TestWatcher, BeforeAllCallback, AfterAllCallback. Далее избавляемся от дублирования кода (выносим обращение к TestRail API и обновление статуса в отдельные методы) и получаем финальный код нашего расширения
TestWatcherExtension
class TestWatcherExtension : TestWatcher, BeforeAllCallback, AfterAllCallback { private val testRailHost = "https://rails.yourcompany.tech/index.php?/api/v2" private val setOfCases = HashMap<String, Int>() private var testRunID = 91 private val projectId = 2 private val localDatetime = LocalDateTime.now() .format(DateTimeFormatter.ofPattern("dd-MM-yyyy HH:mm")) private fun updateResult(body: HashMap<String, Any>, url: String): Response { return RestAssured.given() .auth().preemptive().basic("YOUR_EMAIL", "YOUR_PASSWORD") .contentType(ContentType.JSON) .body(body) .post(url) } private fun createTestRun(projectId: Int, testCases: List<Int>): Int { val requestBody = hashMapOf( "name" to "New test run $localDatetime", "include_all" to false, "case_ids" to testCases ) val response = updateResult(requestBody, "$testRailHost/add_run/$projectId") return response.body.jsonPath().getString("id").toInt() } private fun setStatus(ctx: ExtensionContext, caseStatus: TestCaseStatus) { val annotation: TmsLink? = ctx.element.get().getAnnotation(TmsLink::class.java) annotation?.let { setOfCases[it.value] = caseStatus.id } } override fun testSuccessful(context: ExtensionContext) { setStatus(context, TestCaseStatus.PASSED) } override fun testFailed(context: ExtensionContext, cause: Throwable) { setStatus(context, TestCaseStatus.FAILED) } override fun testAborted(context: ExtensionContext, cause: Throwable) { setStatus(context, TestCaseStatus.RETEST) } override fun testDisabled(context: ExtensionContext, reason: Optional<String>) { setStatus(context, TestCaseStatus.BLOCKED) } override fun beforeAll(context: ExtensionContext) { val cases = arrayListOf<Int>() context.requiredTestClass.declaredMethods.forEach { method -> cases.addAll( method.annotations.filterIsInstance<TmsLink>().map { it.value.toInt() } ) } if (cases.isNotEmpty()) { testRunID = createTestRun(projectId, cases) } } override fun afterAll(context: ExtensionContext) { setOfCases.forEach { (caseId, caseStatus) -> updateResult( hashMapOf("status_id" to caseStatus), "$testRailHost/add_result_for_case/$testRunID/$caseId" ) } } }
Что еще можно улучшить? Вынести адрес хоста, логин, пароль и id проекта в конфигурационный файл (например, env). Также, в TestRail можно добавлять описание при обновлении результата кейса, которое в случае неудачного выполнения мы можем взять из переменной cause: Throwable или reason: Optional<String> вышеуказанных методов.
А еще не помешало бы сделать обработку нескольких аннотаций @TMSLink, присвоенных одному методу и, естественно, при взаимодействии с внешним API нельзя забывать про обработку ошибок (хотя бы проверять корректные статусы...), дабы наши тесты не упали из-за не отработавшего расширения. Но все вышесказанное уже выходит за пределы статьи...
Вместо вывода
Если захочется поэкспериментировать, то весь код доступен по ссылке на github. А уже в следующих частях мы напишем другие расширения с использованием интерфейсов ArgumentAccessor, ArgumentAggregator, ArgumentsProvider, а также разберем способ реализации Dependency Injection при помощи ParameterResolver и TestInstancePostProcessor
Надеюсь, сей опыт кому-нибудь пригодится. Буду рад конструктивной критике и советам!
Что почитать?
Официальная документация TestRail API - Introduction to the TestRail API
Официальная документация по расширениям - JUnit 5 Extension Model
Хорошая статья на английском - Introduction to JUnit 5 Extensions
...и не менее полезная на русском - Полное руководство по расширениям JUnit 5
P.S.
В процессе написания статьи, ни один автоматизатор или тимлид не пострадал! Любые возможные совпадения с реальными людьми и событиями случайны! Всем добра!
Автор статьи: @foxcode85
