Playwright + Pytest

Полное руководство по E2E тестированию с Python

1. Введение

Playwright — это библиотека для автоматизации браузеров от Microsoft. В паре с Pytest получается мощный фреймворк для E2E тестирования веб-приложений.

playwright

Кросс-браузерная автоматизация (Chromium, Firefox, WebKit)

pytest

Тестовый фреймворк с фикстурами и параметризацией

pytest-playwright

Интеграция Playwright в Pytest

playwright codegen

Генерация кода из действий в браузере

Преимущества связки:

Автоматическое управление браузером • Встроенные фикстуры Pytest • Скриншоты и видео • Ожидание элементов • Перехват сетевых запросов • JavaScript виконание

2. Установка
1

Установка библиотек

Создайте виртуальное окружение и установите зависимости

# Создание виртуального окружения
python -m venv venv
source venv/bin/activate  # Linux/macOS
# или
venv\Scripts\activate  # Windows

# Установка Playwright и pytest-playwright
pip install pytest pytest-playwright

# Установка браузеров (один раз)
playwright install
# Или через poetry
poetry add pytest pytest-playwright
poetry run playwright install
Windows PowerShell:

Если установка браузеров зависает, используйте команду с правами администратора или установите браузеры по отдельности:

playwright install chromium

3. Базовая настройка

conftest.py — конфигурация фикстур

# conftest.py
import pytest
from playwright.sync_api import sync_playwright


@pytest.fixture(scope="session")
def browser_context(browser_type_launch_args):
    """Фикстура браузера на всю сессию."""
    with sync_playwright() as p:
        browser = p.chromium.launch(**browser_type_launch_args)
        yield browser
        browser.close()


@pytest.fixture(scope="function")
def page(browser_context):
    """Каждая функция получает чистую страницу."""
    context = browser_context.new_context()
    page = context.new_page()
    yield page
    context.close()


@pytest.fixture(scope="session")
def base_url():
    """Базовый URL для тестов."""
    return "https://example.com"

pytest.ini — конфигурация

# pytest.ini
[pytest]
testpaths = tests
python_files = test_*.py
python_classes = Test*
python_functions = test_*
addopts = --tb=short -v
# Скриншоты при падении
playwright_screenshot = only-on-failure
4. Page Object Pattern

Page Object — паттерн для инкапсуляции взаимодействия с элементами страницы. Упрощает поддержку и читаемость тестов.

Базовый Page Object

# pages/login_page.py
class LoginPage:
    def __init__(self, page):
        self.page = page
        self.username_input = page.locator("#username")
        self.password_input = page.locator("#password")
        self.submit_button = page.locator("button[type=submit]")
        self.error_message = page.locator(".alert-error")

    def open(self):
        self.page.goto("/login")

    def login(self, username, password):
        self.username_input.fill(username)
        self.password_input.fill(password)
        self.submit_button.click()

    def get_error(self):
        return self.error_message.text_content()

Использование в тестах

# tests/test_login.py
import pytest
from pages.login_page import LoginPage


@pytest.fixture
def login_page(page):
    return LoginPage(page)


@pytest.mark.parametrize("username,password,expected_error", [
    ("wrong", "wrong", "Неверные учетные данные"),
    ("", "password", "Введите имя пользователя"),
])
def test_login_validation(login_page, expected_error):
    login_page.open()
    login_page.login(username, password)
    assert expected_error in login_page.get_error()
5. Локаторы

Playwright поддерживает множество стратегий поиска элементов. Приоритет: CSS → XPath → текст.

Локатор Пример Описание
IDpage.locator("#id")По атрибуту id
CSSpage.locator(".class")CSS селектор
Textpage.get_by_text("Click")По тексту
Rolepage.get_by_role("button")По ARIA role
Labelpage.get_by_label("Email")По label
Placeholderpage.get_by_placeholder("Search")По placeholder
XPathpage.locator("xpath=//button")XPath выражение

Примеры локаторов

# ID селектор
page.locator("#submit-btn")

# CSS селектор с атрибутами
page.locator("button[type='submit'][data-qa='login']")

# Текст элемента (точное совпадение)
page.get_by_text("Войти")

# Текст элемента (содержит)
page.get_by_text("Войти", exact=False)

# По ARIA label
page.get_by_label("Имя пользователя")

# По роли
page.get_by_role("button", name="Отправить")
page.get_by_role("alert")

# XPath (для сложных запросов)
page.locator("xpath=//div[contains(@class, 'card')]//button[last()]")

Ожидание элементов

# По умолчанию: ждать до появления (timeout 30 сек)
page.locator("#content").click()

# Явное ожидание
page.locator("#modal").wait_for(state="visible")
page.locator(".spinner").wait_for(state="hidden")

# С timeout
page.locator("#result").click(timeout=5000)

