Приветствую, Хабр! Меня зовут Владислав Тимашенков, я занимаюсь автоматизацией тестирования в ГК Infowatch.
В этой статье мы построим основу лаконичного фреймворка для UI-автотестов. Настроим запуск браузера, подготовим авторизацию, создадим Page Object и напишем тест. Здесь не будет лишних обёрток и сложной инфраструктуры, не будет даже ИИ и прочего хайпа. Только проверенные решения на базе Playwright, готовые к использованию.
Главная идея — максимально использовать возможности самого Playwright, а не строить вокруг него собственный слой абстракций.
Договоримся, что у нас уже установлены playwright со всеми зависимостями и pytest. Будем использовать браузер Chromium.
Все примеры кода, приведенные в статье, используются в реальном проекте (но адаптированы для публикации). Их можно использовать в собственном фреймворке с минимальными изменениями.
Запускаем браузер
Для взаимодействия с UI необходимо запустить процесс браузера, создать контекст и открыть новую страницу.
# ../conftest.py import pytest from playwright.sync_api import sync_playwright BROWSER_SETTINGS: dict = {"headless": False, "slow_mo": 200} @pytest.fixture(scope="session") def browser(): """ Запускает процесс браузера. """ with sync_playwright() as p: browser = p.chromium.launch(**BROWSER_SETTINGS) try: yield browser finally: browser.close() # Гарантируем закрытие процесса браузера, явная подстраховка.
Обратите внимание, что аргументы для вызова p.chromium.launch вынесены отдельно в константу BROWSER_SETTINGS. В примере для статьи настройки заданы прямо в коде, но в реальном проекте их обычно получают из конфигурационного слоя: переменных окружения или конфигурационных файлов. Это позволяет использовать разные параметры запуска браузера на разных окружениях. Можно подготовить несколько конфигураций и механизм их выбора в зависимости от окружения.
Процесс браузера запускается один раз на всю сессию. Это уменьшает накладные расходы и ускоряет выполнение тестов. Изоляция между ними обеспечивается не отдельными процессами браузеров, а независимыми BrowserContext.
Настраиваем авторизацию без UI
# ../conftest.py import pytest import requests from urllib.parse import urlparse from ..settings import settings # settings - конфигурационный модуль, загружающий параметры окружения @pytest.fixture(scope="session") def auth_data_factory(): """ Возвращает фабрику для получения состояния аутентификации (cookies) через API Позволяет выполнять авторизацию в тестах без использования UI. """ def _auth(username, password) -> list[dict]: response = requests.post( f"{settings.base_url}/api/login", json={ "username": username, "password": password, }, ) response.raise_for_status() cookies = [ { "name": name, "value": value, "domain": urlparse(settings.base_url).hostname, "path": "/", } for name, value in response.cookies.items() ] return cookies return _auth
Используем фикстуру-фабрику вместо фиксированной фикстуры с данными авторизации для конкретного пользователя. Такой подход позволяет получать данные для любых пользователей прямо во время выполнения тестов без создания отдельных фикстур под каждого юзера.
В статье используется авторизация через cookies, но для приложений, работающих с JWT, OAuth или Bearer Token, данные можно передавать через extra_http_headers либо использовать storage_state.
Далее под данными авторизации будем понимать полученные после входа в систему cookies, которые используются для создания авторизованного браузерного контекста.
# ../conftest.py import pytest from ..helpers import create_test_user @pytest.fixture(scope="session") def auth_user(auth_data_factory): """ Получает данные для авторизации пользователя. """ username, password = create_test_user() # Пример хелпера для возврата данных авторизации тестового юзера return auth_data_factory(username, password)
Фикстура auth_user — пример использования фабрики. Она предоставляет данные авторизации пользователя, который подходит для большинства тестов проекта. Обычно это заранее созданный или предустановленный пользователь с максимальным набором прав.
Создаем изолированные контексты
Теперь можно приступать к созданию контекста браузера. В Playwright изоляция теста достигается через BrowserContext, поэтому он становится центральной единицей нашего фреймворка.
# ../conftest.py import pytest from ..settings import settings CONTEXT_SETTINGS: dict = { "base_url": settings.base_url, "locale": "ru-RU", "timezone_id": "Europe/Moscow", } @pytest.fixture(scope="function") def context_factory(browser): """ Предоставляет фабрику для создания браузерных контекстов с заданными данными авторизации. Автоматически закрывает все созданные контексты после завершения теста. """ contexts = [] # Сохраняем все созданные тестом контексты для управления их жизненным циклом def _context(auth_data): context = browser.new_context(**CONTEXT_SETTINGS) context.add_cookies(auth_data) contexts.append(context) return context try: yield _context finally: for ctx in contexts: ctx.close() # Гарантируем закрытие всех созданных контекстов
Настройки контекста, как и настройки браузера, вынесены в отдельную конфигурацию. Это позволяет централизованно управлять параметрами окружения и не дублировать их в тестах.
Для создания контекстов также используется фабрика. Один тест может одновременно работать с несколькими пользователями, а значит — ему потребуется несколько независимых BrowserContext. Фабрика позволяет создавать их без дополнительных фикстур.
Именно BrowserContext обеспечивает изоляцию тестов в Playwright. Каждый контекст имеет собственные cookies, localStorage и sessionStorage, поэтому состояние одного теста не повлияет на остальные.
Создаем фикстуру страницы
Используя фабрики авторизации и контекста, мы можем просто и гибко создавать объекты страниц с уже выполненной авторизацией.
# ../conftest.py import pytest @pytest.fixture(scope="function") def page(auth_user, context_factory): """ Создает новый браузерный контекст и страницу с данными авторизации. """ user_context = context_factory(auth_user) page = user_context.new_page() yield page # page закрывается автоматически, при закрытии BrowserContext Playwright автоматически закрывает все принадлежащие ему страницы.
Фикстура page объявлена в корневом conftest проекта, потому что данные авторизации созданного пользователя (фикстура auth_user) подходят для большей части тестов. Обратите внимание, что скоуп фикстуры определен на функцию, чтобы каждый тест получал новую страницу. Фикстура auth_user использует заранее полученные данные авторизации и не выполняет повторный вход через API или UI.
Теперь инфраструктура для запуска UI-тестов полностью готова. У нас есть один процесс браузера на всю сессию, независимые браузерные контексты, авторизация через API и фикстура page, которую можно использовать практически в любом тесте.
При этом мы не написали ни одной надстройки над Browser, BrowserContext или Page. Мы построили фреймворк вокруг возможностей Playwright, а не поверх них. А чем меньше собственной инфраструктуры приходится поддерживать, тем проще сопровождать проект и обновлять Playwright.
Реализуем Page Object
В объектах Page и Locator уже реализовано большое количество возможностей для поиска локаторов и взаимодействия с ними, включая автоожидания. Это упрощает логику page objects на проекте.
Для примера опишем страницу взаимодействия с сущностью продукта под названием Тег.
# ../pages/common.py from enum import StrEnum class Endpoints(StrEnum): TAGS = "/tags"
Для примера используем различные выражения для поиска локаторов.
# ../pages/tags_page.py from playwright.sync_api import Locator, Page from ..pages.common import Endpoints class TagsPageLocators: CREATE_BUTTON = '[data-qa="tag-create"]' DELETE_BUTTON = '[data-qa="tag-delete"]' SAVE_BUTTON_TEXT = "Сохранить" NAME_INPUT = '[data-qa="tag_display_name"]' NOTE_INPUT = '[data-qa="tag_note"]' TAG_ITEMS = '.ag-center-cols-container [role="row"]' COL_NAME = '[col-id="DISPLAY_NAME"]' class TagsPage: """ Page Object для страницы управления тегами. Инкапсулирует локаторы элементов страницы и методы для взаимодействия с ней. """ URL = Endpoints.TAGS def __init__(self, page: Page): self.page = page def open(self): """Открывает страницу тегов по заданному URL.""" self.page.goto(self.URL) @property def create_button(self) -> Locator: """Локатор кнопки создания нового тега.""" return self.page.locator(TagsPageLocators.CREATE_BUTTON) @property def delete_button(self) -> Locator: """Локатор кнопки удаления тега.""" return self.page.locator(TagsPageLocators.DELETE_BUTTON) @property def name_input(self) -> Locator: """Локатор поля ввода имени тега.""" return self.page.locator(TagsPageLocators.NAME_INPUT) @property def note_input(self) -> Locator: """Локатор поля ввода описания тега.""" return self.page.locator(TagsPageLocators.NOTE_INPUT) @property def save_button(self) -> Locator: """Локатор кнопки сохранения тега.""" return self.page.get_by_role("button", name=TagsPageLocators.SAVE_BUTTON_TEXT) @property def _tag_items(self) -> Locator: """Локатор списка всех тегов.""" return self.page.locator(TagsPageLocators.TAG_ITEMS) def create_tag(self, name: str, note: str): """Создает новый тег.""" self.create_button.click() self.name_input.fill(name) self.note_input.fill(note) self.save_button.click() def delete_tag_by_name(self, name): """Удаляет тег по его имени.""" self.get_tag_by_name(name).click() self.delete_button.click() def get_tag_by_name(self, name) -> Locator: """Возвращает локатор тега, найденного по имени.""" return self._tag_items.filter( has=self.page.locator(f'{TagsPageLocators.COL_NAME}[title="{name}"]') )
В Playwright локаторы «ленивые»: объект Locator только описывает способ поиска элемента и практически не требует ресурсов при создании. Поиск элемента выполняется именно в момент взаимодействия с локатором (click(), fill(), expect() и т.д.), поэтому Playwright всегда работает с актуальным состоянием DOM.
Локаторы удобно объявлять через property: при каждом обращении создается новый объект Locator, который будет искать элемент в актуальном состоянии страницы.
Дополнительным преимуществом является более лаконичный синтаксис: self.create_button.click() вместо self.create_button().click().
Page Object содержит только бизнес-логику взаимодействия со страницей. Собственные методы ожидания, повторные поиски элементов и другие вспомогательные обёртки не требуются, так как это уже реализовано в Playwright.
Структура Page Object может отличаться от проекта к проекту. Локаторы можно хранить в классе страницы, вынести в отдельный класс или сгруппировать по компонентам. Принципиальной разницы нет, но важно сохранять единообразие внутри проекта.
Пишем первый тест
# ../tests/ui/tags/conftest.py import pytest from ..pages.tags_page import TagsPage from faker import Faker FAKE = Faker() # Простая генерация тестовых данных @pytest.fixture(scope="function") def tags_page(page) -> TagsPage: t_page = TagsPage(page) t_page.open() return t_page @pytest.fixture def tag_test_data(): name = FAKE.pystr(min_chars=10, max_chars=30) note = FAKE.pystr(min_chars=30, max_chars=256) return name, note
Создаем объект страницы в фикстуре tags_page и сразу открываем нужную страницу. Благодаря этому каждый тест начинается из заранее известного состояния и не зависит от действий предыдущих тестов.
# ../tests/ui/tags/test_tags.py from playwright.sync_api import expect def test_create_tag(tags_page, tag_test_data): name, note = tag_test_data tags_page.create_tag(name, note) tag = tags_page.get_tag_by_name(name) # Локатор только создается и не взаимодействует с элементом в DOM. expect(tag).to_be_visible() # Начинается взаимодействие с созданным локатором: поиск и ожидания.
Обратите внимание, что проверка ожидаемого результата выполняется через expect(), а не через assert tag.is_visible(). Это очень важно, потому что assert выполняет проверку условия однократно без дополнительных логик. А при вызове expect(tag).to_be_visible() происходит следующее:
expect(tag)создаёт assertion-объект, привязанный к локатору. Поиск элемента ещё не выполняетсяto_be_visible()запускает цикл повторяющихся проверок: ищет элемент через локатор и проверяет его состояниеПроверка считается успешной только при достижении состояния “visible”
При успешном выполнении тест продолжается, а при превышении таймаута формируется информация об ошибке.
Поэтому expect лучше использовать для проверки UI, а assert — для проверки обычных Python-объектов.
Пример информации об ошибке:
E Error: element(s) not found E Call log: E - Expect "to_be_visible" with timeout 5000ms E - waiting for locator(".ag-center-cols-container [role=\"row\"]").filter(has=locator("[col-id=\"DISPLAY_NAME\"][title=\"Название тега\"]"))
Итоги
Теперь у нас есть полноценная основа UI-фреймворка: управление браузером, изолированные контексты, авторизация через API, Page Object и лаконичные тесты, использующие возможности Playwright напрямую.
Такой подход хорошо масштабируется: по мере роста проекта появляются новые Page Object, а инфраструктура практически не меняется. Благодаря этому новые тесты обычно сводятся к повторному использованию существующих объектов страниц и добавлению новых локаторов при необходимости. Именно в этом и заключается основная идея — построить минималистичный фундамент, который максимально использует возможности Playwright вместо написания собственных обёрток.
В следующей части рассмотрим остальные компоненты фреймворка: сохранение подробных трейсов, политику ретраев, организацию проекта, конфигурацию окружений и другие практические элементы.

