
Привет Хабр! Меня зовут Станислав, и я 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 для красивых отчётов
Надеюсь, мой опыт окажется полезным для ваших проектов! Буду рад обсуждению и предложениям по улучшению подхода.