# Ждать URL
page.wait_for_url("**/success")

# Ждать load state
page.wait_for_load_state("networkidle")
6. Действия

Клик и ввод

# Клик
page.locator("#btn").click()
page.locator("#btn").dblclick()
page.locator("#btn").click(button="right")  # ПКМ

# Заполнить поле ввода
page.locator("#input").fill("text")
page.locator("#input").type("text", delay=100)  # Посимвольно

# Очистить и ввести
page.locator("#input").clear()
page.locator("#input").fill("new value")

# Нажать клавишу
page.locator("#input").press("Enter")
page.locator("#input").press("Control+A")
page.locator("body").press("Escape")

Навигация

# Переход по URL
page.goto("https://example.com")
page.goto("/login")

# Назад/вперёд
page.go_back()
page.go_forward()

# Обновить страницу
page.reload()

# Получить URL и заголовок
page.url
page.title()

Проверки (assertions)

# Проверки видимости
page.locator("#element").is_visible()
page.locator("#element").is_hidden()
page.locator("#element").is_enabled()
page.locator("#element").is_disabled()

# Проверки содержимого
page.locator("#title").text_content()  # -> "Заголовок"
page.locator("#title").inner_text()   # -> "  Заголовок  "
page.locator("#title").inner_html()   # -> "<b>Заголовок</b>"
page.locator("#input").input_value()  # -> "value"
page.locator("#checkbox").is_checked()

# Ожидание проверки
assert page.locator("#result").filter(has_text="Успешно").is_visible()
7. Работа с сетью

Перехват запросов (MOCK)

def test_api_mock(page):
    # Перехватить API запрос
    page.route("**/api/users", lambda route: route.fulfill(
        status=200,
        content_type="application/json",
        body='{"users": [{"id": 1, "name": "Test User"}]}'
    ))

    page.goto("/users")
    assert page.locator(".user-name").text_content() == "Test User"

Ожидание ответов

# Дождаться конкретного запроса
with page.expect_response("**/api/data") as response_info:
    page.locator("#load-btn").click()
response = response_info.value
assert response.status == 200

# Дождаться запроса с проверкой
with page.expect_request("**/analytics") as request:
    page.locator("#track-btn").click()
assert "event=click" in request.value.url

Обработка ошибок

# Заблокировать рекламу
page.route("**/ads/**", lambda route: route.abort())

# Изменить оригинальный ответ
def modify_response(route):
    fetched_response = route.fetch()
    # Изменяем JSON
    json_data = fetched_response.json()
    json_data["price"] = 0
    route.fulfill(
        status=fetched_response.status,
        content_type="application/json",
        body=json.dumps(json_data)
    )

page.route("**/product/**", modify_response)
8. Фикстуры Pytest + Playwright

Авторизованный контекст

@pytest.fixture(scope="module")
def auth_context(browser_context, base_url):
    """Авторизованный контекст для тестов."""
    context = browser_context.new_context()
    page = context.new_page()
    page.goto(f"{base_url}/login")
    page.locator("#username").fill("testuser")
    page.locator("#password").fill("password")
    page.locator("button[type=submit]").click()
    page.wait_for_url("**/dashboard")
    yield context
    context.close()


@pytest.fixture
def authenticated_page(auth_context):
    return auth_context.new_page()

База данных

@pytest.fixture(scope="session")
def db_connection():
    """Подключение к тестовой БД."""
    conn = psycopg2.connect("postgresql://test:test@localhost/test")
    yield conn
    conn.close()


@pytest.fixture(scope="function")
def clean_db(db_connection):
    """Очистка БД после каждого теста."""
    yield
    with db_connection.cursor() as cur:
        cur.execute("TRUNCATE TABLE users RESTART IDENTITY CASCADE")
    db_connection.commit()

Page Object Factory

@pytest.fixture
def login_page(page):
    """Фабрика Page Objects."""
    from pages import LoginPage, DashboardPage, ProfilePage
    page.goto("/")
    return {
        "login": LoginPage(page),
        "dashboard": DashboardPage(page),
        "profile": ProfilePage(page),
    }


def test_user_profile(login_page):
    login_page["login"].open()
    login_page["login"].login("user", "pass")
    profile_page = login_page["profile"]
    profile_page.open()
    assert profile_page.get_username() == "user"
9. Скриншоты и видео
# Скриншот элемента
page.locator("#widget").screenshot(path="widget.png")

# Полный скриншот страницы
page.screenshot(path="full_page.png", full_page=True)

# Видео запись (включить в контексте)
@pytest.fixture
def video_context(browser_context):
    context = browser_context.new_context(
        record_video_dir="videos/",
        record_video_size="1920x1080"
    )
    yield context
    context.close()


def test_with_video(page):
    page.goto("/complex-form")
    # ... действия ...
    # Получить путь к видео после закрытия контекста
    video_path = page.video.path()

