Jak řešit nestabilní E2E testy

Proč jsou E2E testy nestabilní, jak je identifikovat a praktické tipy na jejich opravu.

12 minut

Flaky testy jsou noční můra každého vývojáře. Test projde, pak neprojde, pak zase projde — a vy nevíte, jestli je chyba v kódu nebo v testu. U E2E testů je tento problém obzvlášť častý.

Co je flaky test

Flaky test je test, který jednou projde a podruhé spadne, i když se kód nezměnil. Chování je nepředvídatelné — nedá se spolehnout, že stejný vstup dá stejný výsledek. Příčina není v kódu aplikace, ale v samotném testu nebo testovacím prostředí.

Flaky testy jsou nebezpečné, protože:

  • Snižují důvěru — vývojáři začnou ignorovat selhání testů
  • Zpomalují vývoj — opakované spouštění CI, čekání na „zelený” build
  • Maskují skutečné chyby — skutečná regrese (nová chyba, kterou přinesla nedávná změna a rozbila dříve fungující funkci) se ztratí mezi falešnými poplachy

Časté příčiny

1. Race conditions

Nejčastější příčina. Test předpokládá, že něco bude hotové, ale není:

// Špatně — element nemusí být ještě v DOM
await page.click('.submit-button');
await page.click('.success-message'); // Může selhat

// Správně — počkat na element
await page.click('.submit-button');
await page.waitForSelector('.success-message');
await page.click('.success-message');

2. Hardcoded čekání

Fixní sleep nebo wait jsou křehké:

// Špatně — 1 sekunda nemusí stačit (nebo je zbytečně moc)
await page.click('.load-data');
await page.waitForTimeout(1000);
expect(await page.locator('.data').count()).toBe(10);

// Správně — čekat na konkrétní podmínku
await page.click('.load-data');
await expect(page.locator('.data')).toHaveCount(10);

3. Závislost na pořadí

Testy by měly být nezávislé. Pokud test B závisí na testu A, máte problém:

// Špatně — test závisí na stavu z předchozího testu
test('zobrazí seznam produktů', async () => {
  // Předpokládá, že předchozí test vytvořil produkty
  await expect(page.locator('.product')).toHaveCount(3);
});

// Správně — test si připraví vlastní data
test('zobrazí seznam produktů', async () => {
  await createTestProducts(3);
  await page.goto('/products');
  await expect(page.locator('.product')).toHaveCount(3);
});

4. Sdílený stav

Testy sdílí databasi, cookies, localStorage nebo jiný stav:

// Před každým testem vyčistit stav
beforeEach(async () => {
  await clearDatabase();
  await page.context().clearCookies();
  await page.evaluate(() => localStorage.clear());
});

5. Animace a přechody

CSS animace mohou způsobit, že element není kliknutelný:

// Špatně — klik během animace může minout cíl
await page.click('.animated-button');

// Správně — Playwright locator.click() má auto-waiting
// a čeká, než je element stabilní (bez animace)
await page.locator('.animated-button').click();

// Nebo zakázat animace v testech
await page.addStyleTag({
  content: '*, *::before, *::after { animation: none !important; transition: none !important; }'
});

6. Síťové požadavky

API odpovědi mohou přijít v různém pořadí nebo s různou latencí:

// Špatně — předpokládá rychlou odpověď
await page.click('.fetch-button');
await expect(page.locator('.result')).toBeVisible();

// Správně — čekat na síťový požadavek
await page.click('.fetch-button');
await page.waitForResponse(resp =>
  resp.url().includes('/api/data') && resp.status() === 200
);
await expect(page.locator('.result')).toBeVisible();

7. Časová pásma a datum

Testy závislé na aktuálním čase:

// Špatně — závisí na aktuálním datu
test('zobrazí dnešní události', async () => {
  await expect(page.locator('.today-events')).toBeVisible();
});

// Správně — mockovat čas
test('zobrazí dnešní události', async () => {
  await page.clock.setFixedTime(new Date('2025-06-15T10:00:00'));
  await page.goto('/events');
  await expect(page.locator('.today-events')).toBeVisible();
});

8. Používání force: true

Volba force: true vypíná auto-waiting i všechny actionability kontroly — viditelnost, překrytí jinými elementy, disabled stav. Test pak závisí na tom, co zrovna běží: někdy loader zmizí včas a klik dorazí na tlačítko, jindy je ještě nad ním a kliknutí nic neudělá. Klasický zdroj flakiness:

// Špatně — maskuje skutečný problém
await page.click('.submit-button', { force: true });

