Привет, меня зовут Коля, занимаюсь тестированием 7 лет, автоматизацией — 6 лет. Так уж сложилось, что не особо люблю WebUI-тесты, но почему-то именно они у меня получаются лучше всего.
В один день мне позвонил мой друг Рома и сказал: «Коля, помнишь ты у нас автотесты делал с селениумом? Помоги мне сделать так же красиво».
Отправив Роме ссылку на свой гитхаб и устроив пару созвонов с объяснениями, что и как работает, я добился от него заветного «Я понял».
Спустя два дня Рома позвонил снова: «Коля, я всё понял, а вот ребята в моей команде — нет. Помоги мне объяснить им».
Внутри я расскажу от том, как:
ускорить написание тестов;
снизить затраты на их поддержку;
прокачаться в написании фреймворков;
сэкономить деньги компании;
сохранить нервы сотрудников.
Что внутри статьи
В статье будет описана эволюция фреймворка для тестов, использующего:
Python;
Pytest;
Selenium (версии 3).
Использование Selenium версии 3.* обусловлено тем, что Selenium 4.*-вебдрайвер не дружит с синглтоном. На мой взгляд, причин для перехода с 3 на 4 нет совсем — изменения только в работе с Selenium Grid, который во всём проигрывает Selenoid. Если есть желание использовать новое-новое, то лучше посмотреть в сторону Playwright — с небольшими изменениям код из статьи можно адаптировать и под него.
Двигаться по статье будем так:
пишем работающий код;
разбираем, почему код выглядит плохо;
улучшаем код;
GOTO 1.
Установить всё необходимое поможет этот набор статей — Selenium для Python. Глава 1. Установка / Хабр (habr.com)
Статья рассчитана на опытного специалиста, который уже знает такие слова, как xpath, selenium webdriver, и уже имел опыт с Pytest, поэтому я пропущу объяснения основ.
Пишем тесты
Для примера возьмём два простых сценария:
зайти на habr.com, проверить, что мы есть на хабре;
зайти на habr.com, сделать поиск по фразе “ozon tech”, проверить, что все статьи содержат фразу “ozon tech”.
Здесь будет много кода, потому что это отправная точка всей статьи.
requirements.txt
selenium==3.141.0
pytest==7.4.3
urllib3<=2.0
Заметка — requirements.txt будет упомянут всего два раза. Фиксированные версии библиотек необходимы для возможности повторить результат на каждом этапе статьи.
Использование urllib3 <= 2.0 требуется только для запуска selenium webdriver.
Установка зависимостей происходит командой pip install -r requirements.txt
.
driver.py
from selenium.webdriver import Firefox
class Driver(Firefox):
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self.implicitly_wait(5) # default time waiting for a locator
conftest.py
import pytest
from driver import Driver
@pytest.fixture()
def browser() -> Driver:
driver = Driver(executable_path="path/to/driver")
driver.set_window_size(1920, 1080)
driver.get("https://habr.com/ru/all")
yield driver
driver.quit()
test_ozon.py
from selenium.webdriver.common.by import By
from selenium.webdriver.support import expected_conditions
from selenium.webdriver.support.wait import WebDriverWait
from driver import Driver
def test_main_page(browser: Driver):
browser.find_element_by_xpath('//a[contains(@class, "tm-header__logo")]')
browser.find_element_by_xpath('//a[contains(@class, "tm-header-user-menu__search")]')
browser.find_element_by_xpath('//section[@id="news_block_1"]')
def test_search(browser: Driver):
search_string = "ozon tech"
open_search = browser.find_element_by_xpath('//a[contains(@class, "tm-header-user-menu__search")]')
open_search.click()
search_input = browser.find_element_by_xpath('//div[contains(@class, "tm-search__input")]//input')
search_input.send_keys(search_string)
search_button = browser.find_element_by_xpath('//div[contains(@class, "tm-search__input")]//span')
search_button.click()
WebDriverWait(browser, 10).until_not(expected_conditions.presence_of_element_located((By.XPATH, '//div[contains(@class, "placeholder-wrapper")]')))
browser.find_element_by_xpath('//a[contains(@class, "tm-header__logo")]')
browser.find_element_by_xpath('//a[contains(@class, "tm-header-user-menu__search")]')
articles = browser.find_elements_by_xpath('//div[@class="tm-articles-list"]/article')
assert articles, "empty articles list"
errors = []
for article in articles:
if search_string not in article.text.lower():
errors.append(f"'{search_string}' not found in '{article.text.lower()}'")
assert not errors, errors
Добавляем PageObject
Тесты написаны, но выглядят как-то не так, очевидное улучшение, которое можно сделать — добавить переиспользуемость кода из тестов.
Когда мы говорим о тестах для веба, этот паттерн называется PageObject — взаимодействие со страницей и её возможностями на основе методов, которые есть у страницы.
Создадим новый класс страницы и поместим туда пару методов для работы с объектами страницы:
pages/main_page.py
from driver import Driver
class MainPage:
header_logo_xpath = '//a[contains(@class, "tm-header__logo")]'
header_search_xpath = '//a[contains(@class, "tm-header-user-menu__search")]'
search_input_xpath = '//div[contains(@class, "tm-search__input")]//input'
search_button_xpath = '//div[contains(@class, "tm-search__input")]//span'
news_block_xpath = '//section[@id="news_block_1"]'
@staticmethod
def verify_page(driver: Driver):
driver.find_element_by_xpath(MainPage.header_logo_xpath)
driver.find_element_by_xpath(MainPage.header_search_xpath)
driver.find_element_by_xpath(MainPage.news_block_xpath)
@staticmethod
def open_search(driver: Driver):
open_search = driver.find_element_by_xpath(MainPage.header_search_xpath)
open_search.click()
@staticmethod
def input_search(driver: Driver, search: str):
search_input = driver.find_element_by_xpath(MainPage.search_input_xpath)
search_input.send_keys(search)
@staticmethod
def press_search_button(driver: Driver):
search_button = driver.find_element_by_xpath(MainPage.search_button_xpath)
search_button.click()
pages/search_page.py
from selenium.webdriver.common.by import By
from selenium.webdriver.support import expected_conditions
from selenium.webdriver.support.wait import WebDriverWait
from driver import Driver
class SearchPage:
header_logo_xpath = '//a[contains(@class, "tm-header__logo")]'
header_search_xpath = '//a[contains(@class, "tm-header-user-menu__search")]'
article_xpath = '//div[@class="tm-articles-list"]/article'
preview_xpath = '//div[contains(@class, "placeholder-wrapper")]'
@staticmethod
def wait_for_loader_off(driver: Driver):
WebDriverWait(driver, 10).until_not(expected_conditions.presence_of_element_located((By.XPATH, SearchPage.preview_xpath)))
@staticmethod
def verify_page(driver: Driver, expected_string_in_results: str):
SearchPage.wait_for_loader_off(driver)
driver.find_element_by_xpath(SearchPage.header_logo_xpath)
driver.find_element_by_xpath(SearchPage.header_search_xpath)
articles = driver.find_elements_by_xpath(SearchPage.article_xpath)
assert articles, "empty articles list"
errors = []
for article in articles:
if expected_string_in_results not in article.text.lower():
errors.append(f"'{expected_string_in_results}' not found in '{article.text.lower()}'")
assert not errors, errors
Применим наши изменения к тестам:
test_ozon.py
from driver import Driver
from pages.main_page import MainPage
from pages.search_page import SearchPage
def test_main_page(browser: Driver):
MainPage.verify_page(browser)
def test_search(browser: Driver):
search_string = "ozon tech"
MainPage.open_search(browser)
MainPage.input_search(browser, search_string)
MainPage.press_search_button(browser)
SearchPage.verify_page(browser, search_string)
Тесты выглядят намного проще, не так ли?
Приколачиваем PageElement
Стало лучше, но на разных страницах у нас есть пересечение по элементам. Посмотрим в будущее, сделаем отдельный класс для повторяющихся элементов, в нашем случае — для Header.
Для этого воспользуемся паттерном PageElement — обычно он рядом с PageObject, если у вас есть повторяющиеся элементы на страницах.
Создадим новый класс компонента Header, добавим в него общие элементы.
components/header.py
from driver import Driver
class ComponentHeader:
header_logo_xpath = '//a[contains(@class, "tm-header__logo")]'
header_search_xpath = '//a[contains(@class, "tm-header-user-menu__search")]'
@staticmethod
def verify_component(driver: Driver):
driver.find_element_by_xpath(ComponentHeader.header_logo_xpath)
driver.find_element_by_xpath(ComponentHeader.header_search_xpath)
@staticmethod
def open_search(driver: Driver):
open_search = driver.find_element_by_xpath(ComponentHeader.header_search_xpath)
open_search.click()
Добавим новый компонент в страницы.
pages/main_page.py
from components.header import ComponentHeader
from driver import Driver
class MainPage:
Header = ComponentHeader
search_input_xpath = '//div[contains(@class, "tm-search__input")]//input'
search_button_xpath = '//div[contains(@class, "tm-search__input")]//span'
news_block_xpath = '//section[@id="news_block_1"]'
@staticmethod
def verify_page(driver: Driver):
MainPage.Header.verify_component(driver)
driver.find_element_by_xpath(MainPage.news_block_xpath)
@staticmethod
def input_search(driver: Driver, search: str):
search_input = driver.find_element_by_xpath(MainPage.search_input_xpath)
search_input.send_keys(search)
@staticmethod
def press_search_button(driver: Driver):
search_button = driver.find_element_by_xpath(MainPage.search_button_xpath)
search_button.click()
pages/search_page.py
from selenium.webdriver.common.by import By
from selenium.webdriver.support import expected_conditions
from selenium.webdriver.support.wait import WebDriverWait
from components.header import ComponentHeader
from driver import Driver
class SearchPage:
Header = ComponentHeader
article_xpath = '//div[@class="tm-articles-list"]/article'
preview_xpath = '//div[contains(@class, "placeholder-wrapper")]'
@staticmethod
def wait_for_loader_off(driver: Driver):
WebDriverWait(driver, 10).until_not(expected_conditions.presence_of_element_located((By.XPATH, SearchPage.preview_xpath)))
@staticmethod
def verify_page(driver: Driver, expected_string_in_results: str):
SearchPage.wait_for_loader_off(driver)
SearchPage.Header.verify_component(driver)
articles = driver.find_elements_by_xpath(SearchPage.article_xpath)
assert articles, "empty articles list"
errors = []
for article in articles:
if expected_string_in_results not in article.text.lower():
errors.append(f"'{expected_string_in_results}' not found in '{article.text.lower()}'")
assert not errors, errors
Чуть-чуть поменяем тесты с учётом нового компонента.
test_ozon.py
def test_search(browser: Driver):
search_string = "ozon tech"
MainPage.Header.open_search(browser)
MainPage.input_search(browser, search_string)
MainPage.press_search_button(browser)
SearchPage.verify_page(browser, search_string)
Driver Driver Driver Driver. Убираем копипасту — добавляем Singleton
Нынешнее решение уже довольно хорошо выглядит — нет поисков локаторов в тестах, всё инкапсулировано в страницах. Страницы, в свою очередь, разделены на разные классы — удобно добавлять изменения в определённые страницы, не ломая в это время другие.
Можно ли что-то ещё добавить для улучшения? Можно. Но не добавить, а убрать — избавимся от вездесущей копипасты с запихиванием веб-драйвера в аргументы.
Браузер в тестах у нас всегда один, зачем его постоянно упоминать? Можно сделать Singleton, который будет возвращать наш браузер.
Мнение — я верю, что тесты с использованием селениума проверяют интерфейс, а не бэкенд. Если у вас возникает необходимость использовать две сессии одновременно, скорее всего, вы проверяете работу двух пользователей над одной сущностью, чего в большинстве случаев можно добиться, реализовав работу второго пользователя через бэкенд, не браузер.
В любом случае остаётся возможность использования Singleton и двух активных веб-драйверов, нужно только сделать переключение активного браузера.
Меняем код драйвера.
driver.py
from selenium.webdriver import Firefox
class Singleton(type):
_instances = {}
def __call__(cls, *args, **kwargs):
if cls not in cls._instances:
cls._instances[cls] = super(Singleton, cls).__call__(*args, **kwargs)
return cls._instances[cls]
class Driver(Firefox, metaclass=Singleton):
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self.implicitly_wait(5) # default time waiting for a locator
После данных изменений ничего не запустится — особенности urlllib3 и selenium webdriver. Поднимем версию urllib3.
requirements.txt
selenium==3.141.0
pytest==7.4.3
urllib3==2.1.0
Обновим teardown в фикстуре и добавим autouse:
conftest.py
import pytest
from driver import Driver
@pytest.fixture(autouse=True)
def browser() -> Driver:
driver = Driver(executable_path="path/to/driver")
driver.set_window_size(1920, 1080)
driver.get("https://habr.com/ru/all")
yield driver
try:
driver.quit()
finally:
driver.__class__._instances = {}
Уберём прокидывание Driver в методы компонента:
components/header.py
from driver import Driver
class ComponentHeader:
header_logo_xpath = '//a[contains(@class, "tm-header__logo")]'
header_search_xpath = '//a[contains(@class, "tm-header-user-menu__search")]'
@staticmethod
def verify_component():
Driver().find_element_by_xpath(ComponentHeader.header_logo_xpath)
Driver().find_element_by_xpath(ComponentHeader.header_search_xpath)
@staticmethod
def open_search():
open_search = Driver().find_element_by_xpath(ComponentHeader.header_search_xpath)
open_search.click()
Повторим для страниц:
pages/main_page.py
from components.header import ComponentHeader
from driver import Driver
class MainPage:
Header = ComponentHeader
search_input_xpath = '//div[contains(@class, "tm-search__input")]//input'
search_button_xpath = '//div[contains(@class, "tm-search__input")]//span'
news_block_xpath = '//section[@id="news_block_1"]'
@staticmethod
def verify_page():
MainPage.Header.verify_component()
Driver().find_element_by_xpath(MainPage.news_block_xpath)
@staticmethod
def input_search(search: str):
search_input = Driver().find_element_by_xpath(MainPage.search_input_xpath)
search_input.send_keys(search)
@staticmethod
def press_search_button():
search_button = Driver().find_element_by_xpath(MainPage.search_button_xpath)
search_button.click()
pages/search_page.py
from selenium.webdriver.common.by import By
from selenium.webdriver.support import expected_conditions
from selenium.webdriver.support.wait import WebDriverWait
from components.header import ComponentHeader
from driver import Driver
class SearchPage:
Header = ComponentHeader
article_xpath = '//div[@class="tm-articles-list"]/article'
preview_xpath = '//div[contains(@class, "placeholder-wrapper")]'
@staticmethod
def wait_for_loader_off():
WebDriverWait(Driver(), 10).until_not(expected_conditions.presence_of_element_located((By.XPATH, SearchPage.preview_xpath)))
@staticmethod
def verify_page(expected_string_in_results: str):
SearchPage.wait_for_loader_off()
SearchPage.Header.verify_component()
articles = Driver().find_elements_by_xpath(SearchPage.article_xpath)
assert articles, "empty articles list"
errors = []
for article in articles:
if expected_string_in_results not in article.text.lower():
errors.append(f"'{expected_string_in_results}' not found in '{article.text.lower()}'")
assert not errors, errors
Почистим тесты от упоминания Driver:
test_ozon.py
from pages.main_page import MainPage
from pages.search_page import SearchPage
def test_main_page():
MainPage.verify_page()
def test_search():
search_string = "ozon tech"
MainPage.Header.open_search()
MainPage.input_search(search_string)
MainPage.press_search_button()
SearchPage.verify_page(search_string)
Избавились от копипасты с webdriver, Driver, driver — теперь не нужно дополнительно везде добавлять его как параметр, достаточно в необходимом месте сделать вызов Driver.
Заворачиваем PageObject в PageObject
От копипасты избавились уже аж два раза, но что, если я скажу, что у нас всё ещё остаётся копипаста? А что, если мы представим, что каждый элемент DOM-дерева — это контейнер методов?
Спойлер — так оно и есть, надо только сделать удобный интерфейс для взаимодействия.
Попробуем сделать отдельный класс для элемента DOM-дерева. Пусть у него будет xpath и ссылка на webelement (объект в DOM-дереве на открытой драйвером странице).
locator.py
from selenium.webdriver.remote.webelement import WebElement
class Locator:
xpath: str = None
name: str = None
webelement: WebElement = None
def __init__(self, xpath: str, name: str):
self.xpath = xpath
self.name = name
Добавим метод для проверки наличия локатора на странице:
locator.py
def is_on_page(self) -> bool:
elements = Driver().find_elements_by_xpath(self.xpath)
if elements:
return True
return False
def wait_for_disappear(self):
WebDriverWait(Driver(), 10).until_not(expected_conditions.presence_of_element_located((By.XPATH, self.xpath)))
def get_all_elements(self):
response = []
for element in Driver().find_elements_by_xpath(self.xpath):
locator = Locator(xpath=self.xpath, name=self.name)
locator.webelement = element
response.append(locator)
return response
Добавим ✨магический✨ метод получения webelement:
locator.py
def _webelement_required(self):
def update_webelement():
try:
self.webelement = Driver().find_element_by_xpath(self.xpath)
return self
except Exception:
raise Exception(f'Error: cannot locate {self.name}')
# magic function to update webelement if there is no webelement or if DOM element changed
if not self.webelement:
update_webelement()
return
try:
# if not fails, webelement still has the DOM object link
self.webelement.location
return
except StaleElementReferenceException:
update_webelement()
Магия работает просто — если удалось обратиться к webelement, значит он ещё есть на странице. Если не удалось обратиться — значит DOM-дерево перестроилось, необходимо найти новый элемент.
Важно — не всем методам необходим webelement. Например, чтобы проверить, что элемент отображается на странице, нам необязательно привязываться к webelement, достаточно xpath-локатора.
Добавим интерактивных методов, таких как клик, получение и ввод текста:
locator.py
def click(self):
self._webelement_required()
self.webelement.click()
def text(self) -> str:
self._webelement_required()
return self.webelement.text
def input(self, value: str):
self._webelement_required()
self.webelement.send_keys(value)
Полный код locator.py:
locator.py
from __future__ import annotations
from selenium.common.exceptions import StaleElementReferenceException
from selenium.webdriver.common.by import By
from selenium.webdriver.remote.webelement import WebElement
from selenium.webdriver.support import expected_conditions
from selenium.webdriver.support.wait import WebDriverWait
from driver import Driver
class Locator:
xpath: str = None
name: str = None
webelement: WebElement = None
def __init__(self, xpath: str, name: str):
self.xpath = xpath
self.name = name
def _webelement_required(self):
def update_webelement():
try:
self.webelement = Driver().find_element_by_xpath(self.xpath)
return self
except Exception:
raise Exception(f'Error: cannot locate {self.name}')
# magic function to update webelement if there is no webelement or if DOM element changed
if not self.webelement:
update_webelement()
return
try:
# if not fails, webelement still has the DOM object link
self.webelement.location
return
except StaleElementReferenceException:
update_webelement()
def is_on_page(self) -> bool:
elements = Driver().find_elements_by_xpath(self.xpath)
if elements:
return True
return False
def click(self):
self._webelement_required()
self.webelement.click()
def text(self) -> str:
self._webelement_required()
return self.webelement.text
def input(self, value: str):
self._webelement_required()
self.webelement.send_keys(value)
def wait_for_disappear(self):
WebDriverWait(Driver(), 10).until_not(expected_conditions.presence_of_element_located((By.XPATH, self.xpath)))
def get_all_elements(self) -> [Locator]:
response = []
for element in Driver().find_elements_by_xpath(self.xpath):
locator = Locator(xpath=self.xpath, name=self.name)
locator.webelement = element
response.append(locator)
return response
Рефакторим странички под новую реальность
Новая сущность — интерфейс для работы с xpath — готова, теперь надо применить её к существующему коду.
После недолгих манипуляций получаем следующую картину:
components/header.py
from locator import Locator
class ComponentHeader:
header_logo = Locator('//a[contains(@class, "tm-header__logo")]', "Header logo")
header_search = Locator('//a[contains(@class, "tm-header-user-menu__search")]', "Header search")
@staticmethod
def verify_component():
assert ComponentHeader.header_logo.is_on_page()
assert ComponentHeader.header_search.is_on_page()
@staticmethod
def open_search():
ComponentHeader.header_search.click()
pages/main_page.py
from components.header import ComponentHeader
from locator import Locator
class MainPage:
Header = ComponentHeader
search_input = Locator('//div[contains(@class, "tm-search__input")]//input', "Search input")
search_button = Locator('//div[contains(@class, "tm-search__input")]//span', "Search button")
news_block = Locator('//section[@id="news_block_1"]', "News block")
@staticmethod
def verify_page():
MainPage.Header.verify_component()
assert MainPage.news_block.is_on_page()
@staticmethod
def input_search(search: str):
MainPage.search_input.input(search)
@staticmethod
def press_search_button():
MainPage.search_button.click()
pages/search_page.py
from components.header import ComponentHeader
from locator import Locator
class SearchPage:
Header = ComponentHeader
article = Locator('//div[@class="tm-articles-list"]/article', "Article")
preview = Locator('//div[contains(@class, "placeholder-wrapper")]', "Article placeholder")
@staticmethod
def wait_for_loader_off():
SearchPage.preview.wait_for_disappear()
@staticmethod
def verify_page(expected_string_in_results: str):
SearchPage.wait_for_loader_off()
SearchPage.Header.verify_component()
articles = SearchPage.article.get_all_elements()
assert articles, "empty articles list"
errors = []
for article in articles:
if expected_string_in_results not in article.text().lower():
errors.append(f"'{expected_string_in_results}' not found in '{article.text().lower()}'")
assert not errors, errors
Теперь методы внутри страниц являются однострочниками. Секундочку, это что получается, теперь можно не писать методы для каждой страницы, а просто обращаться к классу Locator?
Почистим код страниц ещё раз, а потом посмотрим на тесты.
components/header.py
from locator import Locator
class ComponentHeader:
header_logo = Locator('//a[contains(@class, "tm-header__logo")]', "Header logo")
header_search = Locator('//a[contains(@class, "tm-header-user-menu__search")]', "Header search")
@staticmethod
def verify_component():
assert ComponentHeader.header_logo.is_on_page()
assert ComponentHeader.header_search.is_on_page()
pages/main_page.py
from components.header import ComponentHeader
from locator import Locator
class MainPage:
Header = ComponentHeader
search_input = Locator('//div[contains(@class, "tm-search__input")]//input', "Search input")
search_button = Locator('//div[contains(@class, "tm-search__input")]//span', "Search button")
news_block = Locator('//section[@id="news_block_1"]', "News block")
@staticmethod
def verify_page():
MainPage.Header.verify_component()
assert MainPage.news_block.is_on_page()
pages/search_page.py
from components.header import ComponentHeader
from locator import Locator
class SearchPage:
Header = ComponentHeader
article = Locator('//div[@class="tm-articles-list"]/article', "Article")
preview = Locator('//div[contains(@class, "placeholder-wrapper")]', "Article placeholder")
@staticmethod
def verify_page(expected_string_in_results: str):
SearchPage.preview.wait_for_disappear()
SearchPage.Header.verify_component()
articles = SearchPage.article.get_all_elements()
assert articles, "empty articles list"
errors = []
for article in articles:
if expected_string_in_results not in article.text().lower():
errors.append(f"'{expected_string_in_results}' not found in '{article.text().lower()}'")
assert not errors, errors
test_ozon.py
from pages.main_page import MainPage
from pages.search_page import SearchPage
def test_main_page():
MainPage.verify_page()
def test_search():
search_string = "ozon tech"
MainPage.Header.header_search.click()
MainPage.search_input.input(search_string)
MainPage.search_button.click()
SearchPage.verify_page(search_string)
Как видим в примере тестов, инкапсуляция работы с селениумом позволяет убрать всю боль от работы с DOM внутрь фреймворка.
Результаты
Какие плюсы после всех этих улучшений:
быстрее внедрение новых возможностей во фреймворк;
проще и быстрее написание тестов за счёт уменьшения объёма кода;
проще и быстрее добавление новых страниц за счёт инкапсуляции работы с DOM;
снижение порога входа для начала автоматизации за счёт ограничения доступных методов для взаимодействия;
нужен минимум опытных людей для поддержки фреймворка.
Получившийся фреймворк похож на конструктор, который позволяет неразбирающимся в автоматизации начать автоматизировать под присмотром старших товарищей — более не требуются глубокие знания селениума для начала работы.
Бонус — добавляем Allure
Как proof of concept лёгкости добавления новых возможностей во фреймворк сейчас мы добавим Allure-отчёты в наши тесты.
Установим Allure:
pip install allure-pytest
Добавим его использование в класс Locator:
locator.py
def click(self):
with allure.step(f"Click on {self.name}"):
self._webelement_required()
self.webelement.click()
def input(self, value: str):
with allure.step(f"Input '{value}' in {self.name}"):
self._webelement_required()
self.webelement.send_keys(value)
def wait_for_disappear(self):
with allure.step(f"Wait for disappear {self.name}"):
WebDriverWait(Driver(), 10).until_not(expected_conditions.presence_of_element_located((By.XPATH, self.xpath)))
Запустим тесты с генерацией отчёта:
pytest --alluredir=./allure_results
Восхитимся нашим отчётом:
allure serve allure_results/
Вышло довольно просто, не так ли?
В конце добавлю ссылку на свой гитхаб с чуть более развитым (и чуть более устаревшим) фреймворком — https://github.com/giant-whale/selenoid-python-framework
Надеюсь, эта статья поможет улучшить тестирование в вашей компании.
P.S. Рома, теперь ты можешь просто кинуть ссылку на эту статью своим коллегам, сомневающимся в полезности изменений.
Комментарии (14)
Frel0n
15.01.2024 10:43Может я чего то не понял но по мне как то не очень накручивать логику в странице, для это лучше использовать слой операций. Да и асерты тоже логичней туда перенести. А на странице оставлять атомарные методы для взаимодействия с одним элементом
UnusualLetter Автор
15.01.2024 10:43но по мне как то не очень накручивать логику в странице, для это лучше использовать слой операций
Тут уже как душе угодно, всё возможно и доступно с получившейся архитектурой.
san-kolts
15.01.2024 10:43Полезный для изучения новичками подход, однако несколько устаревший: основывать фреймворк на селениуме 3 крайне плохая идея, потому что максимальная версия питона, с которым он работает - Python 3.8, который станет депрекейтед в 2024 году. Ну и сам селениум 3 давно не обновляется.
UnusualLetter Автор
15.01.2024 10:43+1Код из статьи прекрасно работает на python 3.11.
Ну и сам селениум 3 давно не обновляется.
И не будет обновляться, при этом 4 версия больше про работу с Selenium Server, нежели с Selenium Webdriver. Если хочется чего-то действительно обновляемого, лучше посмотреть в сторону Playwright.
LLlAMuJIb
15.01.2024 10:43Я всё таки приверженец того, чтобы на первом уровне тесты читались как можно более понятно и старались содержать Arrange-Act-Assert на видимом уровне, даже, если это противоречит принципу DRY; а также, чтобы названия методов были полностью однозначны.
Например, тест, состоящий из одной строки сам по себе не очень информативный, и когда в репозиторий автотестов коммитят больше 5 человек это может сыграть злую шутку.
def test_main_page():
MainPage.verify_page()Я бы вытащил сюда явное открытие страницы и verify_page заменил бы на VerifyHeaderAndNews. А лучше бы разбил на два метода, или даже на два теста. Ибо если отвалится хэдер, но будет лента - мы об этом узнаем не сразу.
Еще, кстати, вопрос, почему внутри одного проекта выбраны различные нотации именования? для классов CamelCase, а для имен файлов и методов snake_case?UnusualLetter Автор
15.01.2024 10:43Еще, кстати, вопрос, почему внутри одного проекта выбраны различные нотации именования? для классов CamelCase, а для имен файлов и методов snake_case?
https://peps.python.org/pep-0008/#package-and-module-names
Чуть ниже про классы, методы.LLlAMuJIb
15.01.2024 10:43Какой ужасный гайдлайн у этого вашего пайтона. Но ок, вопрос закрыт, спасибо =)
SaM1808
15.01.2024 10:43А можно подробнее зачем вам вообще Singleton ? Какую проблему вы решаете?
UnusualLetter Автор
15.01.2024 10:43Убираю копипасту и лишний код. Если браузер всегда один, зачем его всегда упоминать?
Код выглядит (и что немаловажно, читается) намного лучше, если сравнить "до" и "после".SaM1808
15.01.2024 10:43Извините, но... по мне так слишком громоздко, непонятно и не удобно :). Наверное, от того и не удобно, от того что не понятно. Уверен, что не внимательно смотрел... :
сделайте класс браузера, оберните его в фикстуру и укажите его один раз в начале теста... зачем там Singleton и связанные с этим ограничения (только старый Selenium) - я так и не понял;
поиск элемента... он что один раз ищется? Ну если Stale то ещё раз... Почему бы в эту "магию" не добавить простой поиск пока не найдется в заданный таймаут?
таймауты.... 5 секунд на ожидание элемента... опрометчивый хардкод, ИМНО
отчеты... как их читать если в тесте по 60 кликов и 20 инпутов... по уму ещё скрины надо прикрутить, но тогда это неподъемная штука получится...
Не-не, каждый пишет как ему удобно, в зависимости от поставленных задач, не в коем случае не осуждаю, просто делаю иначе.
UnusualLetter Автор
15.01.2024 10:43сделайте класс браузера, оберните его в фикстуру
А потом прокидывать его в параметры везде, где есть обращение к драйверу. Параметр ради параметра.
и укажите его один раз в начале теста
Не один раз в начале теста, а один раз в начале каждого теста. Зачем?
и связанные с этим ограничения (только старый Selenium)
Selenium 4.* — это про работу с Selenium Grid, который во всём хуже, чем Selenoid
поиск элемента... он что один раз ищется?
Не понял вопроса. Все операции проводятся с webelement DOM-дерева, пока он жив в дереве, зачем его еще раз искать?
таймауты.... 5 секунд на ожидание элемента... опрометчивый хардкод, ИМНО
Сделать нормальное ожидание никто не запрещает. Здесь статья про архитектуру, не про ожидание.
отчеты... как их читать если в тесте по 60 кликов и 20 инпутов...
Рекомендую к прочтению документацию к Allure — https://allurereport.org/docs/pytest/
по уму ещё скрины надо прикрутить, но тогда это неподъемная штука получится...
Скриншоты легко прикручиваются на teardown, достаточно проверить статус теста, и в случае падения делать скриншот.
LLlAMuJIb
15.01.2024 10:43Здесь статья про архитектуру, не про ожидание.
Если говорить об архитектуре, то, думаю, важно еще подчеркнуть необходимость вытаскивания из PageObject жирной логики в классы степов или хелперов (будь они не ладны), а проверки в классы ассертов.
Т.е. ну держать элемент и атомарную обертку над этим элементом - это ок.
Но как только над элементом или группой элементов будет производиться набор действий согласно бизнес логике, такие классы начинают слишком жирно разрастаться. Оглянуться не успеешь, как у тебя уже 1000+ строк в файле.А рефачить будет больно. Лучше сразу разделять по семантике, по началу будет казаться, что это не выгодно, но на долгой дистанции очень удобно
Kalcefer
А почему на Пайтоне? На хейзенбаге показывали тесты на Go, и вами же разработанный allureGo
UnusualLetter Автор
Есть какие-то легаси тесты и на python.