Привет Хабр! Меня зовут Станислав, и я 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 для красивых отчётов

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