Вступление

Устал смотреть на то, как многие QA Automation пишут свои абсолютно костыльные решения, используя паттерны Page Object, Page Factory. Так происходит, потому что в сфере QA Automation нет каких-то определенных рамок и паттернов, по которым стоит писать авто тесты. Да, есть всеми известный Page Object, но даже его часто используют очень криво. Например, в бэкенд разработке есть много паттернов, один из них MVC, который четко говорит, куда складывать роутинг, куда модели, а куда бизнес логику. Но в автоматизации нет каких-то конкретных паттернов, которые скажут, куда писать allure.step, куда писать проверки, как динамически форматировать локатор. Отсюда возникают мнения, и каждое якобы правильное, каждый лучше знает, как лучше, но на самом деле нет. Возникают множество "правильных" решений, но только по мнению создателя этих решений.

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

Requirements

Для примера написания UI авто тестов мы будем использовать:

  • pytest - pip install pytest

  • playwright - pip install pytest-playwright

  • allure - pip install allure-pytest, не обязательная зависимость, вы можете использовать любой другой репортер

Почему не Selenium? Скорее всего, потому что playwright удобнее и современнее. У playwright есть много крутых фич, с которыми он рвет Selenium в пух, ниже разберемся, как и почему. Все, что будет описано ниже с использованием playwright, также применимо к любому другому фреймворку.

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

Во всех примерах ниже мы будем использовать синхронный API для playwright.

Авто тесты будем писать на эту страницу https://playwright.dev/. Сам тест кейс будет простой, но он будет показывать всю концепцию правильной работы с Page Object, Page Factory.

Тест кейс:

  1. Открываем страницу https://playwright.dev

  2. Нажимаем на поиск

  3. Проверяем, что модальное окно поиска успешно открылось

  4. Вводим в поиск язык, в нашем случае будет python

  5. Выбираем из результатов первый

  6. Проверяем, что страница с Python открылась

Отмечу, что локаторы в примерах ниже не являются эталонными, а сайт для тестирования, это документация playwright, на фронтенд которой я никак не могу повлиять. В ваших проектах советую использовать кастомные data-qa-id, которые вы можете поставить в фронтенд приложении React/Vue/Angular, ну или попросить разработчиков сделать это.

Base Page

По сути Base Page - это основная страница, которая не описывает какую-то конкретную страницу или компонент. Сама по себе Base Page не должна использоваться в тестах, от нее мы наследуем наши страницы или компоненты.

import allure
from playwright.sync_api import Page, Response

from components.navigation.navbar import Navbar


class BasePage:
    def __init__(self, page: Page) -> None:
        self.page = page
        self.navbar = Navbar(page)

    def visit(self, url: str) -> Response | None:
        with allure.step(f'Opening the url "{url}"'):
            return self.page.goto(url, wait_until='networkidle')

    def reload(self) -> Response | None:
        with allure.step(f'Reloading page with url "{self.page.url}"'):
            return self.page.reload(wait_until='domcontentloaded')

Внутри BasePage описываем базовые методы. Это лишь образец того, как можно делать BasePage

Page Factory

Теперь самое интересное. Мы определим несколько базовых компонентов, для реализации работы паттерна.

Базовый Component. В python нет интерфейсов и понятия имплементации, поэтому сделаем Component абстрактным классом и унаследуем его от ABC

from abc import ABC, abstractmethod

import allure
from playwright.sync_api import Locator, Page, expect


class Component(ABC):
    def __init__(self, page: Page, locator: str, name: str) -> None:
        self.page = page
        self.name = name
        self.locator = locator

    @property
    @abstractmethod
    def type_of(self) -> str:
        return 'component'

    def get_locator(self, **kwargs) -> Locator:
        locator = self.locator.format(**kwargs)
        return self.page.locator(locator)

    def click(self, **kwargs) -> None:
        with allure.step(f'Clicking {self.type_of} with name "{self.name}"'):
            locator = self.get_locator(**kwargs)
            locator.click()

    def should_be_visible(self, **kwargs) -> None:
        with allure.step(f'Checking that {self.type_of} "{self.name}" is visible'):
            locator = self.get_locator(**kwargs)
            expect(locator).to_be_visible()

    def should_have_text(self, text: str, **kwargs) -> None:
        with allure.step(f'Checking that {self.type_of} "{self.name}" has text "{text}"'):
            locator = self.get_locator(**kwargs)
            expect(locator).to_have_text(text)

Выше приведена очень упрощенная реализация Component. В своем проекте, вы сможете добавить больше методов, больше настроек к ним, заменить allure.steps на другие.

Давайте сделаем еще несколько компонентов

Button - кнопка. В данном компоненте будут базовые методы для работы с кнопками.

import allure

from page_factory.component import Component


class Button(Component):
    @property
    def type_of(self) -> str:
        return 'button'

    def hover(self, **kwargs) -> None:
        with allure.step(f'Hovering over {self.type_of} with name "{self.name}"'):
            locator = self.get_locator(**kwargs)
            locator.hover()

    def double_click(self, **kwargs):
        with allure.step(f'Double clicking {self.type_of} with name "{self.name}"'):
            locator = self.get_locator(**kwargs)
            locator.dblclick()

