Привет Хабр! Меня зовут Станислав, и я team lead QA одной из продуктовых команд.

Сегодня я хочу поделиться своими изысканиями в области автоматизированного тестирования UI.

Представим классический продукт, состоящий из бэкенда (Java/Kotlin) и фронтенда, который включает Web (TypeScript) и Mobile (Swift/Kotlin). Как видим, каждый слой имеет свой стек и может содержать (что было бы прекрасно), а может и не содержать (что крайне печально) unit-тесты.

Но не едиными unit-тестами живём. Мы, как высококвалифицированные инженеры, понимаем: тестирование отдельных кирпичиков ещё не означает, что стена из них будет стоять надёжно. Для пущей уверенности нам нужны тесты более высокого уровня — интеграционные тесты для бэкенда, проверяющие собранные сервисы и интеграции, и e2e-тесты для UI (web, mobile).

Я глубоко убеждён, что правильным решением будет выбрать один язык и на нём создавать платформу для тестирования как бэкенда, так и фронтенда. Это позволит переиспользовать модули, инструменты и стандартизировать автотесты в целом. В моём случае был выбран Kotlin. Причин тому много, но вот основные:

  1. Kotlin имеет множество библиотек для работы с API и БД

  2. Null-безопасность и контроль мутабельности страхуют от типичных ошибок, которые могут допускать начинающие инженеры по тестированию

  3. Наш любимый JUnit 5 во всей красе

  4. Один язык позволяет покрыть все стеки: бэкенд — легко, mobile — синонимы со словом Kotlin, Web — как оказалось, тоже очень даже ничего, о чём и пойдёт речь в этой статье

  5. Причина, вытекающая из предыдущего пункта: команде тестирования достаточно хорошо разобраться в одном языке, чтобы качественно покрывать тестами все слои продукта. Причём с количеством написанных тестов приходит и мастерство

Думаю, список можно было бы продолжать, но я остановлюсь, чтобы перейти к основной части статьи, а именно — Web-тестам и моей интерпретации PageObject.

Что нам понадобится:

  • Его величество Kotlin

  • Ставший лидером на рынке web-тестов Playwright

  • Ваш покорный слуга JUnit 5

  • Тестовый сайт, на котором будем оттачивать наше мастерство: https://practicesoftwaretesting.com/

Исходники проекта можно найти в моём GitHub.

Поехали!

Архитектура тестового фреймворка

В основе концепции лежит следующее представление: есть веб-страница, она состоит из блоков. Блоки, в свою очередь, состоят из компонентов. Компоненты могут быть простыми — кнопка, текст, ссылка, так и составными.

Создание проекта

Для начала создадим новый проект:

  1. File → New → Project

  2. Выбираем Kotlin

  3. В поле Name вводим название нашего проекта (в моём случае — web-test)

  4. Build system выбираем Gradle

  5. Gradle DSL выбираем Kotlin

Нажимаем Create и ждём создания проекта.

Загрузим необходимые зависимости. В файл build.gradle.kts добавляем:

dependencies {
    testImplementation("com.microsoft.playwright:playwright:1.56.0")
    testImplementation(platform("org.junit:junit-bom:6.0.0"))
    testImplementation("org.junit.jupiter:junit-jupiter")
    testImplementation("org.junit.jupiter:junit-jupiter-params")
    testImplementation("org.assertj:assertj-core:3.27.6")
}

Я сознательно не стал добавлять в демо-проект Allure и логирование, чтобы не усложнять статью.

Компоненты

Начинаем создавать необходимые элементы для наших будущих страниц. Начнём с компонентов. Создадим пакет component в src/test/kotlin.

Абстракция компонента src/test/kotlin/component/Component.kt:

package component

import com.microsoft.playwright.Locator

abstract class Component {

    protected abstract val root: Locator
    abstract val name: String

    fun <T> handle(block: Locator.() -> T): T {
        return block.invoke(root)
    }
}

Метод handle позволяет использовать всё многообразие методов Playwright в компонентах без дополнительных обёрток.

Реализации компонентов:

class TextComponent(override val root: Locator, override val name: String = "Text component") : Component() {
    val text: String? 
      get() = root.waitForElements().getOrNull()?.textContent()
}

