198

     # UI | 12 | Playwright: корректное использование паттерна


Определение требований

Чтобы не совершать ошибок, как в прошлой статье, определимся с тем, что нам требуется:

  • Управление тестами через отдельный модуль
  • Каждый тестовый сценарий открывает свой собственный браузер
  • Браузер используется для открытия страницы, требуемой для теста
  • Открытая страница может выполнять все действия, доступные для страниц
  • При работе с элементами в модуле мы можем работать с ними:
    • как на уровне локаторов (если вдруг потребуется)
    • так и через общие методы работы с элементами

Добиться всего этого поможет следующая примерная схема:

Другими словами:

В корне проекта мы создадим файл (напр., main_test.py), который будет описывать, какие тесты запускать. Ненужные можно комментировать, новые добавлять.

Создадим тест test_case_1.py в папке tests/, внутри которого будет с определёнными настройками запускаться бразуер под этот тест.

Создадим файл browser_launcher.py, который будет отвечать за создание экземпляра браузера, будет принимать на вход разные параметры, чтобы была возможность запускать разные браузеры и с разными настройками. Это требуется, чтобы, например, проводить тест из-под разных браузеров или с разным размер экрана.

Дальше мы создадим файл MainPage.py, который также будет использоваться напрямую в тесте. Этот класс будет наследоваться от BasePage.py. Такой подход требуется, чтобы не описывать все действия на каждой странице, как например:

  • перезагрузить страницу
  • вернуться назад
  • открыть url
  • и пр.

Все эти действия будут храниться в одном единственном родителе BasePage.py, как для этой, так и для всех будущих страниц. Это одна из причин, зачем мы используем Page Object. Тем самым мы исключаем повторение кода. На деле это будет выглядеть следующим образом:

  • мы вызываем метод page.open_url() у объекта MainPage.py, но так как его там нет, а мы в свою очередь используем наследование, то Exception-ов не будет. Интерпретатор сначала пойдёт в его родительский класс BasePage.py, а так как он там есть, код отработает.

Все элементы в классе MainPage.py мы больше не будем создавать как локаторы:

self.top_menu_children = self.page.locator("#ul_mainmenu")

Вместо этого будет использовать наш родительский класс для элементов (который ещё требуется создать и описать):

self.menu = WebElement(self.page.locator("#id"))

Такой подход используется по тем же самым причинам. Попытавшись вызвать метод у переменной self.menu.check_visibility() мы по идее должны были бы упасть. Но так как такой метод у нас описан в классе WebElement, то он вызовется.

Зачем нужно отдельно описывать классы страниц, наследоваться от BasePage и WebElements

Ещё раз:

BasePage позволяет один раз и в одном месте описать все действия, которые можно осуществлять над объектом типа Page и больше нам об этом не надо думать.

Если бы мы это не сделали, нам бы пришлось для каждой страницы отдельно прописывать, что мы хотим сделать (перезагрузить, закрыть, вернуться назад), а главное - как это сделать. Это сейчас мы тестируем только одну страницу MainPage.py, а ещё у нас могут быть LoginPage, RegisterPage и т. д. И в случае необходимости совершения какого-либо действия на этих страницах приходилось бы каждый раз отдельно создавать "локальные" функции. Что является нарушением принципа DRY.

То же самое касается и WebElement.py. Либо мы описываем руками все действия, проверки, которые необходимо выполнить в рамках теста (напр., обойти список), что увеличивает количество кода, либо мы описываем это один раз в WebElements и просто обращаемся к методам этого класса.

В этом и состоит смысл паттерна - разделение ответственностей кода:

  • всё что касается работы страниц хранится в BasePage.py
  • всё, что касается логики работы с элементами хранится в Elements.py
  • и только то, что касается теста, хранится в my_test_case.py
  • и только те web-элементы, которые присутствуют на тестируемой странице, храняется в объекте MyTestPage.py

