What an E2E test is
An end-to-end test automates a real browser (or headless) to reproduce a complete user flow: it opens the page, types into a form, clicks, waits for the backend response and verifies what it sees. If it passes, you have a strong proof that the whole system works together.
Tools: Playwright and Cypress
- Playwright (Microsoft): controls Chromium, Firefox and WebKit with the same API; good auto-waiting, parallelism and traces.
- Cypress: runs inside the browser itself, with a very visual debugging experience.
// Conceptual (Playwright doesn't run in this sandbox):
await page.goto("/login");
await page.fill("#email", "ana@x.com");
await page.click("text=Sign in");
await expect(page.getByText("Welcome")).toBeVisible();
When is it worth it?
Reserve E2E for the critical business flows: signup, login, checkout, the "happy path" that must not break. Don't try to cover every edge case with E2E; that's done better with unit/integration (cheaper and more precise).
Flakiness (fragility)
A flaky test passes sometimes and fails other times without the code changing. It's the biggest enemy of E2E: it erodes confidence in the suite. Causes and remedies:
- Fixed waits (
sleep(500)): replace them with waits for a condition (that the element is visible, that the network finishes). Good tools do auto-waiting. - Fragile selectors (styling CSS classes): use stable selectors like
data-testid. - Shared data / order: each test should set up and clean up its own data and be independent of the rest.
Testing in CI
In continuous integration (CI) the suite runs automatically on every push or pull request, usually with the browser in headless mode. Good practices: run the unit tests first (they fail fast and cheap), parallelize, save screenshots/videos/traces of failed E2E tests for debugging, and block the merge if the suite doesn't pass.
Examples
Wait for a condition, not a fixed time
// Bad: fragile to timing variations.
// await sleep(500); expect(visible(button));
// Good: we wait for the condition to be met.
async function waitUntil(cond, attempts = 10) {
for (let i = 0; i < attempts; i++) {
if (cond()) return true;
await new Promise((r) => setTimeout(r, 10));
}
return false;
}
let ready = false;
setTimeout(() => { ready = true; }, 30);
console.log(await waitUntil(() => ready)); // true