Intermediate8 min read

Designing Reliable E2E Tests

End-to-end tests catch what unit tests cannot — but only if you kill flakiness at the source.

End-to-end tests exercise the real system the way a user does. That is their value and their curse: every layer they touch is a chance to be slow or flaky. A flaky E2E suite is worse than no suite — it trains the team to ignore red.

Select elements by intent, not by structure

CSS and XPath selectors tied to layout break on every redesign. Prefer accessible, semantic selectors that mirror how a user finds things — roles, labels, and explicit test ids for the few cases without a natural handle.

// ❌ Brittle: breaks when markup or styling changes
await page.click('div.toolbar > button:nth-child(2)');

// ✅ Resilient: expresses user intent
await page.getByRole('button', { name: 'Save' }).click();
await page.getByLabel('Email').fill('user@example.com');

Never sleep — wait for conditions

A fixed `sleep(2000)` is a bet that the app is always faster than two seconds. It is too slow on a fast machine and too short on a loaded CI runner. Wait for the state you actually care about instead.

// ❌ Arbitrary timing — slow and still flaky
await page.waitForTimeout(2000);

// ✅ Wait for the observable outcome
await expect(page.getByText('Saved')).toBeVisible();

Watch out: Most E2E flakiness is a hidden race condition: the test asserts before the app has finished. Auto-retrying, web-first assertions remove the race instead of papering over it with sleeps.

Keep tests independent and hermetic

  • Each test sets up and tears down its own data — no ordering dependencies between tests.
  • Control the clock, network, and randomness so runs are deterministic.
  • Test one user-visible outcome per test; a 30-step mega-test fails for a hundred reasons.
  • Quarantine, never ignore: a skipped flaky test is a silent gap.

Compare how different runners express these ideas in the Framework Comparison playground before committing your suite to one.

Practice this

Read next