Да, в тесте мы говорим, какие элементы, на что и в каком порядке мы проверяем, именно для этого и нужен тест. Но чтобы его проще было читать, мы не пишем в него, как именно (технически) мы это делаем. Для технической имплементации существует класс Elements.py. Это значительно повышает читаемость теста, а также сокращает объём кода.

Реализация PageObject паттерна

test_launcher.py

Достаточно теории, предстоит много работы. Код в примерах будет указан лишь отрывочно, так как смысл всего был продемонстрирован в предыдущей статье, а полный код можно будет найти в конце этой статьи.

Определимся со структурой проекта и названиями файлов:

.
├── pages
│   ├── base.py
│   ├── elements.py
│   └── main_page.py
├── test_launcher.py
├── browser_launcher.py
└── tests
    └── test_1.py

Создадим модуль test_launcher.py. Название говорит само за себя. Менять в нём пока ничего не придётся, так что сохраним следующее содержание:

from tests.test_1 import launch_smoke_1

launch_smoke_1()

browser_launcher.py

Далее создадим модуль ответственный за запуск браузера, который также лежит в корне проекта. Перенесём в него логику поднятия браузера, которую мы ранее ошибочно записали в base.py, оставив только методы по работе с браузером, но не со страницей:

from playwright.sync_api import sync_playwright

class Current_browser():
    def __init__(self):
        self.playwright = sync_playwright().start()
        self.browser = self.playwright.chromium.launch(headless=False)
        #self.page = self.browser.new_page()

    def close_browser(self):
        self.browser.close()
        self.playwright.stop()

Дополнительно определимся с тем, что мы хотим. А мы хотим:

  • запускать разные браузеры
  • с GUI и без
  • указывать размер окна браузера

Размер задаётся строкой:

context = browser.new_context(ignore_https_errors=True, viewport={"width": 1920, "height": 1080})

Поэтому добавим переменных по умолчанию:

from playwright.sync_api import sync_playwright

class Current_browser():
    def __init__(
                self, 
                cert=True, 
                width=1920, 
                height=1080, 
                headless_off=False
                ):

        self.playwright = sync_playwright().start()
        self.browser = self.playwright.chromium.launch(headless=headless_off)
        self.context = self.browser.new_context(ignore_https_errors=cert, viewport={"width": width, "height": height})


    def close_browser(self):
        self.browser.close()
        self.playwright.stop()

Теперь, если ничего не будет передаваться, браузер по умолчанию будет запускаться с этими настройками. Осталось только не забыть, что мы добавили контекст. И теперь страницы нужно будет открывать не через:

self.page = self.browser.new_page()

а через:

self.page = self.context.new_page()

Для определения браузера воспользуемся блоком условий. Финальный код:

from playwright.sync_api import sync_playwright

class Current_browser():
    def __init__(
                self, 
                cert=True, 
                width=1920, 
                height=1080, 
                headless_off=False,
                browser="chromium"
                ):

        self.playwright = sync_playwright().start()

        if browser == "chromium":
            self.browser = self.playwright.chromium.launch(headless=headless_off)
        elif browser == "chrome":
            self.browser = self.playwright.chromium.launch(channel="chrome", headless=headless_off)
        elif browser == "firefox":
            self.browser = self.playwright.firefox.launch(headless=headless_off)
        elif browser == "webkit":
            self.browser = self.playwright.webkit.launch()
        else:
            raise Exception("Укажите браузер: chromium, chrome, firefox, webkit")

        self.context = self.browser.new_context(ignore_https_errors=cert, viewport={"width": width, "height": height})

    def close_browser(self):
        self.browser.close()
        self.playwright.stop()

Теперь, если мы хотим изменить параметры запуска, не забываем это указывать в тестах:

def launch_smoke_1():
    pw_browser = Current_browser(browser="firefox")

main_page.py - наследование

В рамках логики нашего модуля test_1.py придётся передалать код в двух местах:

def open_page():
    pw_browser.open_url("https://g-oak.ru")
...
def do_smoke_test():
    main_page = MainPage(pw_browser.page)

