Playwright не спасает от флапающих тестов: разбираемся, как он ждёт на самом деле. CI.. CI. DevOps.. CI. DevOps. playwright.. CI. DevOps. playwright. pytest-playwright.. CI. DevOps. playwright. pytest-playwright. python.. CI. DevOps. playwright. pytest-playwright. python. автоожидание.. CI. DevOps. playwright. pytest-playwright. python. автоожидание. автотесты.. CI. DevOps. playwright. pytest-playwright. python. автоожидание. автотесты. Блог компании OTUS.. CI. DevOps. playwright. pytest-playwright. python. автоожидание. автотесты. Блог компании OTUS. Тестирование веб-сервисов.. CI. DevOps. playwright. pytest-playwright. python. автоожидание. автотесты. Блог компании OTUS. Тестирование веб-сервисов. флапающие тесты.

Playwright продают примерно так: автоожидание встроено, sleep перед кликом писать не надо, флапающие тесты остались в эпохе Selenium. Обещание не пустое — механика внутри действительно крутая, и первые тесты правда пишутся как по маслу.

А потом набор дорастает до полутора сотен тестов, уезжает в CI, и вы видите знакомую картину. Локально всё зелёное. На пайплайне падает через раз. Каждый раз в новом месте. Кто‑то предлагает добавить ретраев, кто‑то — «просто подождать подольше», и через месяц у вас набор, которому никто не верит.

Так вот. Автоожидание работает ровно там, где вы им пользуетесь. Проблема в том, что из него очень легко выпасть, причём совершенно естественными способами, и код при этом выглядит нормально. В статье о том — как оно устроено изнутри, где именно вы из него выходите, и как собрать набор, который в CI ведёт себя так же, как у вас на ноутбуке.

Всё на Python и pytest-playwright, синхронный API.

Что происходит между вашим.click() и настоящим кликом

Начнём с того, что вообще значит «Playwright умеет ждать». Когда вы пишете:

page.get_by_role("button", name="Оформить заказ").click()

Перед тем как реально ткнуть мышкой, Playwright прогоняет элемент через набор проверок готовности. Их пять, и они выполняются в цикле, пока все не сойдутся одновременно или не истечёт таймаут:

  • элемент найден в DOM;

  • он видимый — есть непустой bounding box и нет visibility: hidden;

  • он стабилен — не двигался в течение как минимум двух подряд идущих кадров анимации;

  • он принимает события — в точке клика находится именно он, а не что‑то поверх него;

  • он не заблокирован — нет атрибута disabled.

Последние две проверки — самое ценное, именно их недооценивают. Проверка «принимает события» ловит классику: модалка, которая ещё не успела закрыться; тултип, всплывший над кнопкой; невидимый оверлей загрузки на полэкрана. Selenium в этой ситуации радостно кликал по оверлею и шёл дальше, а вы потом полдня выясняли, почему форма не отправилась.

Проверка стабильности ловит вторую классику — анимации. Кнопка едет по экрану, вы кликаете, попадаете туда, где она была сто миллисекунд назад. Playwright дождётся, пока она остановится.

Всё это происходит внутри одного вызова .click() и по умолчанию длится максимум тридцать секунд. Никаких ожиданий вокруг писать не надо — они там уже есть.

Ровно та же тема работает в веб‑ассертах:

expect(page.get_by_text("Заказ оформлен")).to_be_visible()

Эта строка опрашивает страницу, пока условие не выполнится или не кончится таймаут (для ассертов он по умолчанию пять секунд, это отдельная настройка). Так работают все проверки семейства to_*: to_have_text, to_have_value, to_have_count, to_be_checked, to_be_enabled.

Держите эту картинку в голове — дальше всё будет про то, как из неё выпасть.

Выход первый: вытащить значение в переменную

Самая частая ошибка и самая обидная, потому что код выглядит абсолютно нормально.

def test_greeting(page: Page):
    page.goto("/dashboard")
    title = page.text_content("h1")
    assert title == "Добро пожаловать"

Вот тут вы из автоожидания вышли. text_content() возвращает то, что есть на странице в момент вызова. Не ждёт, не перепроверяет, просто снимает показания. Заголовок ещё рендерится — получите None или старый текст, и тест упадёт со сравнением строк, из которого ничего не понятно.

Локально страница отрисовывается мгновенно: сборка тёплая, кеш прогрет, машина ваша и незанятая. В CI браузер поднимается в контейнере, ресурсы делятся с тремя соседними джобами, рендер занимает лишние двести миллисекунд — и привет.