Автоматические скриншоты при падениях

# pytest.ini или pyproject.toml
[pytest]
playwright_screenshot = only-on-failure

# Или в conftest.py
@pytest.fixture(scope="session")
def browser_context(browser_type_launch_args, pytest_config):
    # screenshot на падение включен по умолчанию
    # Но можно настроить:
    pytest_config.stash["playwright"]["screenshot"] = "only-on-failure"
10. Паралленое выполнение

Pytest-xdist позволяет запускать тесты параллельно для ускорения.

# Установка
pip install pytest-xdist

# Запуск в 4 потока
pytest -n 4

# Автоопределение количества ядер
pytest -n auto

# С файлом распределением
pytest --dist loadfile -n auto

Важно: При параллельном запуске:

1. Каждый воркер создаёт свой браузер (фикстура session)

2. Фикстуры module/class делят контекст в рамках одного воркера

3. Используйте уникальные данные для параллельных тестов

Правила параллелизации

# conftest.py
@pytest.fixture
def unique_user(db_connection, worker_id):
    """Уникальный пользователь для каждого воркера."""
    username = f"user_{worker_id}"
    # создаём пользователя
    yield username
    # очищаем


# Или через pytest-xdist
def worker_id(pytestconfig):
    if hasattr(pytestconfig, "workerinput"):
        return pytestconfig.workerinput["workerid"]
    return "master"
11. CI/CD интеграция

GitLab CI

# .gitlab-ci.yml
stages:
  - test

e2e-tests:
  stage: test
  image: python:3.11
  services:
    - postgres:15
  before_script:
    - pip install -r requirements.txt
    - playwright install --with-deps chromium
  script:
    - pytest tests/e2e -v --tb=short
  artifacts:
    when: always
    paths:
      - screenshots/
      - videos/
      - htmlcov/
    reports:
      junit: junit.xml
  parallel: 4

GitHub Actions

# .github/workflows/e2e.yml
name: E2E Tests
on: [push, pull_request]

jobs:
  e2e:
    runs-on: ubuntu-latest
    strategy:
      matrix:
        shard: [1, 2, 3, 4]
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-python@v5
        with:
          python-version: '3.11'
      - name: Install dependencies
        run: |
          pip install -r requirements.txt
          playwright install --with-deps chromium
      - name: Run tests
        run: pytest --shard=${{ matrix.shard }}/4
      - uses: actions/upload-artifact@v4
        if: always()
        with:
          name: test-results-${{ matrix.shard }}
          path: |
            screenshots/
            videos/

Allure Report

# Установка
pip install allure-pytest

# Запуск с отчётом
pytest --alluredir=allure-results
allure serve allure-results

# Генерация HTML
allure generate allure-results -o allure-report --clean
12. Отладка
# Режим отладки (браузер открыт, ждёт)
pytest --headed

# Замедление (slow Mo)
pytest --slowmo=1000  # 1 секунда между действиями

# Debug порт для playwright
# Используйте playwright.sync_api.debugger = True

# Trace viewer
@pytest.fixture
def page(browser_context, pytest_config):
    context = browser_context.new_context(record_video_dir="videos/")
    page = context.new_page()
    # Включаем trace
    page.context.tracing.start(screenshots=True, snapshots=True)
    yield page
    page.context.tracing.stop(path="trace.zip")
    context.close()

# Просмотр trace:
# python -m playwright show-trace trace.zip

Полезные команды

Команда Описание
--headedПоказать браузер
--slowmo=500Замедлить на 500ms
--timeout=10000Таймаут 10 сек
-k "test_name"Запустить по имени
-xОстановиться на первом падении
--lfТолько упавшие тесты
13. Лучшие практики

Структура проекта

tests/
├── conftest.py           # Глобальные фикстуры
├── pages/
│   ├── __init__.py
│   ├── base_page.py      # Базовый класс
│   ├── login_page.py
│   └── dashboard_page.py
├── api/
│   └── client.py         # API клиент
├── fixtures/
│   └── data.py           # Тестовые данные
└── test_*.py             # Тесты

pytest.ini
requirements.txt
README.md

Рекомендации

  • Один assertion за тест — проще анализировать ошибки
  • Page Object для каждой страницы — избегаем селекторов в тестах
  • Фикстуры для общих данных — не дублируйте setup/teardown
  • Уникальные локаторы — data-testid > id > CSS > XPath
  • Ожидайте элементы — не time.sleep()
  • Чистые URL — используйте base_url
  • Скриншоты при падениях — настраивайте в pytest.ini
  • Параллельный запуск — экономьте время CI

Порядок приоритетов локаторов:

1. get_by_role() → 2. get_by_label() → 3. get_by_text() → 4. locator("#id") → 5. locator(".class") → 6. XPath