Это вызвано тем, что pw_browser ссылается на browser_launcher.py, из которого мы только что удалили этот метод, решив, что за данную функцию должен отвечать base.py:

from playwright.sync_api import sync_playwright

class BasePage():
    def __init__(self, pwright):
        self.page = pwright.context.new_page()

    def open_url(self, url):
        self.page.goto(url)

    def close(self):
        self.page.close()

Но есть три момента, которые нужно учитывать:

  • страницу нужно создавать через контекст (выше описано почему)
  • чтобы обратиться к контексту, его нужно передать
from playwright.sync_api import sync_playwright

class BasePage():
    def __init__(self, pw_context):
        self.context = pw_context
        self.page = self.context.new_page()

    def open_url(self, url):
        self.page.goto(url)

    def close(self):
        self.page.close()

А последний момент состоит в том, что класс BasePage() не дёргается напрямую. Он вызывается каждый раз, когда мы создаём объекты, которые на него ссылаются. Другими словами, создавая экземпляр класса MainPage, нам требуется сделать так, чтобы:

  • он расширялся от BasePage
  • и передавал в него pw_browser

Появляется слишком много зависимостей, идём по порядку, правим, что можем исправить:

# test_1.py
def launch_smoke_1():
    pw_browser = Current_browser(browser="chromium")
    main_page = MainPage(pw_browser.context)

    def open_page():
        main_page.open_url("https://g-oak.ru")
  • контекст теперь передаётся, только "уходит в молоко"
  • у main_page по-прежнему нет метода open_url

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

  • main_page.py
from pages.base import BasePage

class MainPage(BasePage):
    def __init__(self, context):
        # запускаем инициализацию родительского класса
        # передавая контекст "дальше"
        super().__init__(context)

        # запускаем метод
        self.open_url("https://g-oak.ru")

Откуда у нас взяался метод self.open_url()? Он есть в классе BasePage, от которого мы наследуемся. Как уже говорилось, интерпретатор смотрит, что в этом классе его нет и вызывает его в "родительском".

Зачем мы вызываем этот метод здесь, при инициализации объекта, а не позже - при прогоне теста? Данное решение возникло не случайно. Последний из элементов не является элементом в прямом смысле:

self.footer_btn_pagination_field_max_value = int(self.page.locator(
            '//label[@id="max_page"]'
            ).inner_text().replace(
                "/", ""
                ).strip())

То есть, тут не просто объявляется элемент (в противном случае мы могли бы и не грузить страницу), но и вызывается inner_text(). Получается, что на момент создания страница должна быть загружена, а элемент присутствовать на странице и и быть прочитан.

Да, можно сказать, что это не элемент и его надо вынести отдельно в тест-кейс и проверять там, но раз уже написано так, да и чтобы избежать всех будущих проблем подобного рода, было решено грузить страницу при объявлении.

Если такая ситуация повторится когда-либо в будущем, мы будем к ней уже готовы. Да и логически ничто не запрещает подгружать страницу во время создания экземпляра класса. Ведь MainPage - это лишь "отображение" главной страницы, следовательно, её можно сразу и подгрузить.

Имеет смысл другой вопрос - как и где лучше хранить url:

  • способ один
# main_page.py
class MainPage(BasePage):
    def __init__(self, context):
        # запускаем инициализацию родительского класса
        # передавая контекст и url "дальше"
        super().__init__(context)
        self.open_url("https://g-oak.ru")
  • способ два
# main_page.py
class MainPage(BasePage):
    def __init__(self, context, url):
        # запускаем инициализацию родительского класса
        # передавая контекст и url "дальше"
        super().__init__(context, url)

# base.py
class BasePage():
    def __init__(self, pw_context, url):
        print(f"base page context: {pw_context}")
        self.context = pw_context
        self.page = self.context.new_page()
        self.open_url(url)
  • способ три
# test_1.py
main_page = MainPage(pw_browser.context, "https://g-oak.ru")