// Proč je to problém:
// - Element může být překrytý modalem nebo loaderem
// - Element může být mimo viewport
// - Element může být disabled
// - Uživatel by na něj reálně kliknout nemohl

Když test selhává bez force: true, je to signál, že něco není v pořádku. Místo obcházení kontroly zjistěte příčinu:

// Správně — počkat až element bude kliknutelný
await page.locator('.submit-button').click(); // Auto-waiting

// Nebo explicitně počkat na podmínky
await expect(page.locator('.loader')).toBeHidden();
await expect(page.locator('.submit-button')).toBeEnabled();
await page.locator('.submit-button').click();

force: true má smysl jen ve výjimečných případech — například testování custom komponenty, která záměrně zachytává kliknutí na jiném elementu.

9. Nejednoznačné selektory

Selektor, který matchuje více elementů, je časovaná bomba:

// Špatně — na stránce může být víc tlačítek "Odeslat"
await page.click('button:has-text("Odeslat")');

// Playwright klikne na první nalezený element, ale:
// - Pořadí elementů se může změnit
// - Může přibýt nové tlačítko výše v DOM
// - V různých stavech UI může být viditelné jiné tlačítko

Používejte specifické selektory:

// Správně — jednoznačný selektor
await page.click('[data-testid="contact-form-submit"]');

// Nebo zúžit kontext
await page.locator('.contact-form').getByRole('button', { name: 'Odeslat' }).click();

// Playwright strict mode — selže, pokud matchuje více elementů
await page.locator('button:has-text("Odeslat")').click(); // Strict by default

10. Spoléhání na pořadí elementů

Selektory jako :nth-child() nebo :first jsou křehké:

// Špatně — závisí na pořadí
await page.click('.product-list li:nth-child(2) .buy-button');
cy.get('.product-list li').eq(1).find('.buy-button').click();

// Problémy:
// - Pořadí se může změnit při řazení
// - Může přibýt/ubýt položka
// - Lazy loading může změnit index

Vybírejte podle obsahu nebo atributů:

// Správně — výběr podle obsahu
await page.locator('.product-list li', { hasText: 'Fytopuf' })
  .getByRole('button', { name: 'Koupit' }).click();

// Nebo podle data atributu
await page.click('[data-product-id="fytopuf"] .buy-button');

11. Toast a notifikace

Notifikace se zobrazí a rychle zmizí — test nestihne ověřit:

// Špatně — toast může zmizet než ho test najde
await page.click('.save-button');
await expect(page.locator('.toast')).toHaveText('Uloženo');

// Toast má animaci 300ms + zobrazení 2s + animace 300ms
// Test může "minout" okno, kdy je toast viditelný

Řešení:

// Správně — počkat na toast ihned po akci
await page.click('.save-button');
await expect(page.locator('.toast')).toBeVisible({ timeout: 5000 });
await expect(page.locator('.toast')).toHaveText('Uloženo');

// Nebo použít waitFor s podmínkou
await expect(page.getByRole('alert')).toContainText('Uloženo');

// Případně prodloužit dobu zobrazení toastu v testech
// (nastavit env proměnnou TOAST_DURATION=10000)

12. Stale element reference

Element byl odebrán z DOM a znovu přidán (např. při rerenderování):

// Špatně — uložená reference (ElementHandle) může být stale
const button = await page.$('.dynamic-button');
await page.click('.trigger-rerender');
await button.click(); // Chyba — element už není připojený k DOM

// Správně — vždy používat čerstvý locator
await page.click('.trigger-rerender');
await page.locator('.dynamic-button').click(); // Nové vyhledání

Playwright locatory jsou „lazy” — vyhledávají element až při akci. Proto preferujte locatory před element handles.

13. Lazy loading a virtualisace

Elementy se načítají až při scrollu (infinite scroll, virtualizované seznamy):

// Špatně — element ještě není v DOM
await expect(page.locator('.item-500')).toBeVisible();

// Správně — pokud element existuje v DOM (lazy rendering),
// scrollIntoViewIfNeeded ho dotáhne do viewportu
await page.locator('.item-500').scrollIntoViewIfNeeded();
await expect(page.locator('.item-500')).toBeVisible();

// Pro virtualizované seznamy — element nemusí být v DOM vůbec.
// Použijte toPass s retry logikou místo ručního while + waitForTimeout:
await expect(async () => {
  await page.mouse.wheel(0, 500);
  await expect(page.locator('.item-500')).toBeVisible();
}).toPass();

Jak identifikovat flaky testy

Opakované spouštění