В эту же категорию попадают все методы, отдающие значение здесь и сейчас: inner_text(), input_value(), is_visible(), is_enabled(), get_attribute(), count(), all().

Правильно так:

def test_greeting(page: Page):
    page.goto("/dashboard")
    expect(page.get_by_role("heading", level=1)).to_have_text("Добро пожаловать")

Разница в том, что локатор — это не элемент, а рецепт, как его найти. Он ленивый. Пока вы не совершили над ним действие или не проверили ассертом, поиска не происходит вообще. Поэтому локатор можно создать заранее, положить в переменную, передать в функцию — и он останется актуальным, даже если страница десять раз перерисовалась.

total = page.get_by_test_id("order-total")   # поиска ещё не было

page.get_by_role("button", name="Добавить").click()
expect(total).to_have_text("1 990 ₽")        # ищет здесь, на свежем DOM

page.get_by_role("button", name="Добавить").click()
expect(total).to_have_text("3 980 ₽")        # и здесь тоже заново

Отдельная проблема может быть в подсчёте элементов:

assert page.locator(".row").count() == 5          # снимок, падает на медленном рендере
expect(page.locator(".row")).to_have_count(5)     # ждёт, пока их станет пять

Правило, которое стоит принять раз и навсегда: всё, что проверяется на странице, проверяется через expect(locator). Как только в тесте появился assert page.что_то() == ..., вы вернулись в мир, где всё зависит от скорости машины.

Из этого правила есть законное исключение — когда значение нужно не для проверки, а для дальнейшей работы: скопировать номер заказа, прочитать сгенерированный ID. Тогда сначала дожидаемся ассертом, потом читаем:

order_id_el = page.get_by_test_id("order-id")
expect(order_id_el).to_be_visible()          # сначала убедились, что оно там
order_id = order_id_el.inner_text()          # теперь можно брать

Две строки вместо одной, зато тест перестаёт зависеть от того, успел ли отрисоваться блок.

Выход второй: селектор, привязанный к вёрстке

page.click("div.card > div:nth-child(2) > button.btn-primary")
page.fill("#form_field_2847", "test@example.com")

Работает ровно до того дня, когда фронтендер обернёт кнопку в тултип, поменяет класс при переходе на новую версию UI‑кита или сгенерирует другой id. Приложение живое, тест красный.

Плюс такой селектор даёт бесполезную диагностику. Сообщение «не найден div.card > div:nth-child(2) > button.btn-primary» не говорит ничего о том, что сломалось: элемент исчез? переехал? переименовался?

Playwright предлагает искать так, как ищет живой человек:

page.get_by_role("button", name="Сохранить").click()
page.get_by_label("Электронная почта").fill("test@example.com")
page.get_by_placeholder("Поиск по каталогу").fill("ноутбук")
page.get_by_text("Заказ оформлен")
page.get_by_title("Закрыть")

Такой локатор переживает перестановку разметки, смену классов, оборачивание в контейнеры — он опирается на роль элемента и его доступное имя, а не на путь в дереве. Бонусом тест заодно проверяет доступность: если кнопка потеряла название, вы узнаете об этом из красного теста, а не из жалобы пользователя со скринридером.

Когда роли и текста не хватает — иконка без подписи, элемент с динамическим содержимым, — правильный ответ не CSS‑путь, а тестовый идентификатор:

page.get_by_test_id("cart-badge")

Атрибут проставляется руками в коде фронта, живёт по договорённости с разработчиками и не меняется от рефакторинга вёрстки. Имя атрибута настраивается, если у вас принято что‑то другое:

# conftest.py
@pytest.fixture(scope="session")
def browser_context_args(browser_context_args):
    return {**browser_context_args, "test_id_attribute": "data-qa"}

Теперь get_by_test_id("cart-badge") будет искать data-qa="cart-badge", и переписывать существующие тесты не придётся.

Порядок выбора локатора получается такой: роль с именем → метка, плейсхолдер, текст → тестовый id → и только когда совсем некуда деваться, CSS. XPath не нужен практически никогда.

Строгий режи

Ещё один момент:

page.get_by_role("button").click()
# Error: strict mode violation: resolved to 12 elements

Playwright по умолчанию требует, чтобы локатор указывал ровно на один элемент. Кажется занудством, но на самом деле это защита от теста, который кликает по первой попавшейся кнопке и делает вид, что всё хорошо.

Сужать надо не индексами, а контекстом:

