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

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

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