
Jak řešit nestabilní E2E testy
Proč jsou E2E testy nestabilní, jak je identifikovat a praktické tipy na jejich opravu.
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
sleepnebowaitForTimeout - Používá auto-waiting assertions
- Testy mají vlastní testovací data
- Animace jsou vypnuté nebo se čeká na jejich dokončení
- Selektory používají
data-testida 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.