JUnit 5 Extensions — практическое руководство (часть 1)
Привет. В Рунете материала по 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.
Lifecycle of 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()
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()
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
— берем там же.
TMS TestRail
Примера ради «накидываем» простенький тестовый класс и навешиваем над оным аннотацию @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()
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 {
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()
context.requiredTestClass.declaredMethods.forEach { method ->
cases.addAll(
method.annotations.filterIsInstance().map { it.value.toInt() }
)
}
if (cases.isNotEmpty()) {
testRunID = createTestRun(projectId, cases)
}
}
}
TMS TestRail
Важно отметить, что метод 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()
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, 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 {
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) {
setStatus(context, TestCaseStatus.BLOCKED)
}
override fun beforeAll(context: ExtensionContext) {
val cases = arrayListOf()
context.requiredTestClass.declaredMethods.forEach { method ->
cases.addAll(
method.annotations.filterIsInstance().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
вышеуказанных методов.
А еще не помешало бы сделать обработку нескольких аннотаций @TMSLink
, присвоенных одному методу и, естественно, при взаимодействии с внешним API нельзя забывать про обработку ошибок (хотя бы проверять корректные статусы…), дабы наши тесты не упали из-за не отработавшего расширения. Но все вышесказанное уже выходит за пределы статьи…
Вместо вывода
Если захочется поэкспериментировать, то весь код доступен по ссылке на github. А уже в следующих частях мы напишем другие расширения с использованием интерфейсов ArgumentAccessor, ArgumentAggregator, ArgumentsProvider, а также разберем способ реализации Dependency Injection при помощи ParameterResolver и TestInstancePostProcessor
Надеюсь, сей опыт кому-нибудь пригодится. Буду рад конструктивной критике и советам!
Что почитать?
P.S.
В процессе написания статьи, ни один автоматизатор или тимлид не пострадал! Любые возможные совпадения с реальными людьми и событиями случайны! Всем добра!
Автор статьи: @foxcode85