Привет, меня зовут Коля, занимаюсь тестированием 7 лет, автоматизацией — 6 лет. Так уж сложилось, что не особо люблю WebUI-тесты, но почему-то именно они у меня получаются лучше всего.

В один день мне позвонил мой друг Рома и сказал: «Коля, помнишь ты у нас автотесты делал с селениумом? Помоги мне сделать так же красиво».

Отправив Роме ссылку на свой гитхаб и устроив пару созвонов с объяснениями, что и как работает, я добился от него заветного «Я понял». 

Спустя два дня Рома позвонил снова: «Коля, я всё понял, а вот ребята в моей команде — нет. Помоги мне объяснить им».

Внутри я расскажу от том, как:

  • ускорить написание тестов;

  • снизить затраты на их поддержку;

  • прокачаться в написании фреймворков;

  • сэкономить деньги компании;

  • сохранить нервы сотрудников.

Что внутри статьи

В статье будет описана эволюция фреймворка для тестов, использующего:

  • Python;

  • Pytest;

  • Selenium (версии 3).

Использование Selenium версии 3.* обусловлено тем, что Selenium 4.*-вебдрайвер не дружит с синглтоном. На мой взгляд, причин для перехода с 3 на 4 нет совсем — изменения только в работе с Selenium Grid, который во всём проигрывает Selenoid. Если есть желание использовать новое-новое, то лучше посмотреть в сторону Playwright — с небольшими изменениям код из статьи можно адаптировать и под него.

Двигаться по статье будем так:

  1. пишем работающий код;

  2. разбираем, почему код выглядит плохо;

  3. улучшаем код;

  4. GOTO 1.

Установить всё необходимое поможет этот набор статей — Selenium для Python. Глава 1. Установка / Хабр (habr.com)

Статья рассчитана на опытного специалиста, который уже знает такие слова, как xpath, selenium webdriver, и уже имел опыт с Pytest, поэтому я пропущу объяснения основ.

Пишем тесты

