|
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")
...
Проект целиком доступен по ссылке.