# плохо: сломается, как только порядок карточек поменяется
page.get_by_role("button", name="Купить").nth(2).click()

# хорошо: ищем внутри конкретной карточки
card = page.get_by_role("listitem").filter(has_text="Ноутбук ASUS")
card.get_by_role("button", name="Купить").click()

filter() — вообще один из самых полезных методов в API. Он умеет и по тексту, и по вложенному локатору, и с отрицанием:

rows = page.get_by_role("row")
rows.filter(has_text="Оплачен")
rows.filter(has=page.get_by_role("button", name="Отменить"))
rows.filter(has_not_text="Черновик")

Локатор от этого не перестаёт быть ленивым: filter возвращает новый локатор, а поиск по‑прежнему случится только в момент действия или проверки.

Выход третий: wait_for_timeout как универсальное лекарство

Тест мигает — поставим паузу. Работает первые две недели.

page.get_by_role("button", name="Отправить").click()
page.wait_for_timeout(2000)
expect(page.get_by_text("Готово")).to_be_visible()

Дальше одно из двух. Либо в CI операция занимает две с половиной секунды и тест падает несмотря на паузу. Либо всё проходит, но каждая такая пауза — две секунды, помноженные на количество тестов, и набор бежит сорок минут вместо восьми.

Фиксированная пауза — это ставка на то, что вы угадали время. Угадать нельзя: на нагруженном раннере оно другое, и оно плавает.

Ждать надо не время, а сигнал. Под каждый тип сигнала есть свой инструмент.

Ждём появления на странице. Ничего не нужно, ассерт справится сам:

page.get_by_role("button", name="Отправить").click()
expect(page.get_by_text("Готово")).to_be_visible()

Ждём исчезновения. Спиннер, оверлей, модалка:

expect(page.get_by_test_id("loading-overlay")).to_be_hidden()

Ждём ответа от бэкенда. Ловим конкретный запрос:

with page.expect_response(
    lambda r: "/api/orders" in r.url and r.request.method == "POST" and r.ok
):
    page.get_by_role("button", name="Отправить").click()

Тут одна деталь, из‑за которой ловят гонку в CI и почти никогда локально: подписка оформляется до действия. Если написать наоборот — сначала клик, потом ожидание ответа, — вы рискуете подписаться уже после того, как ответ пришёл, и повиснуть до таймаута. На быстрой локальной машине с медленным бэком это почти не воспроизводится, а в контейнере с локальным моком — регулярно.

Ждём смены состояния там, где на странице оно не отражается. Есть опрашивающая проверка, которая умеет дёргать любую вашу функцию:

expect.poll(lambda: api.get_order(order_id)["status"], timeout=15_000).to_be("paid")

Она вызывает лямбду с нарастающим интервалом, пока та не вернёт ожидаемое. Работает с чем угодно: с запросами к API, с запросами в базу, с чтением файла.

Ждём навигации. Обычно не нужно вообще — goto и клики по ссылкам ждут сами. Но если переход происходит по JS‑логике:

expect(page).to_have_url(re.compile(r"/orders/d+"))

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

Единственный случай, когда пауза оправдана

Это дебаунс. Поле поиска, которое шлёт запрос через 300 мс после последнего нажатия. Никакого сигнала «дебаунс истёк» страница не даёт.

Но даже тут пауза — не лучший ответ. Правильный — дождаться самого запроса:

with page.expect_request("**/api/search**"):
    page.get_by_placeholder("Поиск").fill("ноутбук")

Запрос ушёл — значит, дебаунс отработал, и дальше можно проверять результаты. Сколько миллисекунд это заняло, тесту знать незачем.

Выход четвёртый: логин через UI в каждом тесте

Здесь ломается не отдельный тест, а весь набор.

def login(page):
    page.goto("/login")
    page.get_by_label("Логин").fill("user")
    page.get_by_label("Пароль").fill("secret")
    page.get_by_role("button", name="Войти").click()
    expect(page).to_have_url("/dashboard")

def test_profile(page):
    login(page)      # и так в каждом из двухсот тестов

Двести тестов — двести логинов по несколько секунд каждый. Двести шансов флапнуть на форме, которая к тестируемой функциональности отношения не имеет. Плюс приличная вероятность, что бэкенд решит, будто двести входов в минуту с одного адреса — это брутфорс, и включит капчу.

Playwright умеет сохранить состояние аутентификации один раз и подкладывать его в каждый браузерный контекст:

# conftest.py
import json, re, pytest
from playwright.sync_api import Browser, expect