class InputComponent(override val root: Locator, override val name: String = "Input component") : Component() {
    val placeholder: String? 
      get() = root.waitForElements().getOrNull()?.getAttribute("placeholder")
    val type: String? 
      get() = root.waitForElements().getOrNull()?.getAttribute("type")
    val valueAttr: String? 
      get() = root.waitForElements().getOrNull()?.getAttribute("value")
    val nameAttr: String? 
      get() = root.waitForElements().getOrNull()?.getAttribute("name")
}

class LinkComponent(override val root: Locator, override val name: String = "Link component") : Component() {
    val text: String? 
      get() = root.waitForElements().getOrNull()?.textContent()
    val href: String? 
      get() = root.waitForElements().getOrNull()?.getAttribute("href")
    val target: String? 
      get() = root.waitForElements().getOrNull()?.getAttribute("target")
}

class ButtonComponent(override val root: Locator, override val name: String = "Button component") : Component() {
    val text: String? 
      get() = root.waitForElements().getOrNull()?.textContent()
}
// Аналогичные реализации создаются и для других компонентов — checkbox, selector и т.д.

Пояснение: Для Locator написана функция-расширение, которая перед получением элемента ждёт его отображения на странице. Это исключает падения тестов из-за попыток взаимодействия с незагруженными элементами.

Функция-расширение в src/test/kotlin/extension/LocatorExtension.kt:

package extension

import com.microsoft.playwright.Locator
import com.microsoft.playwright.options.WaitForSelectorState
import kotlin.runCatching
import kotlin.time.Duration
import kotlin.time.Duration.Companion.seconds

fun Locator.waitForElements(timeout: Duration = 10.seconds): Result<Locator> = runCatching {
    this.first().waitFor(
        Locator.WaitForOptions()
            .setState(WaitForSelectorState.VISIBLE)
            .setTimeout(timeout.inWholeMilliseconds.toDouble())
    )
    this
}

Для поддержки повторяющихся компонентов создаём IterableComponent.kt:

package component

import com.microsoft.playwright.Locator
import extension.waitForElements

class IterableComponent<T : Component>(
    private val root: Locator,
    private val factory: (Locator) -> T
) : Iterable<T> {

    override fun iterator(): Iterator<T> =
        root.waitForElements().getOrThrow().all().map { factory(it) }.iterator()

    fun findByText(text: String, extractor: (T) -> String?): T? =
        this.firstOrNull { extractor(it) == text }
}

Блоки

Переходим к блокам. Создаём пакет src/test/kotlin/block.

Абстракция блока src/test/kotlin/block/PageBlock.kt:

package block

import com.microsoft.playwright.Page

interface PageBlock {
    val page: Page
}

Страницы

Создаём абстракцию страницы src/test/kotlin/page/WebPage.kt:

package page

import block.PageBlock

abstract class WebPage<T : PageBlock> {
    abstract val content: T
    abstract fun navigate(): WebPage<T>
}

Свойство content отвечает за содержимое страницы, а метод navigate — за навигацию к этой странице.

Настало время посмотреть на нашу страницу, которую мы будем тестировать.

Я выделил два элемента, которые мы и будем проверять — меню и контентная часть. Проверить остальные элементы не составит труда по аналогии, и если кому-то будет интересно попробовать это самостоятельно, можно смело форкать проект из GitHub.

Поскольку меню и футер одинаковы для всех страниц сайта, создаём абстракцию src/test/kotlin/page/ShopPage.kt:

package page

import block.FooterBlock
import block.HeaderBlock
import block.PageBlock
import com.microsoft.playwright.Page

abstract class ShopPage<T : PageBlock>(page: Page): WebPage<T>() {
    val header = HeaderBlock(page)
    val footer = FooterBlock(page)
}

Реализации блоков:

HeaderBlock.kt:

package block

import com.microsoft.playwright.Page
import component.IterableComponent
import component.LinkComponent

class HeaderBlock(override val page: Page) : PageBlock {
    private val menu = IterableComponent(page.locator(".nav-link")) { el ->
        LinkComponent(el, name = "Shop menu item")
    }