Для примера возьмём два простых сценария:

  1. зайти на habr.com, проверить, что мы есть на хабре;

  2. зайти на 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)


  1. Kalcefer
    15.01.2024 10:43
    +1

    А почему на Пайтоне? На хейзенбаге показывали тесты на Go, и вами же разработанный allureGo


    1. UnusualLetter Автор
      15.01.2024 10:43
      +1

      Есть какие-то легаси тесты и на python.


  1. Frel0n
    15.01.2024 10:43

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


    1. UnusualLetter Автор
      15.01.2024 10:43

      но по мне как то не очень накручивать логику в странице, для это лучше использовать слой операций

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


  1. san-kolts
    15.01.2024 10:43

    Полезный для изучения новичками подход, однако несколько устаревший: основывать фреймворк на селениуме 3 крайне плохая идея, потому что максимальная версия питона, с которым он работает - Python 3.8, который станет депрекейтед в 2024 году. Ну и сам селениум 3 давно не обновляется.


    1. UnusualLetter Автор
      15.01.2024 10:43
      +1

      Код из статьи прекрасно работает на python 3.11.

      Ну и сам селениум 3 давно не обновляется.

      И не будет обновляться, при этом 4 версия больше про работу с Selenium Server, нежели с Selenium Webdriver. Если хочется чего-то действительно обновляемого, лучше посмотреть в сторону Playwright.


  1. LLlAMuJIb
    15.01.2024 10:43

    Я всё таки приверженец того, чтобы на первом уровне тесты читались как можно более понятно и старались содержать Arrange-Act-Assert на видимом уровне, даже, если это противоречит принципу DRY; а также, чтобы названия методов были полностью однозначны.

    Например, тест, состоящий из одной строки сам по себе не очень информативный, и когда в репозиторий автотестов коммитят больше 5 человек это может сыграть злую шутку.

    def test_main_page():
    MainPage.verify_page()

    Я бы вытащил сюда явное открытие страницы и verify_page заменил бы на VerifyHeaderAndNews. А лучше бы разбил на два метода, или даже на два теста. Ибо если отвалится хэдер, но будет лента - мы об этом узнаем не сразу.


    Еще, кстати, вопрос, почему внутри одного проекта выбраны различные нотации именования? для классов CamelCase, а для имен файлов и методов snake_case?


    1. UnusualLetter Автор
      15.01.2024 10:43

      Еще, кстати, вопрос, почему внутри одного проекта выбраны различные нотации именования? для классов CamelCase, а для имен файлов и методов snake_case?

      https://peps.python.org/pep-0008/#package-and-module-names
      Чуть ниже про классы, методы.


      1. LLlAMuJIb
        15.01.2024 10:43

        Какой ужасный гайдлайн у этого вашего пайтона. Но ок, вопрос закрыт, спасибо =)


  1. SaM1808
    15.01.2024 10:43

    А можно подробнее зачем вам вообще Singleton ? Какую проблему вы решаете?


    1. UnusualLetter Автор
      15.01.2024 10:43

      Убираю копипасту и лишний код. Если браузер всегда один, зачем его всегда упоминать?
      Код выглядит (и что немаловажно, читается) намного лучше, если сравнить "до" и "после".


      1. SaM1808
        15.01.2024 10:43

        Извините, но... по мне так слишком громоздко, непонятно и не удобно :). Наверное, от того и не удобно, от того что не понятно. Уверен, что не внимательно смотрел... :

        • сделайте класс браузера, оберните его в фикстуру и укажите его один раз в начале теста... зачем там Singleton и связанные с этим ограничения (только старый Selenium) - я так и не понял;

        • поиск элемента... он что один раз ищется? Ну если Stale то ещё раз... Почему бы в эту "магию" не добавить простой поиск пока не найдется в заданный таймаут?

        • таймауты.... 5 секунд на ожидание элемента... опрометчивый хардкод, ИМНО

        • отчеты... как их читать если в тесте по 60 кликов и 20 инпутов... по уму ещё скрины надо прикрутить, но тогда это неподъемная штука получится...

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


        1. UnusualLetter Автор
          15.01.2024 10:43

          сделайте класс браузера, оберните его в фикстуру

          А потом прокидывать его в параметры везде, где есть обращение к драйверу. Параметр ради параметра.

          и укажите его один раз в начале теста

          Не один раз в начале теста, а один раз в начале каждого теста. Зачем?

          и связанные с этим ограничения (только старый Selenium)

          Selenium 4.* — это про работу с Selenium Grid, который во всём хуже, чем Selenoid

          поиск элемента... он что один раз ищется?

          Не понял вопроса. Все операции проводятся с webelement DOM-дерева, пока он жив в дереве, зачем его еще раз искать?

          таймауты.... 5 секунд на ожидание элемента... опрометчивый хардкод, ИМНО

          Сделать нормальное ожидание никто не запрещает. Здесь статья про архитектуру, не про ожидание.

          отчеты... как их читать если в тесте по 60 кликов и 20 инпутов...

          Рекомендую к прочтению документацию к Allure — https://allurereport.org/docs/pytest/

          по уму ещё скрины надо прикрутить, но тогда это неподъемная штука получится...

          Скриншоты легко прикручиваются на teardown, достаточно проверить статус теста, и в случае падения делать скриншот.


          1. LLlAMuJIb
            15.01.2024 10:43

            Здесь статья про архитектуру, не про ожидание.

            Если говорить об архитектуре, то, думаю, важно еще подчеркнуть необходимость вытаскивания из PageObject жирной логики в классы степов или хелперов (будь они не ладны), а проверки в классы ассертов.


            Т.е. ну держать элемент и атомарную обертку над этим элементом - это ок.
            Но как только над элементом или группой элементов будет производиться набор действий согласно бизнес логике, такие классы начинают слишком жирно разрастаться. Оглянуться не успеешь, как у тебя уже 1000+ строк в файле.

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