@pytest.fixture(scope="session")
def auth_state(browser: Browser, tmp_path_factory) -> str:
    path = tmp_path_factory.mktemp("auth") / "state.json"
    page = browser.new_page()
    page.goto(f"{BASE_URL}/login")
    page.get_by_label("Логин").fill(USER)
    page.get_by_label("Пароль").fill(PASSWORD)
    page.get_by_role("button", name="Войти").click()
    expect(page).to_have_url(re.compile(r"/dashboard"))
    page.context.storage_state(path=str(path))
    page.close()
    return str(path)


@pytest.fixture
def page(browser: Browser, auth_state: str):
    context = browser.new_context(storage_state=auth_state)
    page = context.new_page()
    yield page
    context.close()

Один вход на весь прогон, дальше каждый тест стартует уже авторизованным. Экономия минут, и вдвое меньше мест, где может мигнуть.

Если ролей несколько, делаем состояние на каждую:

@pytest.fixture(scope="session")
def admin_state(browser, tmp_path_factory):
    return _login_as(browser, tmp_path_factory, ADMIN_USER, ADMIN_PASSWORD)


@pytest.fixture
def admin_page(browser, admin_state):
    context = browser.new_context(storage_state=admin_state)
    yield context.new_page()
    context.close()

Ещё быстрее — логиниться вообще без браузера, через API, и класть в контекст готовые cookies:

@pytest.fixture(scope="session")
def auth_state(playwright, tmp_path_factory):
    request = playwright.request.new_context(base_url=BASE_URL)
    request.post("/api/login", data={"login": USER, "password": PASSWORD})
    path = tmp_path_factory.mktemp("auth") / "state.json"
    request.storage_state(path=str(path))
    request.dispose()
    return str(path)

Секунды вместо десятков секунд, и форма логина вообще не участвует. Только не забудьте оставить один‑единственный тест, который логинится через UI по‑настоящему, иначе сломанную форму входа вы не заметите.

Контекст создаётся свой на каждый тест, общий у них только файл с состоянием, а не живая сессия браузера. storage_state — это снимок cookies и localStorage, а не разделяемое состояние.

Выход пятый: тесты, которые мешают друг другу

Проявляется, когда включаете параллельность.

def test_create_user(page):
    page.get_by_label("Email").fill("test@example.com")   # одна и та же почта
    page.get_by_role("button", name="Создать").click()
    expect(page.get_by_text("Пользователь создан")).to_be_visible()

Локально в один поток хорошо. В CI с четырьмя воркерами два теста создают одного пользователя одновременно, второй получает «email занят», и падает при этом случайный из двух.

Данные должны быть свои у каждого теста:

@pytest.fixture
def unique_email():
    return f"test-{uuid.uuid4().hex[:8]}@example.com"

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

Отдельная категория — данные, живущие между тестами. Браузерное состояние Playwright изолирует сам, а вот база протекает прекрасно. Здесь работает либо уборка за собой, либо создание всего необходимого прямо в тесте с уникальными идентификаторами.

Готовьте состояние через API, а не кликами

Это самый недооценённый приём во всём наборе.

Если тест проверяет, как отображается страница заказа, ему не нужно проходить весь путь оформления кликами: положить в корзину, заполнить адрес, выбрать доставку, ввести карту. Каждый из этих шагов — отдельный шанс флапнуть, и ни один из них не имеет отношения к тому, что вы проверяете.

@pytest.fixture
def api(playwright, auth_state):
    ctx = playwright.request.new_context(base_url=BASE_URL, storage_state=auth_state)
    yield ctx
    ctx.dispose()


def test_order_page(page, api):
    order = api.post("/api/orders", data={"items": [{"sku": "NB-1", "qty": 1}]}).json()
    page.goto(f"/orders/{order['id']}")
    expect(page.get_by_test_id("order-total")).to_have_text("89 990 ₽")

Шагов в разы меньше, время в разы меньше, точек отказа в разы меньше. Путь оформления заказа при этом тестируется отдельно, одним честным сквозным тестом, но именно одним, а не в качестве прелюдии к каждому второму.

Когда виноват не тест, а окружение

Половина флапов в CI не про ваш код вообще. Разберём, что там обычно происходит.

  • Анимации. На вашей машине переходы плавные, в контейнере без GPU — рваные и более долгие. Проверка стабильности элемента их переживёт, но если у вас скриншотные тесты, они будут мигать. Лечится глобальным отключением:

