End-to-End Testing Fundamentals
Drive the real app in a real browser, but only for the few journeys that must never break
Guess first
Pick an answer. You will see if you were right at the end of the lesson.
Why are E2E tests the small top of the test pyramid?
Real-world analogy
What is it?
End-to-end testing drives the finished application the way a user does. A real browser opens the real frontend, which calls the real API and database. Playwright is a popular tool for this. You write steps like "fill in the email, click Sign in, expect the dashboard heading". Playwright runs them in Chromium, Firefox or WebKit. E2E tests give the most realistic check, but they are the slowest and most fragile. So teams keep them few and aim them at the journeys that matter most.
Real-world relevance
An online shop runs five E2E tests on every merge. They cover sign up, log in, search, cart, and paying with a test card. One day a CSS refactor hides the Pay button behind another element, and no unit test notices. The checkout test fails within minutes and the trace shows the button covered. The usual trap is the opposite: 300 E2E tests that take an hour, fail randomly, and are ignored.
Key points
- The browser is the user — An end-to-end (E2E) test opens a real browser, clicks and types like a person, and checks what appears. It runs the frontend, API and database together.
- The test pyramid — Many unit tests sit at the base, fewer integration tests in the middle, and a few E2E tests on top. Each layer up is slower, more fragile and costs more to run.
- Locate by role and label — Playwright finds elements the way a user does: getByRole, getByLabel, getByText. These keep working when someone renames a CSS class.
- Brittle selectors break — A selector like div > div:nth-child(3) .btn depends on page structure. Any layout change breaks it, even if the user sees no difference.
- Auto-waiting — Playwright waits for an element to be visible and ready before it clicks. Web assertions such as toBeVisible also retry until a timeout.
- Flaky tests and their causes — A flaky test passes and fails on the same code. Common causes are fixed sleeps, tests that share data, and tests that depend on run order.
- Test only critical journeys — Cover paths like login and checkout, where a break costs money or trust. Edge cases belong in unit and integration tests, which are faster and easier to debug.
- Use a known test user — Create test accounts and data for E2E runs, and keep passwords in environment variables. Do not run E2E tests against real customer data.
- Traces in CI — Run the tests in your CI pipeline, and keep traces and screenshots when a test fails. The trace viewer shows each step, so you can debug a failure you never saw on your machine.
Quick check
Answer from memory — scrolling up first makes it stick less.
Which locator is the most stable for a Sign in button?
A test passes on your machine and fails randomly in CI. Which cause is most likely?
Code example
import { test, expect } from '@playwright/test';
test('a user can log in and see the dashboard', async ({ page }) => {
await page.goto('/login');
await page.getByLabel('Email').fill('[email protected]');
await page.getByLabel('Password').fill(process.env.E2E_PASSWORD ?? '');
await page.getByRole('button', { name: 'Sign in' }).click();
// The assertions retry until the page is ready or the timeout passes
await expect(page).toHaveURL(/dashboard/);
await expect(page.getByRole('heading', { name: 'Dashboard' })).toBeVisible();
});Line-by-line walkthrough
- 1. This is a fragment of a test file. The import brings in test and expect from Playwright's test runner. The base URL comes from the Playwright config.
- 2. The test receives a fresh browser page and goes to the login screen.
- 3. getByLabel finds the Email and Password fields by their visible labels, like a user would, and fills them. The password comes from an environment variable.
- 4. getByRole finds the button by its role and name, then clicks it. Playwright waits until the button is ready.
- 5. The first assertion expects the URL to match dashboard. It retries until it matches or the timeout passes.
- 6. The last assertion finds the heading by role and name, and expects it to be visible. No sleep is needed.
Spot the bug
test('shows the dashboard after login', async ({ page }) => {
await page.goto('/login');
await page.getByLabel('Email').fill('[email protected]');
await page.getByLabel('Password').fill(process.env.E2E_PASSWORD ?? '');
await page.getByRole('button', { name: 'Sign in' }).click();
await page.waitForTimeout(3000);
expect(await page.getByRole('heading', { name: 'Dashboard' }).isVisible()).toBe(true);
});Need a hint?
Show answer
Explain like I'm 5
Fun fact
Hands-on challenge
More resources
- Playwright: getting started (Playwright)
- Playwright: locators (Playwright)
- Playwright: best practices (Playwright)
Lock it in