    fun getMenu(): List<String> = menu.map { it.text.orEmpty() }
}

ContentBlock.kt:

package block

import com.microsoft.playwright.Locator
import com.microsoft.playwright.Page
import component.Component
import component.IterableComponent
import component.ImageComponent
import component.TextComponent

class ContentBlock(override val page: Page) : PageBlock {
    private val productCards = IterableComponent(page.locator("a.card")) { el ->
        object: Component() {
            override val root: Locator = el
            override val name: String = "Product card"

            val img = ImageComponent(root.locator(".card-img-wrapper img"), "Image")
            val title = TextComponent(root.locator(".card-body h5"), "Title")
            val co2Rating = IterableComponent(root.locator(".co2-rating-scale span")) { el ->
                TextComponent(el, "CO2 rating")
            }
            val price = TextComponent(root.locator("[data-test='product-price']"), "Price")
        }
    }

    fun getProductImg(title: String): String = 
        productCards.findByText(title) { it.title.text }?.img?.src.orEmpty()

    fun getProductCo2Ratings(title: String): List<String> = 
        productCards.findByText(title) { it.title.text }?.co2Rating?.map { it.text.orEmpty() } ?: emptyList()

    fun getProductPrice(title: String): String = 
        productCards.findByText(title) { it.title.text }?.price?.text.orEmpty()
}

FooterBlock.kt:

package block

import com.microsoft.playwright.Page
import component.TextComponent

class FooterBlock(override val page: Page) : PageBlock {
    private val info = TextComponent(page.locator("app-footer p"))

    fun getInfo(): String = info.text.orEmpty()
}

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

Фабрика страниц

Чтобы не создавать отдельные классы для конкретных страниц, спроектируем фабрику, которая будет создавать экземпляры ShopPage на основе передаваемой контентной части.

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

Скрытый текст

Примечание: если в вашем проекте только один сайт, можно упростить архитектуру и создавать сразу фабрику для конкретных страниц.

package page

import block.PageBlock
import com.microsoft.playwright.Page

interface PageFactory {
    fun <T : PageBlock> create(content: T, navigation: (Page) -> Unit = {}): WebPage<T>
}

Теперь создадим фабрику для страниц нашего тестируемого магазина:

package page

import block.PageBlock
import com.microsoft.playwright.Page

interface PageFactory {
    fun <T : PageBlock> create(content: T, navigation: (Page) -> Unit = {}): WebPage<T>
}

class ShopPageFactory(private val page: Page): PageFactory {
    override fun <T : PageBlock> create(content: T, navigation: (Page) -> Unit): ShopPage<T> =
        object : ShopPage<T>(page) {
            override val content: T = content
            override fun navigate(): ShopPage<T> {
                navigation.invoke(page)
                return this
            }
        }
}

Конфигурация Playwright

Прежде чем переходить к написанию тестов, создадим базовую конфигурацию для Playwright. Я не буду подробно останавливаться на всех возможностях настройки Playwright — с этим можно ознакомиться в официальной документации.

package config

import com.microsoft.playwright.Browser
import com.microsoft.playwright.BrowserType
import com.microsoft.playwright.junit.Options
import com.microsoft.playwright.junit.OptionsFactory

class PlaywrightConfig : OptionsFactory {
    override fun getOptions(): Options {
        return Options()
            .setLaunchOptions(
                BrowserType.LaunchOptions()
                    .setHeadless(false)
            )
            .setContextOptions(
                Browser.NewContextOptions().setBaseURL("https://practicesoftwaretesting.com/")
            )
            .setBrowserName("chromium")
    }
}

Тесты

package test

import block.ContentBlock
import com.microsoft.playwright.BrowserContext
import com.microsoft.playwright.Page
import com.microsoft.playwright.junit.UsePlaywright
import config.PlaywrightConfig
import org.assertj.core.api.Assertions.assertThat
import org.junit.jupiter.api.AfterEach
import org.junit.jupiter.api.BeforeEach
import org.junit.jupiter.api.Test
import org.junit.jupiter.api.TestInstance
import page.ShopPage
import page.ShopPageFactory