@pytest.fixture(scope="session")
def browser_context_args(browser_context_args):
    return {**browser_context_args, "reduced_motion": "reduce"}
  • Шрифты. Не успел загрузиться веб‑шрифт — текст отрисовался системным, кнопка сдвинулась, ширины поехали. Для скриншотных сравнений это фатально. Помогает либо ожидание готовности шрифтов, либо, что надёжнее, фиксация окружения через официальный Docker‑образ Playwright — в нём и браузеры, и шрифты те же, что у всех.

  • Время. Тест проверяет «показать заказы за последние 24 часа», а данные подготовлены вчера. Или наоборот, тест ломается в полночь и на переводе часов. Раньше это лечили заглушками над Date, теперь есть штатный Clock API:

page.clock.install(time=datetime(2026, 3, 15, 12, 0, 0))
page.goto("/orders")
expect(page.get_by_text("Сегодня")).to_be_visible()

page.clock.fast_forward("02:00")     # промотали два часа, не ожидая их
expect(page.get_by_text("Сессия истекла")).to_be_visible()

Отдельно хорош для тестирования таймаутов сессии, автообновления и всего, что раньше требовало реально ждать.

  • Внешние сервисы. Виджет чата, аналитика, карты, рекламный скрипт. Они грузятся с чужих серверов, иногда медленно, иногда никак, а иногда перехватывают клик своим оверлеем. В тестах их надо резать:

@pytest.fixture(autouse=True)
def block_third_party(page):
    page.route(re.compile(r"(googletagmanager|hotjar|intercom).com"),
               lambda route: route.abort())
  • Нестабильный бэкенд. Если тестируете фронт, а бэк периодически отвечает пятисоткой не по делу, честнее замокать сеть:

def test_empty_state(page):
    page.route("**/api/orders", lambda route: route.fulfill(
        status=200,
        json={"items": [], "total": 0},
    ))
    page.goto("/orders")
    expect(page.get_by_text("Заказов пока нет")).to_be_visible()

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

def test_server_error(page):
    page.route("**/api/orders", lambda route: route.fulfill(status=500))
    page.goto("/orders")
    expect(page.get_by_role("alert")).to_have_text("Не удалось загрузить заказы")

Или подменять только часть настоящего ответа, оставив остальное как есть:

def handle(route):
    response = route.fetch()
    data = response.json()
    data["balance"] = 0            # хотим посмотреть на пустой баланс
    route.fulfill(response=response, json=data)

page.route("**/api/account", handle)

route.fetch() сходит на настоящий бэкенд, вы правите одно поле в ответе, остальное едет как есть.

Проверка структуры целиком через ARIA‑снимки

Штука, которую редко используют, а зря. Вместо десятка отдельных ассертов можно сравнить дерево доступности целиком с ожидаемым, записанным в YAML:

expect(page.get_by_role("navigation")).to_match_aria_snapshot("""
    - navigation:
      - link "Главная"
      - link "Заказы"
      - link "Профиль"
""")

Тест падает, если пункт исчез, появился лишний или изменилось название. При этом он не привязан ни к разметке, ни к классам, ни к порядку в DOM за пределами того, что вы описали.

Хорошо ложится на проверку меню, форм, таблиц, шапки — всего, где важен состав и структура, а не отдельный элемент. И работает как страховка от случайных регрессий доступности: пропала роль у кнопки — снимок разошёлся.

Снимок можно снять с живой страницы, чтобы не писать руками:

print(page.get_by_role("navigation").aria_snapshot())

Дальше остаётся скопировать вывод в тест и поправить руками то, что вам в нём не нравится.

Когда всё‑таки упало

Три инструмента.

Трейс. Полная запись прогона: скриншоты каждого шага, DOM‑снимки до и после действия, сетевые запросы, консоль, исходник теста с подсветкой текущей строки. Включаем так, чтобы писалось только при повторной попытке после падения:

# pytest.ini
[pytest]
addopts = --tracing=retain-on-failure --video=retain-on-failure --screenshot=only-on-failure

Открывается командой:

playwright show-trace test-results/.../trace.zip

DOM‑снимки в трейсе интерактивные. Вы не просто смотрите картинку, вы открываете инспектор внутри сохранённого момента и можете тыкать в элементы ровно так, как если бы страница была живая. Для CI‑флапа, который не воспроизводится локально, это единственный способ понять, что там было на самом деле: элемент не отрисовался, отрисовался под оверлеем, или отрисовался, но с другим текстом.

