Files
progcode/editorial/agent-rewrites/157.json
T

8 lines
21 KiB
JSON
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
{
"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) =&gt; {\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>"
}