Раздел 09 — UI: страница-объект и стабильность

Как сделать UI-тесты короткими, быстрыми и такими, которым верят

Прогресс курса Раздел 9 из 12

Что вы освоите в этом разделе

6 академических часов практики, без лекции. Новых понятий в разделе мало — это отработка раздела 08 на наборе, который будет жить долго. Прочитайте страницу перед первой парой.
01

Страница-объект

В тестах раздела 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}")

Правила, на которых держится шаблон:

Тесты стали короче и читаются как тест-кейс:

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 ===============================
02

Вход через API

Вход через форму нужен один раз — в тесте самой формы (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 и положить туда, где браузер его ищет.

03

Снимок и трасса при падении

Упавший 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 — состояние страницы в момент падения:

test-failed-1.png
Страница задач нового пользователя в момент падения теста: счётчик «Всего: 1», в таблице одна задача

По снимку видно: задача одна, счётчик честно показывает 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).

04

Нестабильные тесты

Нестабильный (плавающий, 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 со ссылкой на задачу «разобрать нестабильный тест», а не удаляют и не перезапускают молча. Плагины автоматического перезапуска упавших тестов существуют, но скрывают и нестабильность, и редкие настоящие дефекты.

Ключевые выводы раздела

Запомните главное

  • Страница-объект хранит локаторы и действия, тест — данные и проверки
  • Форму входа проверяют одним тестом, остальные входят через API
  • --screenshot only-on-failure --tracing retain-on-failure — снимок и трасса для разбора падений в CI
  • Плавающий тест хуже отсутствующего; sleep — главный источник нестабильности UI-тестов
  • Нестабильность разбирают и устраняют, а не перезапускают до зелёного
Практические задания — на учебном портале. Задания, критерии оценивания, сдача работ и оценки преподавателя — в курсе на portal.nevabit.ru. Учётную запись выдаёт преподаватель.
Раздел 08: Автотесты UI — Playwright Все разделы Раздел 10: Отчёты, покрытие, CI