Playwright i Cypress umožňují opakované spuštění testů:

# Playwright — spustit každý test 10×
npx playwright test --repeat-each=10

# Cypress — pomocí pluginu
npx cypress run --env burn=10

Paralelní běhy

Spuštění testů paralelně často odhalí race conditions a sdílený stav:

# Playwright
npx playwright test --workers=4

# Playwright — plně paralelní mód
npx playwright test --fully-parallel

Jak opravit flaky testy

1. Používejte správné čekání

Playwright a Cypress mají auto-waiting — využívejte ho:

// Playwright — auto-waiting assertions
await expect(page.locator('.message')).toBeVisible();
await expect(page.locator('.count')).toHaveText('5');

// Cypress — automatické opakování
cy.get('.message').should('be.visible');
cy.get('.count').should('have.text', '5');

2. Izolujte testy

Každý test by měl být nezávislý:

// Playwright — isolovaný kontext pro každý test
test.describe('produkty', () => {
  test.beforeEach(async ({ page }) => {
    await resetDatabase();
    await page.goto('/');
  });

  test('vytvoří produkt', async ({ page }) => {
    // ...
  });
});

3. Mockujte externí závislosti

API třetích stran mohou být pomalé nebo nedostupné. Neplatí to vždy, ale někdy může být lepší to namockovat:

// Playwright — mock API
await page.route('**/api/external/**', route => {
  route.fulfill({
    status: 200,
    body: JSON.stringify({ data: 'mocked' })
  });
});

// Cypress — intercept
cy.intercept('GET', '/api/external/*', {
  statusCode: 200,
  body: { data: 'mocked' }
});

4. Přidejte data-testid atributy

Selektory jako .btn-primary nebo div > span:nth-child(2) jsou křehké:

<!-- Špatně -->
<button class="btn btn-primary">Odeslat</button>

<!-- Správně -->
<button class="btn btn-primary" data-testid="submit-button">Odeslat</button>
// Test
await page.click('[data-testid="submit-button"]');

// Nebo pomocí getByTestId
await page.getByTestId('submit-button').click();

5. Retry logika

Pro akce, které mohou selhat, použijte retry:

// Playwright — retry kliknutí
await expect(async () => {
  await page.click('.flaky-button');
  await expect(page.locator('.result')).toBeVisible();
}).toPass({ timeout: 10000 });

6. Stabilní test data

Generujte unikátní data pro každý test:

test('registrace uživatele', async ({ page }) => {
  const uniqueEmail = `test-${Date.now()}@example.com`;

  await page.fill('[data-testid="email"]', uniqueEmail);
  await page.fill('[data-testid="password"]', 'heslo123');
  await page.click('[data-testid="register"]');

  await expect(page.locator('.welcome')).toContainText(uniqueEmail);
});

Prevence

  • Code review testů — kontrolujte testy stejně jako produkční kód
  • Dokumentujte známé problémy — když najdete flaky test, zdokumentujte příčinu
  • Opravujte hned — flaky test se sám neopraví, jen se zhorší
  • Používejte správné nástroje — Playwright a Cypress mají lepší auto-waiting než starší frameworky

Checklist pro stabilní E2E testy

  • Každý test je nezávislý a může běžet samostatně
  • Žádné hardcoded sleep nebo waitForTimeout
  • Používá auto-waiting assertions
  • Testy mají vlastní testovací data
  • Animace jsou vypnuté nebo se čeká na jejich dokončení
  • Selektory používají data-testid a jsou jednoznačné
  • Nepoužívá :nth-child() nebo posicové selektory
  • Nepoužívá force: true (kromě výjimečných případů)
  • Používá locatory místo element handles (kvůli stale references)
  • CI má nastavené retries
  • Trace a video jsou zapnuté pro selhání

Závěr

Flaky testy jsou symptom, ne příčina. Většinou signalizují race condition v testu, sdílený stav, nebo nesprávné čekání. Klíč k jejich řešení je systematický přístup: identifikovat, izolovat, opravit.

Nejlepší flaky test je ten, který nevznikne. Pište testy s vědomím, že běží v nedeterministickém prostředí, používejte správné čekání a isolujte stav.

Odkazy

Co si myslíte o tomto článku?

Diskuse

Novinky e-mailem

Když budu mít něco opravdu zajímavého, můžu vám to poslat e-mailem

Přidej se k 500+ čtenářům
Jen kvalitní obsah
Žádný spam

Web jecas.cz píše Bohumil Jahoda, kontakt
Seznam všech článků · Témata · Zkratky
2013–2026