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.
Приступаем!
Перед тем как приступить к написанию собственных расширений для 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