Как сделать UI-тесты короткими, быстрыми и такими, которым верят
В тестах раздела 08 одни и те же локаторы повторялись в каждом тесте: поле «Логин», кнопка «Добавить», строки задач. Поменяют подпись поля — придётся править все тесты. Страница-объект (Page Object) — класс, который знает, как устроена страница, и умеет на ней действовать. Тест говорит на языке пользователя: «создать задачу», «взять в работу».
"""Страницы-объекты «Задачника»: локаторы и действия страниц — здесь, проверки — в тестах."""
from playwright.sync_api import Page
class LoginPage:
def __init__(self, page: Page):
self.page = page
self.login_field = page.get_by_label("Логин")
self.password_field = page.get_by_label("Пароль")
self.submit = page.get_by_role("button", name="Войти")
self.error = page.get_by_role("alert")
def open(self):
self.page.goto("/login")
return self
def login(self, login, password):
self.login_field.fill(login)
self.password_field.fill(password)
self.submit.click()
return TasksPage(self.page)
class TasksPage:
def __init__(self, page: Page):
self.page = page
self.user = page.get_by_test_id("user")
self.rows = page.get_by_test_id("task")
self.title_field = page.get_by_label("Название")
self.status_filter = page.get_by_label("Статус")
def open(self):
self.page.goto("/tasks")
return self
def create(self, title):
self.title_field.fill(title)
self.page.get_by_role("button", name="Добавить").click()
return self
def row(self, title):
return self.rows.filter(has_text=title)
def status_of(self, title):
return self.row(title).get_by_test_id("status")
def take_in_work(self, title):
self.row(title).get_by_role("button", name="В работу").click()
return self
def show_status(self, status):
self.status_filter.select_option(status)
self.page.get_by_role("button", name="Показать").click()
return self
def stat(self, name):
return self.page.get_by_test_id(f"stat-{name}")
Правила, на которых держится шаблон:
login, create, show_status. Метод, после которого открывается другая страница, возвращает её объект (login возвращает TasksPage); остающийся на месте — self, поэтому вызовы можно сцеплять.status_of, stat), а expect пишет тест. Иначе страница начнёт решать, что правильно, и её нельзя будет использовать в негативных тестах.Тесты стали короче и читаются как тест-кейс:
from playwright.sync_api import expect
def test_login_form(login_page):
tasks = login_page.login("ivan", "Ivan2026pass")
expect(tasks.user).to_have_text("ivan")
expect(tasks.rows).to_have_count(8)
def test_create(tasks_page):
tasks_page.create("Купить бумагу")
expect(tasks_page.status_of("Купить бумагу")).to_have_text("Новая")
expect(tasks_page.stat("total")).to_have_text("1")
def test_take_in_work(tasks_page):
tasks_page.create("Позвонить в банк").take_in_work("Позвонить в банк")
expect(tasks_page.status_of("Позвонить в банк")).to_have_text("В работе")
def test_filter_in_progress(tasks_page):
for title in ["Первая", "Вторая", "Третья"]:
tasks_page.create(title)
tasks_page.take_in_work("Вторая").show_status("in_progress")
expect(tasks_page.rows).to_have_count(1)
expect(tasks_page.status_of("Вторая")).to_have_text("В работе")
$ pytest -v ui
============================= test session starts ==============================
platform linux -- Python 3.13.5, pytest-9.2.0, pluggy-1.7.0 -- ~/qa/.venv/bin/python3
cachedir: .pytest_cache
metadata: {'Python': '3.13.5', 'Platform': 'Linux-6.12.95+deb13-amd64-x86_64-with-glibc2.41', 'Packages': {'pytest': '9.2.0', 'pluggy': '1.7.0'}, 'Plugins': {'metadata': '3.1.1', 'playwright': '0.10.0', 'locust': '2.47.0', 'html': '4.2.0'}}
rootdir: ~/qa
configfile: pytest.ini
plugins: metadata-3.1.1, playwright-0.10.0, locust-2.47.0, html-4.2.0
collecting ... collected 4 items
ui/test_tasks_po.py::test_login_form[chromium] PASSED [ 25%]
ui/test_tasks_po.py::test_create[chromium] PASSED [ 50%]
ui/test_tasks_po.py::test_take_in_work[chromium] PASSED [ 75%]
ui/test_tasks_po.py::test_filter_in_progress[chromium] PASSED [100%]
============================== 4 passed in 3.31s ===============================
Вход через форму нужен один раз — в тесте самой формы (test_login_form). Остальным тестам вход — подготовка, а не предмет проверки. Ходить через форму в каждом тесте медленно и рискованно: сломается форма — упадут все UI-тесты, и по отчёту не поймёшь, что сломалось одно место.
«Задачник» хранит сессию браузера в cookie sid, и это тот же токен, что выдаёт /api/login. Значит, можно получить токен запросом и положить его в cookie браузера до открытия страницы:
import pytest
from client import login_token
from pages import LoginPage, TasksPage
@pytest.fixture
def login_page(page):
return LoginPage(page).open()
@pytest.fixture
def tasks_page(page, base_url, user):
"""Вход через API: токен — в cookie sid, форму входа не заполняем."""
token = login_token(base_url, user["login"], user["password"])
page.context.add_cookies([{"name": "sid", "value": token, "url": base_url}])
return TasksPage(page).open()
Фикстура tasks_page отдаёт тесту уже открытую страницу задач нового пользователя. Как именно приложение хранит вход — cookie, заголовок, localStorage, — смотрят в DevTools на вкладке Application (Приложение) после обычного входа. У каждого приложения по-своему, но приём один: получить «пропуск» по API и положить туда, где браузер его ищет.
Упавший UI-тест в CI не посмотришь глазами. Поэтому pytest-playwright умеет сохранять для упавших тестов снимок экрана и трассу — запись всего теста по шагам:
from playwright.sync_api import expect
from pages import TasksPage
def test_counter_after_create(tasks_page: TasksPage):
tasks_page.create("Задача для снимка")
expect(tasks_page.stat("total")).to_have_text("2", timeout=2000)
$ pytest -q --tb=line fail --screenshot only-on-failure --tracing retain-on-failure
F [100%]
=================================== FAILURES ===================================
E AssertionError: Locator expected to have text '2'
Actual value: 1
Call log:
- Expect "to_have_text" get_by_test_id("stat-total") with timeout 2000ms
- waiting for get_by_test_id("stat-total")
20 × locator resolved to <b data-testid="stat-total">1</b>
- unexpected value "1"
Aria snapshot:
- text: "1"
~/qa/fail/test_fail.py:8: AssertionError: Locator expected to have text '2'
=========================== short test summary info ============================
FAILED fail/test_fail.py::test_counter_after_create[chromium] - AssertionErro...
1 failed in 3.94s
$ find test-results -type f | sort
test-results/fail-test-fail-py-test-counter-after-create-chromium/test-failed-1.png
test-results/fail-test-fail-py-test-counter-after-create-chromium/trace.zip
Снимок test-failed-1.png — состояние страницы в момент падения:

