8 lines
21 KiB
JSON
8 lines
21 KiB
JSON
{
|
||
"index": 157,
|
||
"slug": "editorial-2023-08-field-e2e-stability",
|
||
"title": "Flaky e2e-тест: как найти причину и сохранить сигнал",
|
||
"excerpt": "Если первый запуск падает, а retry проходит, тест не становится надёжным. Разбираем locator, actionability, готовность интерфейса, данные и trace, чтобы менять только доказанный слой.",
|
||
"contentHtml": "<p>В CI тест оформления платежа падает на <code>click</code>, а повторная попытка проходит. На следующий день тот же сценарий становится красным уже на проверке результата. Команда увеличивает timeout и добавляет ещё один retry, но получает только более длинную очередь: причина остаётся неизвестной, а настоящий сбой оплаты может потеряться среди зелёных повторов.</p>\n<p>Такой тест называют flaky, когда он при одинаковом заявленном сценарии иногда проходит, а иногда нет. Это описание наблюдения, а не диагноз. Чтобы вернуть тесту ценность, нужно сохранить исходную ошибку, разделить слои отказа и доказать маленьким экспериментом, какой слой меняется между попытками.</p>\n<p>Ниже — рабочая схема для Playwright Test. Маршрут, тексты, фикстуры и способ подготовки платежа в примерах условны: их нужно заменить контрактом конкретного приложения. Статья не обещает нулевой flake rate и не разрешает автоматически скрывать дефекты retry-настройкой.</p>\n<h2>Сначала зафиксируйте симптом</h2>\n<p>Начните с карточки одного запуска. Запишите commit, проект браузера, worker, номер попытки, входные данные и точное место падения. Не заменяйте initial failure результатом retry. В Playwright Test значение <code>testInfo.retry</code> показывает номер повторной попытки, а <code>testInfo.workerIndex</code> помогает связать запуск с worker.</p>\n<pre><code>import { test } from '@playwright/test';\n\ntest('confirms payment', async ({ page }, testInfo) => {\n console.log({\n retry: testInfo.retry,\n worker: testInfo.workerIndex,\n project: testInfo.project.name,\n });\n\n await page.goto('/checkout');\n await page.getByRole('button', { name: 'Оплатить' }).click();\n});</code></pre>\n<p>Лог не заменяет отчёт: в нём должны остаться текст ошибки, URL или идентификатор тестовых данных и ссылка на артефакт именно этой попытки. Если trace был собран только на retry, пометьте initial как <code>evidence: absent</code>, а не делайте вид, что повторный trace объясняет первый сбой.</p>\n<h2>Что меняется при retry</h2>\n<p>Retry не продолжает тот же браузер с места ошибки. Когда тест падает, Playwright Test удаляет worker вместе с браузером и запускает новый worker; при включённых retry тест начинается заново в новом процессе. Поэтому между initial и retry могут отличаться cookies, storage, состояние фикстур, worker, порядок подготовки данных и доступность внешней зависимости.</p>\n<p>Playwright классифицирует результаты так: <code>passed</code> — первый запуск прошёл; <code>flaky</code> — первый запуск упал, но retry прошёл; <code>failed</code> — упали первый запуск и все retry. Метка <code>flaky</code> полезна для очереди разбора, но не доказывает, что продукт исправен или что причина относится к инфраструктуре.</p>\n<table><caption>Как читать пару initial/retry</caption><thead><tr><th scope='col'>Наблюдение</th><th scope='col'>Что уже известно</th><th scope='col'>Чего ещё нельзя утверждать</th><th scope='col'>Следующая проверка</th></tr></thead><tbody><tr><td>Обе попытки падают на одном locator</td><td>Сбой воспроизводится в этом запуске</td><td>Что виноват только selector</td><td>Проверить число совпадений и actionability</td></tr><tr><td>Initial падает, retry проходит до click</td><td>Между попытками изменилось состояние или время</td><td>Что нужен больший timeout</td><td>Сравнить DOM, overlay, animation и данные</td></tr><tr><td>Click проходит, assertion результата падает</td><td>Действие принято браузером</td><td>Что операция завершилась успешно</td><td>Проверить финальный UI-state и ответ операции</td></tr><tr><td>Тест проходит отдельно, но падает в пачке</td><td>Есть зависимость от порядка или общего состояния</td><td>Что проблема в браузере</td><td>Запустить с новым id данных и другим порядком</td></tr><tr><td>Есть trace только у retry</td><td>Видна одна повторная попытка</td><td>Что она показывает initial</td><td>Включить симметричный сбор первого failure</td></tr></tbody></table>\n<h2>Разделите locator и actionability</h2>\n<p>Для <code>locator.click()</code> Playwright ждёт, пока locator разрешится ровно в один элемент, элемент станет видимым, стабильным, доступным для событий и активным. Это несколько разных проверок. Таймаут на невидимой кнопке, перекрытие модальным слоем и два совпавших элемента требуют разных исправлений, хотя в отчёте могут выглядеть как один <code>TimeoutError</code>.</p>\n<p>Сначала проверьте cardinality — количество совпадений. Локатор должен описывать одну пользовательскую цель в нужной области страницы. Предпочтительны роль и доступное имя; цепочка CSS-классов связывает тест с реализацией DOM. <code>getByTestId</code> допустим, если команда поддерживает test id как стабильный технический контракт. Нельзя объявлять locator надёжным только потому, что он зелёный в одном браузере.</p>\n<pre><code>const dialog = page.getByRole('dialog', { name: 'Оплата' });\nconst pay = dialog.getByRole('button', { name: 'Оплатить' });\n\nawait expect(dialog).toBeVisible();\nawait expect(pay).toHaveCount(1);\nawait pay.click();</code></pre>\n<p>Проверка <code>toHaveCount(1)</code> сама ожидает условие, поэтому не превращается в мгновенный снимок состояния. Но она не доказывает, что платёж принят: она лишь делает цель действия явной. Если элемент появляется после запроса, ищите причину отсутствия или перекрытия, а не маскируйте её глобальным timeout.</p>\n<h2>Готовность экрана не равна успеху операции</h2>\n<p>После click нужен бизнес-результат, который видит пользователь: подтверждённый статус, новая запись или переход на согласованный маршрут. Spinner, исчезновение skeleton и завершение отдельного сетевого запроса — промежуточные признаки. Они не заменяют assertion финального состояния.</p>\n<pre><code>await pay.click();\nawait expect(page.getByRole('status')).toHaveText('Платёж подтверждён');\nawait expect(page).toHaveURL(/\\/checkout\\/success$/);</code></pre>\n<p>Два assertion должны соответствовать контракту приложения. Если статус появляется раньше фактического сохранения, тест обязан ждать более точный сигнал или проверять запись через контролируемый API. Если URL и статус намеренно не меняются, не добавляйте их ради формы — зафиксируйте один действительно наблюдаемый результат.</p>\n<p>Случайная задержка <code>waitForTimeout</code> не объясняет, какое событие делает страницу готовой. Ожидание <code>networkidle</code> тоже не является универсальным признаком готовности: аналитика, polling и WebSocket могут не завершаться, а нужный UI уже может быть готов. Выбирайте состояние, принадлежащее пользовательскому сценарию.</p>\n<h2>Проверьте данные и границы внешних систем</h2>\n<p>Тест может быть стабильным, а данные — нет. Используйте уникальный идентификатор заказа, подготавливайте его перед тестом и удаляйте после него. Запуск отдельно и запуск в пачке должны получать независимые записи. Если тест зависит от общей корзины, аккаунта или очереди, это состояние нужно назвать владельцем и включить в fixture либо изменить контракт сценария.</p>\n<p>Внешний платёжный провайдер, email-шлюз и сторонняя аналитика находятся вне контроля e2e-команды. Полный путь к провайдеру проверяйте отдельным контрактным или интеграционным набором с его политикой доступности. В пользовательском e2e-тесте контролируйте внешний ответ через API-мок, если задача теста — проверить собственный UI. Иначе смена ответа третьей стороны будет ошибочно классифицирована как flaky интерфейса.</p>\n<figure><img src='/assets/editorial/2023/e2e-stability-2023-triage-loop.svg' alt='Цикл разбора flaky e2e-теста: сравнить initial и retry, проверить locator, готовность интерфейса и evidence, затем внести малый diff с rollback' loading='lazy' /><figcaption>Порядок triage: сначала карточка двух попыток, затем отдельно selector и readiness, после этого — данные и внешние зависимости. Схема описывает процедуру и не является trace конкретного CI-запуска.</figcaption></figure>\n<h2>Настройте артефакты так, чтобы они сохраняли сигнал</h2>\n<p>Для CI разумно собирать trace на первом retry: это даёт подробный контекст повторной попытки и не записывает тяжёлый trace для каждого зелёного теста. Если retry выключены, используйте <code>retain-on-failure</code>, чтобы сохранить trace неудачного запуска. Режим <code>on</code> удобен для локального расследования, но в большом CI увеличивает стоимость и объём артефактов.</p>\n<pre><code>import { defineConfig } from '@playwright/test';\n\nexport default defineConfig({\n retries: process.env.CI ? 1 : 0,\n use: {\n trace: process.env.CI ? 'on-first-retry' : 'retain-on-failure',\n screenshot: 'only-on-failure',\n },\n});</code></pre>\n<p>Проверить initial и retry можно явно. В локальной диагностике запустите один тест без повторов и с полным trace, затем откройте отчёт:</p>\n<pre><code>pnpm exec playwright test tests/checkout.spec.ts --project=chromium --retries=0 --trace on\npnpm exec playwright show-report\n# Для сохранённого архива: pnpm exec playwright show-trace path/to/trace.zip</code></pre>\n<p>В Trace Viewer смотрите один вопрос за раз: какой locator использовался, что было в DOM до и после действия, какие запросы ушли, какой ответ пришёл и какой статус получил assertion. Viewer показывает timeline, snapshots, action log, network и metadata конкретного запуска. Он не сообщает, корректен ли бизнес-контракт, и не восстанавливает отсутствующий initial trace.</p>\n<h2>Порядок расследования</h2>\n<ol><li><strong>Сохраните исходный факт.</strong> Запишите initial error, результат retry, commit, project, worker, attempt и входные данные.</li><li><strong>Разнесите гипотезы.</strong> Отдельно назовите locator, actionability, readiness, изоляцию данных и внешнюю зависимость.</li><li><strong>Повторите без retry.</strong> Используйте тот же тест, свежие данные и trace, чтобы не смешивать диагностику с автоматической маскировкой.</li><li><strong>Проверьте цель действия.</strong> Убедитесь, что locator находит ровно один control в правильном контейнере.</li><li><strong>Проверьте финальный контракт.</strong> Assertion должен ждать пользовательский результат, а не случайный промежуточный сигнал.</li><li><strong>Сравните окружения.</strong> Сверьте worker, cookies, storage, браузер, порядок запуска, fixture setup/cleanup и ответы контролируемых API.</li><li><strong>Измените один слой.</strong> Исправьте selector, предусловие, assertion, данные или сбор артефактов — только тот слой, для которого есть evidence.</li><li><strong>Проверьте отрицательный путь.</strong> Отказ операции должен показывать ожидаемую ошибку, а старый status не должен приниматься за новый успех.</li><li><strong>Оставьте критерий снятия.</strong> Для временного retry укажите owner и срок пересмотра; для постоянного diff — команду проверки и rollback.</li></ol>\n<h2>Как отличить исправление от маскировки</h2>\n<p>После правки прогоните тест отдельно и в пачке, с новым идентификатором данных и в поддерживаемых проектах браузера. Сравнивайте не только итоговый exit code, но и место assertion, длительность действия и наличие артефактов для каждой попытки. Один зелёный запуск ничего не доказывает; полезнее серия запусков с одинаковым контрактом и независимым setup.</p>\n<p>Повышать timeout можно только когда trace показывает допустимую задержку конкретного события, а данные подтверждают её верхнюю границу. Даже в этом случае изменяйте локальный timeout нужного действия и фиксируйте причину. Глобальное увеличение времени скрывает регрессии производительности и заставляет все тесты ждать чужую проблему.</p>\n<h2>Ограничения применимости</h2>\n<p>Эта схема рассчитана на Playwright Test и его worker/retry/trace-модель. В Cypress, Selenium Grid или самописном runner жизненный цикл попытки и набор артефактов могут отличаться. Проверки actionability не исправляют серверный ответ, а web-first assertion не делает неверное ожидание правильным.</p>\n<p>Мок внешнего сервиса повышает повторяемость собственного UI, но не проверяет реальную интеграцию. Изоляция тестовых данных не устраняет дефекты параллельной обработки в production. Trace может содержать чувствительные URL, заголовки и данные страницы, поэтому задайте срок хранения и права доступа по правилам своей CI-системы.</p>\n<p>Разбор можно считать законченным, когда команда отвечает на пять вопросов: что произошло в initial, что изменилось в retry, какой locator выполнял действие, какой финальный результат ожидался и какой evidence относится к каждой попытке. Только после этого retry становится диагностическим инструментом, а не зелёной маской.</p>\n<h2>Проверяемые источники</h2>\n<ul><li><a href='https://playwright.dev/docs/best-practices' target='_blank' rel='noopener noreferrer'>Playwright — Best Practices</a>: пользовательские locator, изоляция тестов, web-first assertions, управление третьими сторонами и trace в CI.</li><li><a href='https://playwright.dev/docs/actionability' target='_blank' rel='noopener noreferrer'>Playwright — Auto-waiting</a>: точные проверки перед <code>click</code> — уникальность, видимость, стабильность, получение событий и enabled state.</li><li><a href='https://playwright.dev/docs/test-retries' target='_blank' rel='noopener noreferrer'>Playwright — Retries</a>: worker lifecycle, retry, категории <code>passed/flaky/failed</code> и <code>testInfo.retry</code>.</li><li><a href='https://playwright.dev/docs/trace-viewer' target='_blank' rel='noopener noreferrer'>Playwright — Trace viewer</a>: режимы записи, открытие trace и доступные timeline, snapshots, actions, network и metadata.</li></ul>"
|
||
}
|