В панели Actions видно, какой локатор использовался на каждом шаге и сколько он выполнялся. Если шаг занял 29,8 секунды и упал — значит, он ждал все тридцать, и проблема в том, что условие не наступило, а не в том, что таймаут маленький.

Прогон в цикле. Самый прямой способ поймать редкий флап:

pytest tests/test_checkout.py --count=50 -x

Три падения из пятидесяти — тест точно нестабилен, и дальше вы ищете причину, а не спорите, показалось или нет. Для параллельного варианта:

pytest tests/test_checkout.py --count=50 -n 4

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

PWDEBUG=1 pytest tests/test_checkout.py

Браузер откроется видимым, выполнение остановится на каждом шаге, а рядом будет панель, где можно наводить мышкой на элементы и получать готовые локаторы.

Про ретраи

Ретраи в CI нужны. Но договоритесь с собой о том, зачем.

addopts = --tracing=retain-on-failure
pytest --reruns 2 --reruns-delay 1

Ретрай существует, чтобы билд не разваливался от единичного сетевого чиха. Он не существует, чтобы прятать нестабильные тесты.

Если тест за месяц потребовал ретрая больше пяти раз, он едет в карантин и чинится, а не продолжает жить в наборе, потихоньку отравляя доверие ко всему остальному.

Как это всё собрать в одну конфигурацию

Чтобы не искать по тексту, вот минимальный набор настроек, с которого разумно начинать проект.

# pytest.ini
[pytest]
addopts =
    --tracing=retain-on-failure
    --video=retain-on-failure
    --screenshot=only-on-failure
    --output=test-results
# conftest.py
import pytest
from playwright.sync_api import expect

expect.set_options(timeout=10_000)          # ассерты ждут 10 секунд вместо 5


@pytest.fixture(scope="session")
def browser_context_args(browser_context_args):
    return {
        **browser_context_args,
        "base_url": BASE_URL,
        "locale": "ru-RU",
        "timezone_id": "Europe/Moscow",
        "viewport": {"width": 1440, "height": 900},
        "reduced_motion": "reduce",
        "test_id_attribute": "data-qa",
    }

Без фиксация локали, таймзоны и размера окна вы получите тесты, которые ведут себя по‑разному на машине разработчика и в контейнере, где локаль по умолчанию английская, а часовой пояс UTC. Форматы дат, разделители в числах, порядок сортировки — всё это поедет.

В CI поверх этого — официальный Docker‑образ Playwright той же версии, что в зависимостях. Расхождение версии образа и версии библиотеки даёт самые весёлые ошибки, которые невозможно воспроизвести локально.

container:
  image: mcr.microsoft.com/playwright/python:v1.XX.X-noble

Обновлять версию образа стоит вместе с версией библиотеки, одним коммитом — иначе однажды вы полдня будете искать причину падения, которого нет ни у кого локально.


Проверить свой набор можно по одному признаку: есть ли в коде хоть что‑нибудь, что предполагает конкретное время или конкретную структуру разметки.

  • Пауза на две секунды — предполагает время.

  • Значение, вытащенное в переменную, — предполагает, что оно уже там.

  • Путь div > div:nth-child(3) — предполагает разметку.

Каждое такое предположение однажды окажется неверным, и произойдёт это в CI, потому что там всё чуть медленнее и чуть иначе.

Если хотите так же проверить собственные знания, пройдите вступительный тест по автоматизации тестирования на Python. Он покажет, какие темы уже не вызывают вопросов, а где остались пробелы.

Тест должен трогать интерфейс только там, где он проверяет интерфейс. Всё подготовительное — авторизация, создание данных, приведение системы в нужное состояние — делается через API.

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

Playwright не спасает от флапающих тестов: разбираемся, как он ждёт на самом деле - 1

Когда тесты начинают нестабильно вести себя в CI, важно найти источник гонки и выстроить предсказуемую работу с интерфейсом, API и тестовыми данными.

На бесплатных открытых уроках разберем UI‑ и API‑тестирование с Playwright на Python и покажем, как локальные модели искусственного интеллекта ускоряют подготовку сценариев. Так рутинных шагов становится меньше, а контроль над надёжностью тестов остаётся у инженера.

  • 30 июля в 20:00. «API и UI тестирование с Playwright на Python». Записаться

  • 20 августа в 20:00. «Как ускорить создание автотестов с помощью локальных ИИ‑моделей». Записаться

Больше бесплатных уроков и других полезных подборок смотрите в дайджесте.

Автор: badcasedaily1

Источник