Input - поле ввода. В данном компоненте будут базовые методы для работы с инпутами.

import allure
from playwright.sync_api import expect

from page_factory.component import Component


class Input(Component):
    @property
    def type_of(self) -> str:
        return 'input'

    def fill(self, value: str, validate_value=False, **kwargs):
        with allure.step(f'Fill {self.type_of} "{self.name}" to value "{value}"'):
            locator = self.get_locator(**kwargs)
            locator.fill(value)

            if validate_value:
                self.should_have_value(value, **kwargs)

    def should_have_value(self, value: str, **kwargs):
        with allure.step(f'Checking that {self.type_of} "{self.name}" has a value "{value}"'):
            locator = self.get_locator(**kwargs)
            expect(locator).to_have_value(value)

Link - поле ввода. В данном компоненте будут базовые методы для работы со ссылками.

from page_factory.component import Component


class Link(Component):
    @property
    def type_of(self) -> str:
        return 'link'

ListItem - любой элемент списка.

from page_factory.component import Component


class ListItem(Component):
    @property
    def type_of(self) -> str:
        return 'list item'

Title - заголовок. Можно использовать просто Text, но я предпочитаю разделать все, для понятности. Title, Text, Label, Subtitle...

from page_factory.component import Component


class Title(Component):
    @property
    def type_of(self) -> str:
        return 'title'

Теперь вопрос: "Зачем все это?". Данный подход решает сразу тонну проблем и вопросов, которые возникают у любого QA Automation, который хоть раз писал UI авто тесты.

  • Дает удобный и понятный интерфейс для работы с объектами на странице. То есть мы работаем не с каким-то там локатором, а с конкретным объектом, например, Button.

  • Универсализирует все взаимодействия и проверки компонентов. Очень хорошо для команд, где над авто тестами работают два и более QA Automation, ну или если авто тесты пишут разработчики тоже. С таким подходом у вас не будет споров и проблем, то есть один QA Automation может писать так expect(locator).to_be_visible() , что является единственно правильным с точки зрения playwright. Второй QA Automation может писать так assert locator.is_visible(), что тоже по сути правильно, но костыльно. На этой основе могут возникать бесполезные споры или еще хуже: каждый пишет так, как он хочет. По итогу получаем проект, в котором одни и те же проверки пишутся по разному. С данным подходом мы один раз устанавливаем, как делается проверка и забываем об этом, все работает прекрасно.

  • Дает возможность универсализировать все шаги для отчета. В примере выше я использовал allure, но на самом деле это не важно, вы можете использовать любой репортер. При объявлении нового компонента, нам не нужно переписывать все шаги, они динамически формируются на основе параметров, name, type_of. Конечно же, вы можете их изменить и расширить под ваши требования. Достаточно переопределить type_of и мы получаем новый компонент с полностью уникальными шагами.

  • Динамические локаторы - это вечная боль, но не с данным подходом. Механизм форматирования локатора до безобразия прост. Мы передаем **kwargs в каждый метод, далее все это идет в сам локатор self.locator.format(**kwargs). То есть это позволяет писать нам локаторы span#some-element-id-{user_id}, далее передавать user_id через **kwargs прямо в локатор. Механизм прост, но он избавляет нас от локаторов в методах, от дублирования или еще хуже хардкода локаторов.

  • Появляется возможность создавать компоненты, работа с которыми специфична. Например, у вас в продукте есть какой-то хитрый авто комплит, который нужно как-то хитро кликать, возможно, печатать через клавиатуру. Вы можете создать компонент MyCustomAutocomplete, унаследовать его от Input и переопределить метод fill. Далее такой компонент можно будет использовать во всем проекте без дублирования логики ввода.

  • Если у вас над авто тестами работают сразу несколько QA Automation команд, например, одна тестирует админку, другая тестирует сайт, то вы можете вынести весь Page Factory внутрь библиотеки. Библиотеку можно запушить на pypi или на свой приватный nexus сервер. Далее библиотекой могут пользоваться все QA Automation команды, вы получите общий ентри поинт для написания UI авто тестов, общие шаги и проверки.

  • Last but not least. Предложенный мною подход Page Factory максимально простой, а это очень важно для масштабирования в будущем. Хуже, когда в коде наблюдается "магия" и эта "магия" понятна только тому, кто ее создал. В решение выше та "магия" отсутствует, все максимально прозрачно и в рамках обычного ООП.

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

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

  • Фокус на тестировании бизнес логики, без вечных головоломок с кодом;

  • Красивый и понятный отчет, который спокойно могут читать Manual QA, менеджеры и разработчики.

Pages

Теперь опишем страницы, которые нам понадобятся, уже с использованием Page Factory.

Основная страница playwright https://playwright.dev

from playwright.sync_api import Page

from pages.base_page import BasePage


class PlaywrightHomePage(BasePage):
    def __init__(self, page: Page) -> None:
        super().__init__(page)

Страница с языками https://playwright.dev/python/docs/languages

from playwright.sync_api import Page

from page_factory.title import Title
from pages.base_page import BasePage