По снимку видно: задача одна, счётчик честно показывает 1 — неправ тест, ожидавший 2. Трассу trace.zip открывают командой playwright show-trace test-results/…/trace.zip (или перетаскивают на сайт trace.playwright.dev — файл разбирается в браузере и никуда не отправляется). В трассе — каждое действие с локатором и снимком до и после, сетевые запросы, сообщения консоли. Это главный инструмент разбора падений UI-тестов в CI.
Чтобы ключи не писать каждый раз, их добавляют в addopts файла pytest.ini: addopts = -ra --screenshot only-on-failure --tracing retain-on-failure. Папку test-results не кладут в git — её сохраняет CI как артефакт (раздел 10).
Нестабильный (плавающий, flaky) тест на одном и том же коде то проходит, то падает. Он хуже, чем отсутствие теста: после пятого ложного падения команда перестаёт смотреть на красные прогоны и пропускает настоящий дефект.
Учебная страница имитирует медленный сервер: после нажатия «Сохранить» надпись меняется через случайное время до 0,4 с. Первый тест ждёт фиксированные 0,2 с, второй — через expect:
"""Учебная страница: «Сохранить» отвечает через случайное время от 0 до 0,4 с — как сервер под нагрузкой."""
import time
from playwright.sync_api import Page, expect
PAGE = """
<button onclick="setTimeout(() => document.querySelector('#msg').textContent = 'Сохранено',
Math.random() * 400)">Сохранить</button>
<p id="msg">…</p>
"""
def test_saved_with_sleep(page: Page):
page.set_content(PAGE)
page.get_by_role("button", name="Сохранить").click()
time.sleep(0.2)
assert page.locator("#msg").inner_text() == "Сохранено"
def test_saved_with_expect(page: Page):
page.set_content(PAGE)
page.get_by_role("button", name="Сохранить").click()
expect(page.locator("#msg")).to_have_text("Сохранено")
Каждый тест запущен по 10 раз подряд:
$ for i in $(seq 10); do pytest -q flaky/test_flaky.py::test_saved_with_sleep | tail -1 | sed "s/ in .*//"; done | sort | uniq -c
4 1 failed
6 1 passed
$ for i in $(seq 10); do pytest -q flaky/test_flaky.py::test_saved_with_expect | tail -1 | sed "s/ in .*//"; done | sort | uniq -c
10 1 passed
Тест со sleep падает в части прогонов, хотя страница работает правильно; тест с expect проходит всегда. Увеличить sleep до 1 секунды — не решение: тест станет медленным и всё равно упадёт на загруженном компьютере CI.
| Причина | Признак | Что делать |
|---|---|---|
Не дождались: sleep, чтение текста без expect | Падает чаще на медленной машине, в CI | Только expect и автоожидания Playwright |
| Зависимость от других тестов | Один проходит, в наборе падает (раздел 07) | Свои данные, уборка, чистое приложение |
| Зависимость от даты и времени | Падает около полуночи, по выходным, в конце месяца | Даты — от «сегодня» в тесте; в модульных — подмена (раздел 05) |
| Порядок в ответе не задан | Список приходит в разном порядке | Сравнивать множества или сортировать перед сравнением — если порядок не требование |
| Окружение | Занят порт, кончилось место, нет сети | Свободный порт, временные папки, заглушки внешних сервисов |
| Настоящий дефект гонки в программе | Падает редко и при параллельной работе | Баг-репорт с трассой: это не тест плохой |
Как разбирать: запустить тест много раз подряд (как выше), сохранить трассы упавших прогонов, сравнить с прошедшими. Пока причина не найдена, тест помечают xfail со ссылкой на задачу «разобрать нестабильный тест», а не удаляют и не перезапускают молча. Плагины автоматического перезапуска упавших тестов существуют, но скрывают и нестабильность, и редкие настоящие дефекты.
--screenshot only-on-failure --tracing retain-on-failure — снимок и трасса для разбора падений в CIsleep — главный источник нестабильности UI-тестов