Приветствую, Хабр! Меня зовут Владислав Тимашенков, я занимаюсь автоматизацией тестирования в ГК 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, которую можно использовать практически в любом тесте.

При этом мы не написали ни одной надстройки над BrowserBrowserContext или 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 вместо написания собственных обёрток.

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