Проблемы E2E: состояние, flakiness и Page Object
Сцена · почему вчера прошло, а сегодня нет
Ты написал E2E из урока 16, он зелёный. Прогнал десять раз: восемь зелёных, два красных, кода никто не трогал. Это не баг в коде, это flaky-тест: проверка, которая врёт про код, то проходя, то падая без причины.
E2E flaky чаще всех: он самый асинхронный (браузер, сеть, рендер) и самый зависимый от состояния (общая БД, чужие сессии, мусор от прошлого прогона). Этот урок про то, как сделать E2E надёжным: управлять состоянием, убрать источники flakiness, спрятать селекторы в Page Object. Глубже про карантин flaky и поиск причины будет в уроке 23, здесь чиним саму технику.
Карта урока · что заберёшь
- научишься управлять состоянием E2E: seed, teardown, изоляция прогонов;
- поймёшь природу flakiness и заменишь
sleepна ожидание состояния; - спрячешь селекторы в Page Object, чтобы правка вёрстки не ломала десяток тестов;
- переиспользуешь сессию через
storageState, не логинясь в каждом тесте.
Раздел 1 · состояние: seed, teardown, изоляция
Идея словами
E2E работает на настоящей БД, и БД помнит. Прогнал тест “баланс после платежа 900”, он списал 100. Прогнал ещё раз, баланс уже 800, тест красный. Тест не изолирован: его результат зависит от мусора, который оставил прошлый прогон.
Три приёма управления состоянием:
- seed: подготовить нужное состояние перед сценарием (создать счёт, завести данные), обычно через API, а не кликами;
- teardown: убрать за собой после теста, чтобы следующий стартовал с чистого листа;
- изоляция: сделать так, чтобы тесты в принципе не делили данные.
В Kassa изоляцию делаем самым дешёвым способом: свежая БД на весь прогон плюс уникальные данные на тест.
Шаг 1 · свежая БД на прогон
Playwright поднимает оба сервера сам, и kassa-api стартует на SQLite :memory:: каждый запуск получает пустую базу, мусор прошлого прогона физически негде взять:
// e2e/playwright.config.ts
webServer: [
{
command: 'pnpm --filter kassa-api exec tsx src/server.ts',
cwd: '..',
port: 3000,
env: { KASSA_DB: ':memory:', PORT: '3000' },
reuseExistingServer: !process.env.CI,
timeout: 60_000,
},
// ... kassa-web в режиме preview
],
Готовность серверов Playwright ждёт по порту и URL, а не читает логи: опрашивает, пока не ответят. Это первое правило стабильности, не угадывать тайминг старта, а ждать факт готовности.
Шаг 2 · уникальные данные на тест
Свежей БД на прогон мало, если внутри прогона тесты делят почту. Дай каждому уникальный вход, чтобы баланс не тянулся из соседнего теста:
// e2e/specs/payment.spec.ts
const email = `pay-${Date.now()}@batschool.dev`;
await login.open();
await login.login(email);
await dashboard.expectBalance('1000.00');
Новая почта это новый счёт со стартовым балансом 1000.00. Тест не зависит от того, что было до него, и от порядка запуска. Это та же изоляция по построению, что в интеграционных, только на уровне данных сценария.
Что взять с собой
- E2E на настоящей БД не изолирован, пока ты этим не займёшься;
- свежая БД на прогон плюс уникальные данные на тест убирают зависимость от порядка;
- готовность сервера жди опросом порта, а не угадыванием тайминга.
Раздел 2 · flakiness и ожидание состояния
Идея словами
Главный источник flaky в E2E это время. Браузер асинхронен: клик отправил запрос, ответ придёт через сколько-то миллисекунд, баланс обновится ещё позже. Если тест проверяет баланс сразу после клика, он гонится с рендером и иногда проигрывает.
Наивное лечение: page.waitForTimeout(2000), “подожди две секунды”. Это худшее, что можно сделать. На быстрой машине ты теряешь две секунды зря, на медленной двух секунд не хватит и тест всё равно упадёт. Жёсткий тайминг это гонка, замаскированная под паузу.
Правильное лечение: ждать состояние, а не время. “Жди, пока баланс не станет 900.00” вместо “жди две секунды”. Playwright это умеет из коробки: auto-waiting сам дожидается, пока ассерт станет истинным, в пределах таймаута.
Шаг 1 · ожидание состояния вместо паузы
Сравни два подхода к “после платежа баланс 900”:
// плохо: гонка, замаскированная под паузу
await dashboard.pay('100.00');
await page.waitForTimeout(2000);
expect(await dashboard.balance().textContent()).toBe('Баланс: 900.00');
// хорошо: ждём состояние, Playwright опрашивает до таймаута
await dashboard.pay('100.00');
await expect(dashboard.balance()).toHaveText('Баланс: 900.00');
expect(locator).toHaveText это web-first assertion: он повторяет проверку, пока баланс не станет нужным или не выйдет таймаут. На быстрой машине пройдёт за миллисекунды, на медленной дождётся. Никаких потерянных секунд и никаких гонок.
Шаг 2 · селекторы по роли и data-testid
Второй источник хрупкости это селекторы. page.click('.btn.btn-primary.mt-4') ломается от любой правки классов или вёрстки. Привязывайся к смыслу, а не к оформлению:
getByRole('button', { name: 'Войти' })это доступная роль, как видит элемент пользователь и скринридер;getByTestId('payment-submit')это явный якорь для теста, не зависящий от текста и классов.
data-testid меняется только сознательно, когда меняешь сам тест. Цепочка css-классов меняется каждый раз, когда дизайнер двигает отступ. Якорь по смыслу переживает рефакторинг вёрстки, селектор по оформлению нет.
Когда код пишет агент
Агентские E2E это парад антипаттернов flakiness. Агент пишет page.waitForTimeout(3000), потому что “так надёжнее подождать”, и page.click('.css-1x2y3z'), потому что скопировал класс из DOM. Оба упадут: первый на медленном CI, второй на следующей правке вёрстки. Чини механически: каждый waitForTimeout на web-first ожидание, каждый селектор по классу на getByRole или getByTestId. Это первое, что проверяешь в сгенерированном E2E.
Задача · ревизия селекторов набора
Браузер тут не нужен: разбор селектора это чистая логика. Собери проверку, которая по списку селекторов скажет, какие переживут правку вёрстки, а какие отвалятся первыми.
Что взять с собой
- жёсткий
waitForTimeoutэто гонка, а не пауза, убирай его; - жди состояние через web-first assertion (
toHaveText,toBeVisible); - привязывайся к роли и
data-testid, а не к классам и вёрстке.
дальше после входа
Урок целиком открыт всем, кто вошёл. Войди по своему email, чтобы читать дальше.
Вход пока по приглашению. Если хочешь учиться, напиши Vassiliy.