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

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

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

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

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

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

// ПОГАНО: не роби так
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:

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 – там тести демонструють мислення на співбесіді, тут вони працюють інфраструктурою довіри в CI.
  • Очікування вшиті в перевірки, а не розкидані навколо дій.

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

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

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

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.