@TestInstance(TestInstance.Lifecycle.PER_CLASS)
@UsePlaywright(PlaywrightConfig::class)
class ShopTest {

    private lateinit var webPage: ShopPage<ContentBlock>

    @BeforeEach
    fun setup(page: Page) {
        webPage = ShopPageFactory(page).create(ContentBlock(page)) { page.navigate("/") }
    }

    @AfterEach
    fun tearDown(page: Page, context: BrowserContext) {
        context.close()
        page.close()
    }

    @Test
    fun `should show menu in header`() {
        webPage.navigate()
        val actualMenu = webPage.header.getMenu()
        assertThat(actualMenu).isEqualTo(listOf("Home", " Categories ", "Contact", "Sign in", " EN "))
    }

    @Test
    fun `should show 'Pliers' in product list`() {
        webPage.navigate()
        val actualPliersImg = webPage.content.getProductImg("Pliers")
        val actualPliersPrice = webPage.content.getProductPrice("Pliers")
        val actualPliersCo2Ratings = webPage.content.getProductCo2Ratings("Pliers")
        
        assertThat(actualPliersImg).isEqualTo("assets/img/products/pliers02.avif")
        assertThat(actualPliersPrice).isEqualTo("$12.01")
        assertThat(actualPliersCo2Ratings).isEqualTo(listOf("A", "B", "C", "D", "E"))
    }
}

Заключение

Представленный подход к организации автоматизированного тестирования UI демонстрирует несколько ключевых преимуществ:

  • Универсальность Kotlin: Мы успешно применили один язык для всего стека тестирования, что подтверждает гибкость Kotlin не только для бэкенда и мобильной разработки, но и для Web UI-тестирования.

  • Модульная архитектура: Разработанная структура "Компоненты → Блоки → Страницы" обеспечивает отличную поддерживаемость и переиспользование кода. Добавление новых тестов или изменение существующих элементов теперь требует минимальных усилий.

  • Гибкость фреймворка: Фабричный подход к созданию страниц позволяет легко масштабировать тестовое покрытие и адаптировать фреймворк под различные сценарии тестирования.

На практике этот подход показал себя как эффективное решение, позволяя значительно ускорить разработку автотестов при сохранении высокого качества кодовой базы.

Для дальнейшего развития фреймворка можно рассмотреть добавление:

  • Более гибкой конфигурации Playwright

  • Интеграции с Allure для красивых отчётов

