На заре формирования команды, в которой работаю, было решено использовать JVM-стек для реализации UI-автотестов, а именно:

  • Kotlin — язык разработки;

  • JUnit5 — ядро проектирования автотестов;

  • Selenide — основа взаимодействия с DOM-моделью браузера в автотестах;

  • Allure для JVM — очень удобный инструмент для формирования отчётности в автотестах.

Дополнительно, у нас имеется следующий ряд инструментов для улучшения процессов UI-автотестирования:

  • Allure TestOPS — инструмент хранения артефактов автотестов и тестовой документации;

  • Selenoid — для удалённого запуска тестов, кросс-браузерного тестирования и опциональной видеозаписи запущенных автотестов и последующего их хранения

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

Очень часто дефолтное использование этих инструментов при корректной настройке порождало несколько проблем:

  1. Тестов раньше было мало, а сейчас стало больше.

  2. Стало больше автотестов — стали длиннее видеофайлы в Selenoid с записанным прохождением автотестов. К тому же они не особо связаны с отчётами от Allure.

  3. Стали длиннее видеофайлы — тяжелее стало понимать, какой тест исполняется в конкретный момент времени.

  4. Тяжело стало понимать —нужно большей прозрачности и гибкости в формировании отчёта о прохождении UI-автотестов.

Есть проблема — нужно решать. В целом, подобные нетривиальные задачи решались и ранее, только в среде API автотестов. Выбранный стек (JUnit5, Selenide) позволяет проводить довольно глубокие конфигурации, удалось реализовать поставку серьёзно доработанных видеоартефактов прохождения автотестов для Allure-отчёта с поддержкой временных меток.

