Привет. В Рунете материала по 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
ссылка на оригинал статьи https://habr.com/ru/articles/773530/
Добавить комментарий