# Playwright без flaky-тестів: як писати E2E, які не падають «просто тому що CI»

> Звідки береться flaky у E2E-тестах і як його лікувати: locator-first підхід, web-first assertions, ізоляція тестових даних, Trace Viewer і чесна відповідь, чому retry – це не fix.

- Автор: Юра Скиба (https://cookiesoftware.io)
- Опубліковано: 2026-10-01
- Категорія: React і Frontend
- Canonical: https://cookiesoftware.io/blog/playwright-no-flaky-tests

---

**Коротка відповідь.** Flaky-тест – це тест, який то проходить, то падає без змін у коді. У 9 з 10 випадків причина одна з трьох: ручні затримки (`waitForTimeout`), спільні тестові дані між тестами або неконтрольована мережа. Лікування: web-first assertions замість sleep-ів, ізольовані дані на кожен тест, явні межі між своїм і чужим бекендом – і Trace Viewer для розбору падінь. Retry в CI – знеболювальне, а не лікування.

## Звідки насправді береться flaky

E2E-тест ганяє реальний браузер проти реального застосунку, і між «натиснув кнопку» та «побачив результат» живе ціла прірва асинхронності: рендеринг React, мережеві запити, анімації, кеші. Тест стає flaky тоді, коли він **робить припущення про час**, якого ніхто не гарантував: «за 2 секунди точно завантажиться», «список уже оновився», «попередній тест залишив базу в потрібному стані».

Погана новина: жоден retry це не лагодить. Хороша: Playwright спроєктований так, що правильний спосіб писати тести – він же і стабільний. Треба лише перестати з ним боротись.

## Поганий тест: знайди всі міни

Класичний checkout-флоу, написаний «аби працювало»:

```ts
// ПОГАНО: не роби так
import { test, expect } from '@playwright/test';

test('checkout', async ({ page }) => {
  await page.goto('/catalog');
  await page.waitForTimeout(2000); // «хай прогрузиться»

  await page.click('.product-card:first-child .btn-add');
  await page.waitForTimeout(1000);

  await page.click('#cart-icon');
  await page.waitForTimeout(1500); // кошик точно відкрився... мабуть

  const total = await page.$eval('.total', (el) => el.textContent);
  expect(total).toContain('299');

  await page.click('.checkout-btn');
  await page.waitForTimeout(3000);
  expect(await page.url()).toContain('/success');
});
```

Міни по порядку. Кожен `waitForTimeout` – це ставка: на швидкому ноутбуці 2 секунди вистачає, на завантаженому CI-раннері – ні. Селектори `.btn-add` і `#cart-icon` прив'язані до верстки: перейменували клас – тест упав, хоча продукт працює. `$eval` читає DOM **одразу**, без очікування: якщо total ще перераховується, тест зловить старе значення. А ціна `299` узагалі приїхала з бази, яку міг змінити сусідній тест.

## Стабільна версія: locator-first і web-first assertions

Той самий сценарій, написаний так, як радить [офіційна документація Playwright](https://playwright.dev/docs/best-practices):

```ts
import { test, expect } from '@playwright/test';

test('покупець оформлює замовлення з одним товаром', async ({ page }) => {
  await page.goto('/catalog');

  const product = page
    .getByRole('listitem')
    .filter({ hasText: 'Бездротові навушники' });
  await product.getByRole('button', { name: 'Додати в кошик' }).click();

  await page.getByRole('link', { name: 'Кошик' }).click();

  // Web-first assertion: Playwright сам чекає, поки умова стане істинною
  await expect(page.getByTestId('cart-total')).toHaveText('1 299 грн');

  await page.getByRole('button', { name: 'Оформити замовлення' }).click();

  await expect(page).toHaveURL(/\/success/);
  await expect(
    page.getByRole('heading', { name: 'Дякуємо за замовлення' })
  ).toBeVisible();
});
```

Що змінилось концептуально:

- **Жодного `waitForTimeout`.** Локатори в Playwright виконують actionability-перевірки автоматично: клік відбудеться лише коли елемент видимий і активний. Assertions типу `toHaveText` і `toBeVisible` самі ретраяться до таймауту – це і є web-first assertions.
- **`getByRole` замість CSS-класів.** Тест звертається до сторінки так, як користувач: «кнопка з назвою "Додати в кошик"». Рефакторинг верстки тест не ламає; зламана доступність – ламає, і це правильно: тест бонусом пильнує семантику. До речі, не плутай цей жанр із [тестами на live coding](/blog/testy-dlia-live-coding) – там тести демонструють мислення на співбесіді, тут вони працюють інфраструктурою довіри в CI.
- **Очікування вшиті в перевірки**, а не розкидані навколо дій.

## Тестові дані: кожному тесту – свій світ

Друге за частотою джерело flaky – спільний стан. Тест А створив замовлення, тест B рахує «всього замовлень: 3», паралельний запуск перемішав усе. Правила:

1. **Кожен тест створює свої дані** – через API-виклик у фікстурі, не через UI (швидше і стабільніше).
2. **Унікальність замість очищення.** Простіше генерувати унікальний email/SKU на тест, ніж гарантувати ідеальний cleanup.
3. **Авторизація – через setup project.** Playwright дозволяє залогінитись один раз, зберегти storage state і перевикористати в усіх тестах, замість логіну через UI в кожному.

```ts
// fixtures.ts: свій товар для кожного тесту
import { test as base } from '@playwright/test';

type Fixtures = { product: { id: string; name: string } };

export const test = base.extend<Fixtures>({
  product: async ({ request }, use) => {
    const name = `Товар ${Date.now()}-${test.info().workerIndex}`;
    const res = await request.post('/api/test/products', { data: { name } });
    const product = await res.json();
    await use(product);
    await request.delete(`/api/test/products/${product.id}`);
  },
});
```

## Мережа: де мокати, а де ні

Правило з офіційних best practices: **не тестуй те, чим не володієш**. Платіжний провайдер, аналітика, сторонні віджети – мокаються через `page.route`, бо їхні падіння не мають валити твій реліз:

```ts
await page.route('**/api/third-party/exchange-rate', (route) =>
  route.fulfill({ status: 200, json: { usd: 41.5 } })
);
```

Власний бекенд у ключових флоу краще не мокати: сенс E2E саме в інтеграції. Мок власного API перетворює E2E на дорогий компонентний тест – для цього є дешевші рівні (яку частину піраміди чим покривати – тема окремої розмови про стратегію тестування).

## Падіння сталось: Trace Viewer замість ворожіння

Найгірше в flaky – розбір. «На CI впало, локально працює» без артефактів означає годину гіпотез. Playwright записує trace: DOM-знімки кожного кроку, мережу, консоль. Увімкни в конфізі `trace: 'on-first-retry'` – і кожне падіння на CI приїде з повним «чорним ящиком», який відкривається у Trace Viewer: видно, що бачив браузер у момент кліку, які запити висіли, де розійшлись очікування з реальністю. Це перетворює triage з археології на перегляд запису.

## Retry – знеболювальне, не лікування

Playwright вміє `retries: 2` у CI, і це нормальний запобіжник від справді випадкових збоїв інфраструктури. Але якщо тест проходить з другої спроби **регулярно** – у тебе не «нестабільний CI», у тебе баг у тесті або в продукті, який ти офіційно дозволив ігнорувати. Практика, що працює: окремий звіт по тестах, які пройшли після retry, і регулярний розбір цього списку. Зелений пайплайн, якому не довіряють, гірший за червоний.

## Типові помилки

- **`waitForTimeout` як універсальний клей.** Кожен такий виклик – майбутнє flaky-падіння плюс повільніший сьют уже сьогодні.
- **Селектори по класах і структурі DOM.** `.col-md-6 > div:nth-child(2)` ламається від CSS-рефакторингу. Role і accessible name – стабільні.
- **Тести, що залежать від порядку запуску.** Паралельність Playwright це виявить найболючішим способом.
- **Логін через UI в кожному тесті.** Повільно і додає зайву точку відмови; storage state вирішує.
- **Мок усього підряд.** E2E, який не ходить у твій бекенд, перевіряє лише фронтенд-казку.
- **Ігнорування трейсів.** Падіння без trace – це падіння, яке повториться.

## Практичне завдання

Візьми свій найбільш «проклятий» E2E-тест (у кожній команді такий є) і зроби аудит: порахуй `waitForTimeout` та CSS-селектори, перепиши їх на web-first assertions і `getByRole`, винеси дані тесту у фікстуру з унікальним ідентифікатором. Потім прожени його 20 разів поспіль (`--repeat-each=20`) до і після рефакторингу – різниця в стабільності й часі виконання буде найкращим аргументом для команди.

Якщо після цього тест усе одно мерехтить – увімкни trace і подивись, на що він насправді чекає. Відповідь майже завжди виявляється багом продукту, який ховався за retry.