Видео теста более часа. Беда :(
Видео теста более часа. Беда :(

Поиск решения

На какие вспомогательные факты опирался при решении задачи:

  • Allure позволяет использовать «вложения» в результирующем отчёте, и это «вложение» может быть применено как к конкретному шагу теста, так и ко всему тесту;

  • Selenide начинает взаимодействие с браузером в контейнере Selenoid при первом вызове метода open() — за это тоже можно зацепиться при реализации решения;

  • у Selenide есть контракт безопасного доступа к webdriver c помощью обёртки WebdriverRunner;

  • у java-реализации Selenium (Selenide основан на нём) существует контракт работы с событиями, исполняемыми webdriver'ом — WebdriverListener;

  • Selenide берёт на себя ответственность при закрытии своих webdriver после окончания всех тестов, если работает в режиме по умолчанию;

  • у JUnit5 есть богатая функциональность внедрения в жизненный цикл теста с помощью JUnit5 lifecycle extensions.

Основную идею реализации улучшения процесса отчётности UI-автотестов можно разбить на следующие шаги:

  1. Отказываемся от записи видео всех тестов в один файл, работаем над идеями дробления видео.

  2. Формируем таймлайн прохождения автотестов с момента старта записи видео в Selenoid.

  3. Формируем временные метки для упавших автотестов относительно таймлайна.

  4. Модернизируем видеозапись в соответствии с временными метками.

  5. Внедряем в Allure отчёт «вложение» с модернизированной видеозаписью.

Часть идей была реализована as-is, а над некоторыми пришлось чуть-чуть «попотеть» в реализации разумного по времени разработки алгоритма. Но подробности ниже.

Как переработать существующий процесс записи видео в Selenoid

При работе над проектом требуется учитывать следующие условия:

  • Selenoid записывает действия в контейнере с браузером только тогда, когда выставлены следующие capabilities для webdriver (для удобства буду представлять это в виде json):

{
  "selenoid:options": {
    "enableVideo": true
  }
}
  • Selenoid начинает запись видео при старте новой сессии (открытие сессии → старт браузера → переход по ссылке на url для UI тестов)

  • Selenoid останавливает видеозапись после закрытия сессии (или самого webdriver) в коде автотестов

Обсудив различные идеи с командой и потенциальными потребителями вот этого всего решения, мы приняли следующий подход — записывать отдельное видео для каждого тестового сьюта или тестового класса, если проще.

Это можно сделать следующим образом — реализацией JUnit5 Extension. Это класс, который обязан реализовать один или несколько интерфейсов, содержащихся в библиотеке JUnit5. Нас будут интересовать следующие интерфейсы:

  • BeforeAllCallback — интерфейс, добавляющий некую функциональность перед запуском всех тестов и фикстур в тестовом контейнере (тестовом классе). Здесь мы добавим предварительную конфигурацию Selenide для настройки опций функции видеозаписи в Selenoid;

  • AfterAllCallback — интерфейс, добавляющий некую функциональность после прохождения всех тестов и фикстур в тестовом контейнере (тестовом классе). Здесь мы реализуем процесс остановки записи видео.

К тому же крайне желательно при работе этого Extension нужно понять, что:

  1. запускаются Selenide тесты, чтобы настроить нужные нам capabilities;

  2. пользователь хочет запустить тесты на Selenoid;

  3. пользователь хочет использовать опцию видеозаписи на Selenoid.

Я не придумал ничего лучше чем:

  1. Классифицировать тесты, реализованные с помощью библиотеки Selenide c помощью аннотаций. Разработчик автотестов обязан пометить аннотацией тестовый класс, в котором хранятся его Selenide тесты.

@Target(AnnotationTarget.CLASS)
annotation class SelenideTest

Руководствовался тем, что возможна ситуация, когда в одном репозитории с автотестами могут находиться и UI, и API, и ещё какие-либо другие тесты, но работа этого Extension не должна влиять ни на что, кроме Selenide тестов.

2-й и 3-й пункты решить с помощью «флагов». Эту роль могут легко исполнять переменные окружения, в которых хранится булево значение:

  • SELENOID_SUPPORT — для опции запуска тестов через selenoid;

  • SELENOID_VIDEO — для опции видеозаписи.

MVP решения можно представить в коде так (часть дополнительных для работы Extension методов представлены в укороченном виде и без разъяснений, и могут отличаться в зависимости от ваших идей):

Extension с функцией записи видео по тестовым классам
class SelenideCoolExtension : BeforeAllCallback, AfterAllCallback {

 	override fun beforeAll(context: ExtensionContext) {

        // Проверяем, что тестовый класс содержит Selenide тесты

        val matchAnnotation = parseSelenideTest(context)

        // Настраиваем Selenide конфигурацию только в случае запуска Selenide тестов
        matchAnnotation?.let {

            // Рекомендация от разработчиков Selenide — использовать однопоточный режим исполнения тестов
            checkSingleThreadExecution(context)

            val capabilities = DesiredCapabilities()
            // Здесь могут идти дополнительные настройки самого браузера

            // Настраиваем, если нужно запуск через Selenoid
						if (checkSelenoidSupport()) {
							Configuration.remote = "$selenoidUrl/wd/hub" 
							Configuration.driverManagerEnabled = false
							// Настраиваем, если нужно, и опцию видеозаписи с уникальным именем для видео 
							// на основе метки имени тестового класса, данных CI/Local режима запуска,
							// и UTC времени для удобства поиска в Selenoid capabilities.setCapability("selenoid:options", prepareSelenoidOptions(context))
						}

            // Полностью готовые capabilities пробрасываются в конфигурацию
            Configuration.browserCapabilities = capabilities
        }
	}

    override fun afterAll(context: ExtensionContext) {
        // Проверяем, что тестовый класс содержит Selenide тесты
        val matchAnnotation = parseSelenideTest(context)

        // Закрываем сессию только для Selenide тестов
        matchAnnotation?.let {
            WebDriverRunner.closeWebDriver()

		} 
	}

	private fun parseSelenideTest(ctx: ExtensionContext): SelenideTest? { 
		return ctx
			.testClass
      .orElseThrow { -> IllegalStateException("Something wrong with context. Test must contain ancestor (test class)") }
			.let { AnnotationUtils.findAnnotation(it, SelenideTest::class.java) } 
			.orElse(null)
	}

	private fun checkSingleThreadExecution(context: ExtensionContext) { 
		context.getConfigurationParameter("junit.jupiter.execution.parallel.enabled")?.ifPresent {
			if (it.toBoolean()) throw IllegalStateException("This extension works only with single thread mode") 
		}
	}


	private fun checkSelenoidSupport(): Boolean {
		return when (System.getenv(SELENOID_SUPPORT_ENV_PROPERTY_NAME)) {
			"1" -> true
			else -> false 
		}
	}

	private fun checkVideoRecorderSupport(): Boolean {
		return when (System.getenv(SELENOID_VIDEO_SUPPORT_ENV_PROPERTY_NAME)) {
			"1" -> true
			else -> false 
		}
	}

	private fun prepareSelenoidOptions(context: ExtensionContext): Map<String, Any> { 
		val selenoidOptions = mutableMapOf<String, Any>(
			"enableVNC" to true, 
		)
		if (checkVideoRecorderSupport()) { 
			selenoidOptions["enableVideo"] = true 
			selenoidOptions["videoName"] = generateVideoName(context)
		}
		return selenoidOptions 
	}

	private fun generateVideoName(context: ExtensionContext): String {

        val ciJobId = System.getenv(GITLAB_JOB_ID_ENV_PROPERTY_NAME) ?: "Local"
        val currentTime = ZonedDateTime.now(Clock.systemUTC())
            .format(DateTimeFormatter.ofPattern(SELENOID_VIDEO_DATETIME_PATTERN))

        val suiteName = context
            .testClass
						.orElseThrow().let {
								AnnotationUtils.findAnnotation(it, DisplayName::class.java)
            }.orElseThrow {
                IllegalStateException("Test suite must be annotated with @DisplayName")
            }.value
            .splitToSequence(" ")
            .joinToString("-")

		val resultName = "$ciJobId-$suiteName-$currentTime.mp4" 

		return resultName
	}

	private val selenoidUrl by lazy {
		System.getenv(SELENOID_URL_ENV_PROPERTY_NAME) ?: SELENOID_DEFAULT_SERVICE_URL
	}
}

Я целенаправленно стараюсь писать при таких решениях простой и понятный код, для того чтобы решение могло поддерживаться многими людьми и командами.

Проверить данное решение помог синтетический тест:

  1. Запустить два тестовых класса:

@SelenideTest 
@DisplayName("A") 
class ASuite {

	@Test
	fun aTest() {
		open("https://www.google.com")
		Selenide.sleep(5 * 1000)
	}
}
@SelenideTest 
@DisplayName("B") 
class BSuite {

	@Test
	fun bTest() {
		Selenide.open("https://www.yandex.ru")
		Selenide.sleep(5 * 1000)
	}
}
  1. Убедиться, что в Selenoid создано ровно два видео c нужным именем, а каждое видео содержит исполнение всех тестов тестового класса.

Результат более чем устроил:

Два видео с теста, как доктор прописал
Два видео с теста, как доктор прописал

Как сформировать таймлайн и временные метки для тестового сьюта

Сложность заключалась в том, что хотелось одновременно сделать простое и понятное решение, но и максимально точно формировать все эти временные метки с учётом поведения тестов на Selenoid.

Вдохновившись примером в официальной документации JUni5 для работы со временем, алгоритм сразу пришёл в голову:

  1. При начале работы Extension создать внутри него поле, содержащее счётчик времени с началом в нуле.

  2. Перед запуском всех тестов в классе и при поднятом флаге SELENOID_VIDEO зарегистрировать для webdriver кастомный WebdriverListener, у которого есть ссылка на этот счётчик. Этот listener должен запустить счёт времени только тогда, когда произошло первое взаимодействие с браузером в автотестах. Я выбрал завязку на срабатывание метода webdriver navigate(), так как именно он вызывается при вызове метода open() библиотеки Selenide, c вызовом которого начинается любой автотеста. И к тому же этот listener обязан хранить внутри себя состояние первого обращения к методу navigate, чтобы корректно обрабатывать и не реагировать на последующие вызовы navigate.

  3. Перед каждым запуском конкретно взятого автотеста снимать состояние счётчика времени и регистрировать его в какой-нибудь переменной. Да и в лог добавить бы. В этом нам поможет реализация нового интерфейса внутри нашего Extension — BeforeEachCallback, позволяющая добавить некую функциональность, которая будет выполнена прямо перед стартом каждого отдельно взятого автотеста.

  4. Если тест упал по какой-либо причине, нужно получить текущее состояние счётчика времени, а также на момент запуска теста, и добавить это всё в лог. В этом нам поможет реализация нового интерфейса внутри нашего Extension — TestWatcher, позволяющая добавлять функциональность после успеха/падения/пропуска автотеста.

  5. После прохождения всех автотестов сбрасывать счётчик времени и отзывать работу WebDriverListener.

Не забываем, что все эти функции должны исполняться только для Selenide тестов.

Предлагаю взглянуть на новый этап реализации MVP нашего Extension:

Extension с поддержкой временных меток
private val logger = KotlinLogging.logger {}

class SelenideCoolExtension : BeforeAllCallback, AfterAllCallback, TestWatcher, BeforeEachCallback {

    // Инциализируем переменную для регистрации меток времени
    private val timeSnapshot = AtomicLong(0)
    
    // Инициализируем счетчик времени
    private val stopwatch: Stopwatch = Stopwatch.createUnstarted()

    private lateinit var extensionListener: WebDriverListener 
    
    override fun beforeAll(context: ExtensionContext) {
    
        // Проверяем, что тестовый класс содержит Selenide тесты
        val matchAnnotation = parseSelenideTest(context)

        // Настраиваем Selenide конфигурацию только в случае запуска Selenide тестов
        matchAnnotation?.let {

            // Рекомендация от разработчиков Selenide - использовать однопоточный режим исполнения тестов
            checkSingleThreadExecution(context)

            val capabilities = DesiredCapabilities()
            // Здесь могут идти дополнительные настройки самого браузера

            // Настраиваем, если нужно запуск через Selenoid
            if (checkSelenoidSupport()) {
                Configuration.remote = "$selenoidUrl/wd/hub" 
                Configuration.driverManagerEnabled = false
                // Настраиваем, если нужно, и опцию видеозаписи с уникальным именем для видео 
                // на основе метки имени тествого класса, данных CI/Local режима запуска,
                // и UTC времени для удобства поиска в Selenoid 
                capabilities.setCapability("selenoid:options", prepareSelenoidOptions(context))
            }
            // Регистрируем listener только при включенной опции записи видео
            if (checkVideoRecorderSupport()) { 
                extensionListener = CounterListener(stopwatch) 
                WebDriverRunner.addListener(extensionListener)
            }
            // Полностью готовые capabilities пробрасываются в конфигурацию
            Configuration.browserCapabilities = capabilities
        }
    }

    override fun beforeEach(context: ExtensionContext) {
        val matchAnnotation = parseSelenideTest(context)

        matchAnnotation?.let {
            // Фиксируем время старта теста только при включенной опции записи видео
            if (checkVideoRecorderSupport()) { 
                stopwatch.elapsed(TimeUnit.SECONDS).let { time ->
                    timeSnapshot.set(time)
                }
            }
        }
    }

    override fun testFailed(context: ExtensionContext, cause: Throwable?) {
        val matchAnnotation = parseSelenideTest(context)
        
        // Логируем время старта теста и время падения по отношению к таймлайну видео
        // только при включенной опции записи видео
        matchAnnotation?.let {
            if (checkVideoRecorderSupport()) {
                logger.error { "Test is failed. Test start time in video - ${timeSnapshot.get()} sec" }
                stopwatch.elapsed(TimeUnit.SECONDS).let {
                    logger.error { "Test failure is on $it sec" }
                } 
            }
        }
    }

    override fun afterAll(context: ExtensionContext) {
        // Проверяем, что тестовый класс содержит Selenide тесты
        val matchAnnotation = parseSelenideTest(context)

        // Закрываем сессию только для Selenide тестов
        // Отзываем listener
        // А также логируем общее время видеозаписи
        matchAnnotation?.let {
            if (checkVideoRecorderSupport()) {
                logger.warn { "Test run seconds: ${stopwatch.elapsed(TimeUnit.SECONDS)} seconds" } 
                stopwatch.reset()
            }
            WebDriverRunner.closeWebDriver()
            if (this::extensionListener.isInitialized) {
                WebDriverRunner.removeListener(extensionListener)
            }
        }
    }

    private fun parseSelenideTest(ctx: ExtensionContext): SelenideTest? { 
        return ctx
            .testClass
            .orElseThrow { -> IllegalStateException("Something wrong with context. Test must contain
    ancestor (test class)") }
            .let { AnnotationUtils.findAnnotation(it, SelenideTest::class.java) } 
            .orElse(null)
    }

    private fun checkSingleThreadExecution(context: ExtensionContext) {
        context.getConfigurationParameter("junit.jupiter.execution.parallel.enabled")?.ifPresent {
            if (it.toBoolean()) throw IllegalStateException("This extension works only with single thread mode")  
        }
    }

    private fun checkSelenoidSupport(): Boolean {
        return when (System.getenv(SELENOID_SUPPORT_ENV_PROPERTY_NAME)) {
            "1" -> true
            else -> false 
        }
    }

    private fun checkVideoRecorderSupport(): Boolean {
        return when (System.getenv(SELENOID_VIDEO_SUPPORT_ENV_PROPERTY_NAME)) {
            "1" -> true
            else -> false 
        }
    }

    private fun prepareSelenoidOptions(context: ExtensionContext): Map<String, Any> { 
        val selenoidOptions = mutableMapOf<String, Any>(
            "enableVNC" to true, 
        )
        if (checkVideoRecorderSupport()) { 
            selenoidOptions["enableVideo"] = true 
            selenoidOptions["videoName"] = generateVideoName(context)
        }
        return selenoidOptions 
    }

    private fun generateVideoName(context: ExtensionContext): String {

            val ciJobId = System.getenv(GITLAB_JOB_ID_ENV_PROPERTY_NAME) ?: "Local"
            val currentTime = ZonedDateTime.now(Clock.systemUTC())
                .format(DateTimeFormatter.ofPattern(SELENOID_VIDEO_DATETIME_PATTERN))

            val suiteName = context
                .testClass
                .orElseThrow().let {
                    AnnotationUtils.findAnnotation(it, DisplayName::class.java)
                }.orElseThrow {
                    IllegalStateException("Test suite must be annotated with @DisplayName")
                }.value
                .splitToSequence(" ")
                .joinToString("-")

            val resultName = "$ciJobId-$suiteName-$currentTime.mp4" 
            return resultName
    }
    private val selenoidUrl by lazy {
         System.getenv(SELENOID_URL_ENV_PROPERTY_NAME) ?: SELENOID_DEFAULT_SERVICE_URL
    }   
}

MVP реализация собственного WebDriverListener выглядит так:

private val logger = KotlinLogging.logger {}

class CounterListener(private val timer: Stopwatch) : WebDriverListener {

    private val sessionCreated = AtomicBoolean(false)

    override fun afterAnyCall(target: Any?, method: Method?, args: Array<out Any>?, result: Any?) { 
        if (method?.name == LISTENER_TARGET_METHOD_FOR_TRACKING) {
            if (!sessionCreated.get()) {
                logger.warn { "Session start was intercepted, starting counting time" } 
                    if (!timer.isRunning) {
                        timer.start()
                    }
                sessionCreated.set(true) 
            }
        }
    }
    
    override fun afterQuit(driver: WebDriver?) {
        logger.warn { "Resetting time counter due to driver close" } 
        sessionCreated.set(false)
    } 
}

В качестве счетчика времени взял проверенный опытом счетчик от Guava. Считать будем в миллисекундах — пусть и грубо, но для первого варианта реализации подойдёт.

Для проверки доработок написал следующий синтетический тест:

  1. Запустить тестовый класс, в котором все тестовые методы реализованы падающими.

@SelenideTest 
@TestMethodOrder(MethodOrderer.OrderAnnotation::class) 
@DisplayName("A")
class ASuite {

    private val EXPECTED_TITLE = "совсем случайный текст"
    
    @Test
    @Order(1)
    fun aTest() {
        open("https://www.google.com")
        element(By.xpath("//input[@title='Поиск']")).setValue("Проверка синтетического теста1").submit() 
        Assertions.assertEquals(title(), EXPECTED_TITLE)
    }

    @Test
    @Order(2)
    fun bSyntheticFailedTest() {
        open("https://www.google.com")
        element(By.xpath("//input[@title='Поиск']")).setValue("Проверка синтетического теста2").submit()             
        Assertions.assertEquals(title(), EXPECTED_TITLE)
    }


    @Test
    @Order(3)
    fun cTest() {
        open("https://www.google.com")
        element(By.xpath("//input[@title='Поиск']")).setValue("Проверка синтетического теста3").submit()
        Assertions.assertEquals(title(), EXPECTED_TITLE)
    }
}
  1. По логам увидеть метки времени.

  2. Перейти в сформированном видео на секунду падения из лога 3.

После запуска этих тестов логах отобразилось следующее:

[Test worker] INFO com.codeborne.selenide.drivercommands.CreateDriverCommand - Created webdriver in thread 1: RemoteWebDriver -> RemoteWebDriver: chrome on LINUX (a7d95bf7873bfc86345b131ee7e8a3ae)
[Test worker] INFO com.codeborne.selenide.drivercommands.CreateDriverCommand - Add listeners to webdriver: [tech.inno.qa.core.ui.extension.CounterListener@1cdd31a4]
[Test worker] WARN tech.inno.qa.core.ui.extension.CounterListener - Session start was intercepted, starting counting time
[Test worker] ERROR tech.inno.qa.core.ui.extension.SelenideExtension - Test is failed. Test start time in video - 0 ms
[Test worker] ERROR tech.inno.qa.core.ui.extension.SelenideExtension - Test failure is on 1748 ms

expected: <совсем случайный текст> but was: <Проверка синтетического теста1 - Поиск в Google>
Expected :совсем случайный текст
Actual :Проверка синтетического теста1 - Поиск в Google

[Test worker] ERROR tech.inno.qa.core.ui.extension.SelenideExtension - Test is failed. Test start time in video - 2204 ms
[Test worker] ERROR tech.inno.qa.core.ui.extension.SelenideExtension - Test failure is on 5044 ms

expected: <совсем случайный текст> but was: <Проверка синтетического теста2 - Поиск в Google>
Expected :совсем случайный текст
Actual :Проверка синтетического теста2 - Поиск в Google

[Test worker] ERROR tech.inno.qa.core.ui.extension.SelenideExtension - Test is failed. Test start time in video - 5411 ms
[Test worker] ERROR tech.inno.qa.core.ui.extension.SelenideExtension - Test failure is on 7773 ms

expected: <совсем случайный текст> but was: <Проверка синтетического теста3 - Поиск в Google>
Expected :совсем случайный текст

[Test worker] WARN tech.inno.qa.core.ui.extension.SelenideExtension - Test run: 7779 ms
[Test worker] INFO com.codeborne.selenide.drivercommands.CloseDriverCommand - Close webdriver: 1 -> Decorated {RemoteWebDriver: chrome on LINUX (a7d95bf7873bfc86345b131ee7e8a3ae)}...
[Test worker] WARN tech.inno.qa.core.ui.extension.CounterListener - Resetting counter state due to driver close
[Test worker] INFO com.codeborne.selenide.drivercommands.CloseDriverCommand - Closed webdriver 1 in 117 ms

Ради примера откроем видео на моменте ошибки теста N3 — 7 773 мс (7–8 секунды на стандартном HTML5 видеоконтейнере) и убедимся, что там возникает падение:

Минутка заметок

К сожалению, не удалось добиться прецизионной точности во временных метках, потому что весь процесс строится на обёртках вокруг событий. Не удалось найти вменяемого способа зацепиться за событие именно старта видеозаписи, так как этим занимается функция Selenoid. Счётчик может промахнуться на какую-то долю секунды в зависимости от скорости сетевого взаимодействия.

В моём же варианте сильно помогло, что CI раннеры, на которых запускаются автотесты, имеют очень быстрый доступ к Selenoid, так как находятся в одной подсети инфраструктуры.

В целом, результат устраивает, метки времени более-менее точны, поэтому идём дальше.

Как связать метки времени видео с отчётом Allure

Здесь не обошлось без творческой нотки. С ходу предсказуемое решение не шло, но были следующие идеи:

  • возможно, проще отрезать кусочек видео по меткам от основного видео тестового сьюта и приложить его в отчёт в качестве «вложения» для конкретного теста. Как оказалось, не проще, да и не очень хотелось использовать библиотеки по обработке видео на JVM.

  • как вариант, не париться вообще, и отдавать просто ссылку на видео в Allure отчёт для конкретного теста, а также дать потребителям решения директиву смотреть в логи и идти на нужные секунды вручную. Как оказалось, конечные потребители хотели видеть факты прохождения тестов в одном месте, поэтому этот вариант тоже отпал.

Но вскоре до меня дошло, что ничто не мешает использовать в качестве «вложений» кусочки HTML-разметки. Это значило, что удастся:

  1.  управлять поведением конкретно взятого HTML5 элемента (в данном случае это <video/>) на уровне javascript;

  2. писать в этой разметке то, что хочется потребителям.

Вот какой алгоритм родился в голове:

  1. Перед запуском всех тестов в классе сохраняем сгенерированное имя для Selenoid видео в поле нашего Extension.

  2. После падения какого-либо теста генерируем HTML5 код с самописным js, который модифицирует видеоплеер, и открывает видео только на секундах прохождения упавшего теста. Например, из 10-минутного видео показывать всего лишь 30 секунд.

  3. Сгенерированный код сохраняем как «вложение» конкретно взятого теста с помощью AllureLifecycle. Этот класс позволяет безопасно обращаться с данными для отчёта Allure. Для этого разработаем отдельный метод.

Таким образом в Extension нужно всего лишь добавить поведение в реализацию интерфейса TestWatcher, предварительно реализовав функцию генерации улучшенного «вложения» для Allure.

Для доработок видео решил обернуть стандартный видеоконтейнер в плеер Plyr.  Он показался удобной обёрткой над стандартным видеоплеером, и он может пригодиться, если в какой-то момент потребители захотят ещё больше фич для взаимодействия с видеоотчётами.

Как же заставить плеер открыть видео только на нужных секундах? Ответ кроется в старинной спецификации W3C по работе с медиафрагментами. Этим решается проблема — могу на каждом Allure «вложении» открывать одно и то же видео, только на разных метках.

Давайте взглянем на итоговый код:

Финальная версия Extension
class SelenideExtension1 : BeforeAllCallback, AfterAllCallback, BeforeEachCallback, TestWatcher { 

    // Инциализируем переменную для регистрации меток времени
    private val testStartTimeSnapshot = AtomicLong(0)

    // Инициализируем счетчик времени
    private val stopwatch: Stopwatch = Stopwatch.createUnstarted() 
    private lateinit var extensionListener: WebDriverListener


    // Инициализируем переменную для хранения названия видео-файла 
    // Так как все тесты в классе принадлежат одному видео 
    private val currentVideoLink = AtomicReference("")
    
    override fun beforeAll(context: ExtensionContext) {
    
        // Проверяем, что тестовый класс содержит Selenide тесты
        val matchAnnotation = parseSelenideTest(context)
    
        // Настраиваем Selenide конфигурацию только в случае запуска Selenide тестов
        matchAnnotation?.let {
    
            // Рекомендация от разработчиков Selenide - использовать однопоточный режим исполнения тестов
            checkSingleThreadExecution(context)
    
            val capabilities = DesiredCapabilities()

            // Здесь могут идти дополнительные настройки самого браузера

            // Настраиваем, если нужно запуск через Selenoid
            if (checkSelenoidSupport()) {
            Configuration.remote = "$selenoidUrl/wd/hub" 
            Configuration.driverManagerEnabled = false
            // Настраиваем, если нужно, и опцию видеозаписи с уникальным именем для видео 
            // на основе метки имени тествого класса, данных CI/Local режима запуска,
            // и UTC времени для удобства поиска в Selenoid 
            capabilities.setCapability("selenoid:options", prepareSelenoidOptions(context))
            }

            // Полностью готовые capabilities пробрасываются в конфигурацию
            Configuration.browserCapabilities = capabilities

            // Регистрируем listener только при поднятом флаге необходимости записи видео
            if (checkSelenoidVideoSupport()) { 
                extensionListener = CounterListener(stopwatch) 
                WebDriverRunner.addListener(extensionListener)
            } 
        }
    }

    override fun beforeEach(context: ExtensionContext) {

        // Проверяем, что тестовый класс содержит Selenide тесты
        val matchAnnotation = parseSelenideTest(context)

        matchAnnotation?.let {

            // Здесь, если нужно, чистим состояние браузера (cookies, localstorage, etc..)

            // Фиксируем время старта теста только при включенной опции записи видео
            if (checkSelenoidVideoSupport()) { 
                testStartTimeSnapshot.set(stopwatch.elapsed(TimeUnit.MILLISECONDS))
            } 
        }
    }

    override fun testFailed(context: ExtensionContext, cause: Throwable?) {

        // Проверяем, что тестовый класс содержит Selenide тесты
        val matchAnnotation = parseSelenideTest(context)

        // Логируем время старта теста и время падения по отношению к таймлайну видео
        // только для Selenide тестов, и при включенной опции записи видео
        // Готовим вложение с метками времени в случае падения теста
        matchAnnotation?.let {
            
            if (checkSelenoidVideoSupport()) {
                val testStartTime = testStartTimeSnapshot.get()
                logger.error { "Test is failed. Test start time in video - ${testStartTime} ms" } 
                stopwatch.elapsed(TimeUnit.MILLISECONDS).let {
                    logger.error { "Test failure is on $it ms" }
                    prepareAllureVideoAttachment(testStartTime, it)

                } 
            }
        }
    }

    override fun afterAll(context: ExtensionContext) {

        // Проверяем, что тестовый класс содержит Selenide тесты
        val matchAnnotation = parseSelenideTest(context)

        // Закрываем сессию только для Selenide тестов,
        // отзываем listener, и также логируем общее время видеозаписи
        matchAnnotation?.let {
            if (checkSelenoidVideoSupport()) {
                logger.warn { "Test run: ${stopwatch.elapsed(TimeUnit.MILLISECONDS)} ms" } 
                stopwatch.reset()
            }
            if (WebDriverRunner.hasWebDriverStarted()) {
                WebDriverRunner.closeWebDriver()
            }
            if (this::extensionListener.isInitialized) { 
                WebDriverRunner.removeListener(extensionListener)
            } 
        }
    }

    private val selenoidUrl by lazy {
        System.getenv(SELENOID_URL_ENV_PROPERTY_NAME) ?: SELENOID_DEFAULT_SERVICE_URL
    }
    
    private fun parseSelenideTest(ctx: ExtensionContext): SelenideTest? {
        return ctx.testClass.orElseThrow { -> IllegalStateException("Something wrong with context. Test must contain ancestor (test class)") }
            .let { AnnotationUtils.findAnnotation(it, SelenideTest::class.java) }.orElse(null)
    }

    private fun checkSingleThreadExecution(context: ExtensionContext) { 
        context.getConfigurationParameter("junit.jupiter.execution.parallel.enabled")?.ifPresent {
            if (it.toBoolean()) throw IllegalStateException("This extension works only with single thread mode")
            }
    }


    private fun checkSelenoidSupport(): Boolean {
        return when (System.getenv(SELENOID_SUPPORT_ENV_PROPERTY_NAME)) {
            "1" -> true
            else -> false 
        }
    }

    private fun checkSelenoidVideoSupport(): Boolean {
        return when (System.getenv(SELENOID_VIDEO_SUPPORT_ENV_PROPERTY_NAME)) {
            "1" -> true 
            else -> false
            } 
    }

    private fun checkScreenResolution(): String {
            val resolution = System.getenv(SELENOID_DEFAULT_SCREEN_RESOLUTION_ENV_PROPERTY_NAME) ?: "normal" 
            return try {
                ScreenResolution.valueOf(resolution.uppercase()).value 
            } catch (e: IllegalArgumentException) {
                ScreenResolution.NORMAL.value
            }
    }

    private fun prepareSelenoidOptions(context: ExtensionContext): Map<String, Any> { 
        val selenoidOptions = mutableMapOf<String, Any>(
            "enableVNC" to true, "enableLog" to true 
        )
        if (checkSelenoidVideoSupport()) { 
            selenoidOptions["enableVideo"] = true 
            selenoidOptions["videoName"] = generateVideoName(context) 
            selenoidOptions["videoFrameRate"] to 24 
            selenoidOptions["screenResolution"] = checkScreenResolution()
        }
    return selenoidOptions 
    }

    private fun generateVideoName(context: ExtensionContext): String {

        val ciJobId = System.getenv(GITLAB_JOB_ID_ENV_PROPERTY_NAME) ?: "Local"

        val currentTime = ZonedDateTime.now(Clock.systemUTC()).format(DateTimeFormatter.ofPattern(SELENOID_VIDEO_DATETIME_PATTERN))
        
        val suiteName = context.testClass.orElseThrow().let { 
            AnnotationUtils.findAnnotation(it, DisplayName::class.java)
        }.orElseThrow {
            IllegalStateException("Test suite must be annotated with @DisplayName")
        }.value.splitToSequence(" ").joinToString("-")

        val resultName = "$ciJobId-$suiteName-$currentTime.mp4"
        // Сохраняем имя файла
        currentVideoLink.set(resultName)

        return resultName 
    }

    private fun generateVideoLink(): String {
        return "$selenoidUrl/video/${currentVideoLink.get()}"
    }

    private fun prepareAllureVideoAttachment(startTime: Long, failTime: Long) { 
        val lifecycle = Allure.getLifecycle()
        val startTimeFormatted = DurationFormatUtils.formatDurationHMS(startTime) 
        val failTimeFormatted = DurationFormatUtils.formatDurationHMS(failTime)

        lifecycle.currentTestCase.ifPresent {
            lifecycle.addAttachment(
                "Recorded video", "text/html", ".html", """
                    <html>
                        <head>
                            <script src="https://cdn.plyr.io/3.7.2/plyr.js"></script>
                            <link rel="stylesheet" href="https://cdn.plyr.io/3.7.2/plyr.css" />
                        </head>
                        <body>
                            <p>Время прохождения теста: $startTimeFormatted - $failTimeFormatted</p>
                            <video controls playsinline id="player" onpause="this.load()">
                                <source src='${generateVideoLink()}#t=${startTimeFormatted},${failTimeFormatted}' type='video/mp4'>
                            </video>
                            <script>
                                const player = new Plyr(window.document.getElementById('player'), { 
                                    title: 'Selenoid Player',
                                    invertTime: false
                                 });
                            </script>
                         </body>
                     </html>
                """.trimIndent().toByteArray()
            )
        }
    }
}

Для проверки работоспособности нужно будет:

  • запустить Selenide тесты. Нам хватит того, что было описано в предыдущем пункте статьи;

  • взглянуть на сформированные временные метки;

  • сформировать Allure отчёт — локально или удалённо в Allure TestOPS;

  • убедиться, что метки времени в логах соответствуют фрагменту видео с упавшим тестом в Allure отчёте.

После запуска тестов нам достаточно посмотреть на отрывок лога любого упавшего теста:

[Test worker] ERROR tech.inno.qa.core.ui.extension.SelenideExtension - Test is failed. Test start time in video - 2179 ms
[Test worker] ERROR tech.inno.qa.core.ui.extension.SelenideExtension - Test failure is on 4291 ms

expected: <совсем случайный текст> but was: <Проверка синтетического теста2 - Поиск в Google>
Expected :совсем случайный текст
Actual   :Проверка синтетического теста2 - Поиск в Google

И взглянем на упавший тест в TestOPS:

Всё работает!
Всё работает!

Видео открывается на конкретном временном диапазоне, а после того как он завершился, отматывает время на начало упавшего теста. Вуаля!

Красота спасёт автотесты

Функциональное и удобное решение улучшает процесс отчётности UI-автотестов. Да, оно собрано на коленке, но позволяет эффективно решать поставленную задачу. А ещё лишний раз напоминает нам о возможности автоматизации и кастомизации процессов при должном упорстве и желании.

Сейчас MVP дорабатывается. Есть идеи сделать видеоотчёты ещё приятней, реализовав текстовое описание идущего теста на видео тестового сьюта через субтитры, но об этом как-нибудь в другой раз.

Советы по оптимизации, а также вопросы по реализации принимаются в комментариях.

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