# main_page.py
class MainPage(BasePage):
    def __init__(self, context, url):
        # запускаем инициализацию родительского класса
        # передавая контекст и url "дальше"
        super().__init__(context)
        self.open_url(url)

Наверное, лучше всего передавать url через test_1.py, так и нагляднее, сразу видно, какой url используется, не надо копаться под капотом в поисках используемого url-а. Плюс ко всему больше гибкости, можно в тесте сразу заменить url на другой, если требуется.

main_page.py - работа с элементами

Осталось переделать все элементы, преобразовав их в класс WebElement.

  • Было:
# main_page.py
self.top_menu_children = self.page.locator("#ul_mainmenu")
  • Станет:
self.top_menu_children = Webelement(self.page.locator("#ul_mainmenu"))

Но для этого нужно:

  • импортировать класс Webelement
  • "расписать" класс Webelement

Изменяем элементы для первого теста:

# main_page.py

...
# Test 1
self.top_menu_children = WebElement(self.page.locator("#ul_mainmenu"))
self.top_menu_options = WebElement(self.page.locator("#ul_mainmenu li"))
self.top_menu_options_as_list = WebElement(self.top_menu_options.all())
self.top_menu_articles = WebElement(self.page.locator("#ul_mainmenu div").get_by_role("img"))
self.top_menu_articles_chapters = WebElement(self.page.locator('//btn[text()="# > "]'))
...

Описываем класс elements.py, создавая в нём методы нужные для первого теста:

# elements.py

from playwright.sync_api import expect

class WebElement(object):
    def __init__(self, locator):
        self.locator = locator

    def all(self):
        print(f"element: {self.locator}")
        return self.locator.all()


    def check_count_equals(self, amount):
        print(f"element: {self.locator}")
        expect(self.locator).to_have_count(amount)

    def check_presence(self, required_timeout=5000):
        print(f"element: {self.locator}")
        expect(self.locator).to_be_attached(timeout=required_timeout)

    def check_visibility_locators_as_list(self):
        print(f"локатор: {self.locator}")
        print(f"тип: {type(self.locator)}")
        assert isinstance(self.locator, list)
        for elem in self.locator:
            print(type(elem))
            assert "Locator" in str(elem)
            expect(elem).to_be_visible()

Переписываем наш тест test_1.py:

from browser_launcher import Current_browser
from pages.main_page import MainPage

def launch_smoke_1():
    pw_browser = Current_browser(browser="chromium")
    main_page = MainPage(pw_browser.context, "https://g-oak.ru")

    def finish_testing():
        main_page.close()
        main_page.context.close()
        pw_browser.close_browser()

    def do_smoke_test():
        print(f"\nТест 1: старт")

        print("[DO] Проверка главного меню")
        main_page.top_menu_children.check_presence()
        print("[OK] Проверка главного меню")

        print("[DO] Проверка элементов главного меню")
        main_page.top_menu_options.check_count_equals(4)
        print("[OK] Проверка элементов главного меню")

        print("[DO] Проверка видимости элементов главного меню")
        main_page.top_menu_options_as_list.check_visibility_locators_as_list()
        print("[OK] Проверка видимости элементов главного меню")

    try:
        do_smoke_test()
        finish_testing()
    except Exception as exc:
        print(f"Тест 1: Тест-кейс упал")
        print(f"Тест 1: {exc.with_traceback}")
        main_page.screenshot(path=f"Тест 1.png")
        finish_testing()

Блок try ... except никуда не годится, но по сути сам тест теперь занимает только 3 строчки.

Более того, для запуска мы теперь можем использовать разные браузеры, а также менять размер окна.

# test_launcher.py

from tests.test_1 import launch_smoke_1
from tests.test_1_small import launch_smoke_1_small

launch_smoke_1()
launch_smoke_1_small()


# test_1_small.py

def launch_smoke_1_small():
    pw_browser = Current_browser(browser="firefox", width=400, height=750)
    main_page = MainPage(pw_browser.context, "https://g-oak.ru")
...

Проект целиком доступен по ссылке.