Надеюсь, мой опыт окажется полезным для ваших проектов! Буду рад обсуждению и предложениям по улучшению подхода.

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


  1. frostsumonner
    28.10.2025 17:45

    У вас е2е тесты встроены в pipeline? Если да, то какие тесты запускаются синхронно, какие асинхронно? Если тест падает, потому что функциональность обновилась, то кто его правит? Как на это планируется время?

    Вот это и подобное было бы суперинтересно...


    1. stasbykov Автор
      28.10.2025 17:45

      Да, все тесты встроены в pipeline, но в отличие от unit-тестов, они не являются блокирующими. После прохождения тестов, формируется подробный allure-отчет по которому любой инженер QA, даже не автоматизатор может с легкостью определить причину падения и в дополнение идет отправка уведомления в корп. мессенджер, где подсвечивается общее число тестов, число успешных и упавших тестов. Соответсвенно, если в этом сообщении все тесты успешно прошли, то смотреть отчет не требуется. Если есть упавшие, то любой из свободных инженеров (не дежурный на регрессе) идет смотреть отчет. Дальше уже по ситуации. Если поменялся функционал, а тесты не поменялись, то требуется их доработать и это идет отдельной тех.долговой задачей. Тут к сожалению на ручном приводе. В остальных случаях падения отталкиваемся уже от ситуации. Может быть проблема с инфрой. В этом случае тест можно повторно запустить вручную и убедиться, что проблема уже устранена(особенно, если жалоб с регресса не поступало). Если проблема с функционалом, то заливается фикс, после чего история с автозапуском в пайпе повторяется.

      Что касается распараллеливания - тут используются встроенные средства junit, ничего кастомного нет, но важно понимать это при создании архитектуры проекта и писать код, который не будет конфликтовать в многопоточном исполнении и придерживаться атомарности каждого теста. Нельзя использовать общие тестовые данные. Для каждого теста свои тестовые данные с созданием и удалением после теста.

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


  1. v-ctor
    28.10.2025 17:45

    Как вы вовремя. Как раз за подобную задачу взялся и стек тот же. Было бы интересно осветить несколько доп моментов:
    1) на сколько я знаю, есть практика сравнения сразу снимками экрана по эталону. Вы используете такое? Если да, то насколько это удобно/практично? Можно ли как-то отсечь какие-то динамические куски (скажем баннер крутится)?
    2) Так же вроде можно DOM/HTML сравнивать. Насколько это рабочая тема? Или все тут же сломается из-за имен стилей и т.п.?
    3) Хорошо бы всё же Allur тут видеть
    4) DSL нам тут не поможет? Для меня Kotlin это конечно вот все что вы перечислили, но еще и выразительный DSL. А тут прям так руки и чешутся описывать структуру страницы через свой DSL что бы можно было писать типобезопасные тесты особенно в ситуации если блоки не имеют id/name/уникальных имен классов/стилей. Как к ним навигироваться? Ну и просто люди далекие от кода могли выражать тест примерно перенося картинку в текст теста.
    Или структура сайта/страниц меняется чаще и поддерживать DSL в актуальном состоянии все замучаются?


    1. stasbykov Автор
      28.10.2025 17:45

      Добрый день.

      1. Да, сравнение по скриншотам вполне хорошая практика и playwright очень хорошо справляется с этим из коробки. Если нужно убрать какую-то часть, то можно использовать маски. При использовании маски вы можете передать локатор (css класс, id или любой другой атрибут) динамического элемента и он не будет включен в проверку. Однако стоит понимать, когда нужно использовать такие тесты и не включать их всегда и везде. Если у вас есть какие-то лендинги , какой-то визуальный контент, который достаточно критичен для пользователя и поехавшая верстка может нести существенный репутационный риск, то конечно такие тесты обязательны в общем скоупе. С другой стороны, если вы работаете с админкой или каким-то внутренним продуктом, где есть некоторая лояльность конечного пользователя, то таких тестов может быть не много или не быть совсем, т.к. главное, что бы элементы были видимы, кликабельны, активны и т.д., что можно проверить и без использования скриншот тестов. Я несколько подробно остановился на этом вопросе, потому что тесты со скриншотами подразумевают их подготовку, поддержку и не исключают флаки по магическим причинам, зависящим в том числе от фазы луны)

      2. По поводу сравнения DOM, тут может быть много мнений, но конкретно мое - отрицательное. HTML и css очень непостоянная вещь в мире разработки и в динамично развивающемся продукте меняется очень часто (название классов, вложенность элементов, табличная верстка, а через месяц верстка на блоках и тд тп). Поэтому я бы такое не рекомендовал, но опять же, это связано с отсутствием у меня положительного опыта в этом.

      3. С allure возможно дойдут руки и я напишу отдельную статью, где будет и это. Но если кратко, то в описанном подходе не требуется отдельной прослойки в виде степов. У нас есть публичные методы внутри блоков, которые взаимодействуют с компонентами и вот их можно и нужно помечать аннотацией @Step. В этом случае в отчете allure будет прозрачно видно какое действие и в какой последовательности вызывалось. В самих тестах мы расставляем аннотации @Epic, @Story, что позволяет категоризовать тесты.

      4. Да, DSL безусловно мощный инструмент в котлине, которым грех не воспользоваться. И на одной из итераций я пошел этим путем, что бы можно было работать в стиле page { block { text {} input {} } } Но в какой-то момент поймал себя на мысли, что ухожу от главной задачи фрэймворка тестирования в создание движка по генерации страниц и решил от этого отказаться. В тестах я использую fluent синтаксис, например page .navigation() .content .getTitle() И этого вполне достаточно для обеспечения выразительности кода, как мне кажется)) Надеюсь смог ответить на все вопросы.


      1. v-ctor
        28.10.2025 17:45

        Да, спасибо.