Полное руководство по E2E тестированию с Python
Playwright — это библиотека для автоматизации браузеров от Microsoft. В паре с Pytest получается мощный фреймворк для E2E тестирования веб-приложений.
playwright
Кросс-браузерная автоматизация (Chromium, Firefox, WebKit)
pytest
Тестовый фреймворк с фикстурами и параметризацией
pytest-playwright
Интеграция Playwright в Pytest
playwright codegen
Генерация кода из действий в браузере
Преимущества связки:
Автоматическое управление браузером • Встроенные фикстуры Pytest • Скриншоты и видео • Ожидание элементов • Перехват сетевых запросов • JavaScript виконание
Создайте виртуальное окружение и установите зависимости
# Создание виртуального окружения 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
Если установка браузеров зависает, используйте команду с правами администратора или установите браузеры по отдельности:
playwright install chromium
# 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] testpaths = tests python_files = test_*.py python_classes = Test* python_functions = test_* addopts = --tb=short -v # Скриншоты при падении playwright_screenshot = only-on-failure
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()
Playwright поддерживает множество стратегий поиска элементов. Приоритет: CSS → XPath → текст.
| Локатор | Пример | Описание |
|---|---|---|
| ID | page.locator("#id") | По атрибуту id |
| CSS | page.locator(".class") | CSS селектор |
| Text | page.get_by_text("Click") | По тексту |
| Role | page.get_by_role("button") | По ARIA role |
| Label | page.get_by_label("Email") | По label |
| Placeholder | page.get_by_placeholder("Search") | По placeholder |
| XPath | page.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")
# Клик 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()
# Проверки видимости 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()
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)
@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()
@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"
# Скриншот элемента 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"
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"
# .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/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/
# Установка pip install allure-pytest # Запуск с отчётом pytest --alluredir=allure-results allure serve allure-results # Генерация HTML allure generate allure-results -o allure-report --clean
# Режим отладки (браузер открыт, ждёт) 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 | Только упавшие тесты |
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
Порядок приоритетов локаторов:
1. get_by_role() → 2. get_by_label() → 3. get_by_text() → 4. locator("#id") → 5. locator(".class") → 6. XPath