Привет. В Рунете материала по 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
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<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 - берем там же.

TMS TestRail
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<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)
      }
  }
}

TMS TestRail
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<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

Надеюсь, сей опыт кому-нибудь пригодится. Буду рад конструктивной критике и советам!

Что почитать?

P.S.

В процессе написания статьи, ни один автоматизатор или тимлид не пострадал! Любые возможные совпадения с реальными людьми и событиями случайны! Всем добра!

Автор статьи: @foxcode85

Комментарии (4)


  1. penolegrus
    15.11.2023 08:12

    Очень полезная статья! Автору большой респект за проделанные труды


  1. IvanVakhrushev
    15.11.2023 08:12

    Не забываем про "хэшмапу" для хранения результатов

    А что будет при параллельном прогоне тестов? Как этот код будет работать?


    1. IvanVakhrushev
      15.11.2023 08:12

      И что будет при использовании ретраев на тестах: например, первая попытка провалилась, а вторая прошла? Эта ситуация нормально распознается?


      1. foxcode85
        15.11.2023 08:12

        Уровень статьи для начинающих, так что как оно будет работать с многопоточным запуском тестов - дело того самого "тимлид 1шт")

        Если серьезно, то в junit5 можно сделать несколько режимов запуска тестов в многопоке. Один из них, запускает параллельно методы из разных тестовых классов - в таком случае проблем не будет. Но опять-таки, зачем заворачивать в ThreadLocal переменную, если по сути все кейсы (айдишники) должны быть разными?

        А насчёт дожима тестов, ну не знаю. По мне это не очень практика и сигнализирует о том, что что-то не так.

        В целом, вопрос с многопоточкойв тестах интересный, но это тема уже для отдельной статьи.