class PlaywrightLanguagesPage(BasePage):
    def __init__(self, page: Page) -> None:
        super().__init__(page)

        self.language_title = Title(
            page, locator='h2#{language}', name='Language title'
        )

    def language_present(self, language: str):
        self.language_title.should_be_visible(language=language)
        self.language_title.should_have_text(
            language.capitalize(), language=language
        )

Components

Теперь опишем компоненты, которые нам будут нужны.

Navbar

from playwright.sync_api import Page

from components.modals.search_modal import SearchModal
from page_factory.button import Button
from page_factory.link import Link


class Navbar:
    def __init__(self, page: Page) -> None:
        self.page = page

        self.search_modal = SearchModal(page)

        self.api_link = Link(page, locator="//a[text()='API']", name='API')
        self.docs_link = Link(page, locator="//a[text()='Docs']", name='Docs')
        self.search_button = Button(
            page, locator="button.DocSearch-Button", name='Search'
        )

    def visit_docs(self):
        self.docs_link.click()

    def visit_api(self):
        self.api_link.click()

    def open_search(self):
        self.search_button.should_be_visible()

        self.search_button.hover()
        self.search_button.click()

        self.search_modal.modal_is_opened()

SearchModal

from playwright.sync_api import Page

from page_factory.input import Input
from page_factory.list_item import ListItem
from page_factory.title import Title


class SearchModal:
    def __init__(self, page: Page) -> None:
        self.page = page

        self.empty_results_title = Title(
            page, locator='p.DocSearch-Help', name='Empty results'
        )
        self.search_input = Input(
            page, locator='#docsearch-input', name='Search docs'
        )
        self.search_result = ListItem(
            page, locator='#docsearch-item-{result_number}', name='Result item'
        )

    def modal_is_opened(self):
        self.search_input.should_be_visible()
        self.empty_results_title.should_be_visible()

    def find_result(self, keyword: str, result_number: int) -> None:
        self.search_input.fill(keyword, validate_value=True)
        self.search_result.click(result_number=result_number)

Testing

Теперь настало время теста. Тут все просто, у нас есть готовые страницы, тест соберем, как конструктор, но перед этим напишем фикстуры.

conftest.py

import pytest
from playwright.sync_api import Browser, Page, sync_playwright

from pages.playwright_home_page import PlaywrightHomePage
from pages.playwright_languages_page import PlaywrightLanguagesPage


@pytest.fixture(scope='function')
def chromium_page() -> Page:
    with sync_playwright() as playwright:
        chromium = playwright.chromium.launch(headless=False)
        yield chromium.new_page()


@pytest.fixture(scope='function')
def playwright_home_page(chromium_page: Page) -> PlaywrightHomePage:
    return PlaywrightHomePage(chromium_page)


@pytest.fixture(scope='function')
def playwright_languages_page(chromium_page: Page) -> PlaywrightLanguagesPage:
    return PlaywrightLanguagesPage(chromium_page)

Я не буду объяснять, как работают и как писать фикстуры в pytest, для этого уже есть много информации. Скажу только то, что инициализацию объектов страниц лучше выносить внутрь фикстур, чтобы избежать дублирования внутри теста.

test_search.py

import pytest

from pages.playwright_home_page import PlaywrightHomePage
from pages.playwright_languages_page import PlaywrightLanguagesPage
from settings import BASE_URL


class TestSearch:
    @pytest.mark.parametrize('keyword', ['python'])
    def test_search(
        self,
        keyword: str,
        playwright_home_page: PlaywrightHomePage,
        playwright_languages_page: PlaywrightLanguagesPage
    ):
        playwright_home_page.visit('https://playwright.dev')
        playwright_home_page.navbar.open_search()
        playwright_home_page.navbar.search_modal.find_result(
            keyword, result_number=0
        )

        playwright_languages_page.language_present(language=keyword)

При использовании Page Object, Page Factory, как я описал выше, тесты пишутся легко и понятно. И, самое главное, это позволяет нам фокусироваться на тестировании бизнес логики продукта, а не на ерунде по типу, как написать allure.step, как написать проверку или как мне динамически подставить параметр в локатор.

Заключение

Весь исходный код проекта вы можете посмотреть на моем github https://github.com/Nikita-Filonov/playwright_python

Подход, который описан выше, можно использовать не только с Playwright. Его можно использовать с Selenium, Pylenium, PyPOM, Selene, с чем угодно. Фреймворк всего лишь инструмент и применять его можно по-разному.

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


  1. Andy_U
    03.01.2023 01:20
    +1

    сделаем Component абстрактным классом и унаследуем его от ABC

    Чтобы класс нельзя было инстанцировать, какой-нибудь метод должен быть декорирован с помощью @abc.abstractmethod.


    1. sound_right Автор
      03.01.2023 16:06

      Спасибо, забыл добавить abstractmethod к type_of


  1. Rudyasha
    03.01.2023 16:04
    +1

    Ооо, помогло, огромое спасибо. Давно искала похожий подход для ui автотестов, а то у меня лишь костыльные решения выходили. Добра тебе, добрый человечек :3