
Привет Хабр! Меня зовут Станислав, и я team lead QA одной из продуктовых команд.
Сегодня я хочу поделиться своими изысканиями в области автоматизированного тестирования UI.
Представим классический продукт, состоящий из бэкенда (Java/Kotlin) и фронтенда, который включает Web (TypeScript) и Mobile (Swift/Kotlin). Как видим, каждый слой имеет свой стек и может содержать (что было бы прекрасно), а может и не содержать (что крайне печально) unit-тесты.
Но не едиными unit-тестами живём. Мы, как высококвалифицированные инженеры, понимаем: тестирование отдельных кирпичиков ещё не означает, что стена из них будет стоять надёжно. Для пущей уверенности нам нужны тесты более высокого уровня — интеграционные тесты для бэкенда, проверяющие собранные сервисы и интеграции, и e2e-тесты для UI (web, mobile).
Я глубоко убеждён, что правильным решением будет выбрать один язык и на нём создавать платформу для тестирования как бэкенда, так и фронтенда. Это позволит переиспользовать модули, инструменты и стандартизировать автотесты в целом. В моём случае был выбран Kotlin. Причин тому много, но вот основные:
Kotlin имеет множество библиотек для работы с API и БД
Null-безопасность и контроль мутабельности страхуют от типичных ошибок, которые могут допускать начинающие инженеры по тестированию
Наш любимый JUnit 5 во всей красе
Один язык позволяет покрыть все стеки: бэкенд — легко, mobile — синонимы со словом Kotlin, Web — как оказалось, тоже очень даже ничего, о чём и пойдёт речь в этой статье
Причина, вытекающая из предыдущего пункта: команде тестирования достаточно хорошо разобраться в одном языке, чтобы качественно покрывать тестами все слои продукта. Причём с количеством напис��нных тестов приходит и мастерство
Думаю, список можно было бы продолжать, но я остановлюсь, чтобы перейти к основной части статьи, а именно — Web-тестам и моей интерпретации PageObject.
Что нам понадобится:
Его величество Kotlin
Ставший лидером на рынке web-тестов Playwright
Ваш покорный слуга JUnit 5
Тестовый сайт, на котором будем оттачивать наше мастерство: https://practicesoftwaretesting.com/
Исходники проекта можно найти в моём GitHub.
Поехали!
Архитектура тестового фреймворка
В основе концепции лежит следующее представление: есть веб-страница, она состоит из блоков. Блоки, в свою очередь, состоят из компонентов. Компоненты могут быть простыми — кнопка, текст, ссылка, так и составными.
Создание проекта
Для начала создадим новый проект:
File → New → ProjectВыбираем
KotlinВ поле
Nameвводим название нашего проекта (в моём случае —web-test)Build systemвыбираемGradleGradle 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 для красивых отчётов
Надеюсь, мой опыт окажется полезным для ваших проектов! Буду рад обсуждению и предложениям по улучшению подхода.
