End-to-End with Playwright
End-to-end (E2E) tests drive a real browser through a real version of your app, clicking and typing exactly as a user would, and asserting on what actually renders. Where component tests verify one piece in a simulated DOM, E2E tests exercise the whole stack—routing, data fetching, authentication, and rendering—working together. Playwright is the modern tool of choice: it launches Chromium, Firefox, and WebKit, waits for elements automatically, and ships a test runner with parallelism and tracing built in. This page covers writing a flow, picking selectors, how auto-waiting removes flakiness, and when an E2E test earns its keep over a component test.
Setting up Playwright
Playwright installs its own runner and browser binaries, independent of Vitest or Jest.
npm init playwright@latest
The installer scaffolds a playwright.config.ts and an example test. The key config concern for a React app is pointing Playwright at a running dev server. The webServer option starts Vite for you and waits until it responds before any test runs.
// playwright.config.ts
import { defineConfig, devices } from "@playwright/test";
export default defineConfig({
testDir: "./e2e",
use: {
baseURL: "http://localhost:5173",
trace: "on-first-retry",
},
webServer: {
command: "npm run dev",
url: "http://localhost:5173",
reuseExistingServer: !process.env.CI,
},
projects: [
{ name: "chromium", use: { ...devices["Desktop Chrome"] } },
],
});
Writing a flow: visit, interact, assert
Every E2E test follows the same shape. You navigate to a page, perform user actions, then assert on the resulting state. Consider a login form that, on success, routes to a dashboard greeting the user by name.
// e2e/login.spec.ts
import { test, expect } from "@playwright/test";
test("a user can log in and reach the dashboard", async ({ page }) => {
await page.goto("/login");
await page.getByLabel("Email").fill("[email protected]");
await page.getByLabel("Password").fill("correct-horse");
await page.getByRole("button", { name: "Sign in" }).click();
await expect(page).toHaveURL("/dashboard");
await expect(
page.getByRole("heading", { name: "Welcome back, Ada" })
).toBeVisible();
});
Note that every action is awaited. Playwright actions are asynchronous because each one waits for the element to be ready before acting.
Selectors: prefer user-facing locators
A locator is a lazy, re-queryable handle to an element. Playwright resolves it freshly each time you use it, so locators never go stale. Prefer locators that mirror how a person perceives the page, in roughly this order:
| Locator | Finds by | Best for |
|---|---|---|
getByRole | ARIA role + accessible name | Buttons, links, headings, inputs |
getByLabel | Associated <label> text | Form fields |
getByText | Visible text content | Static content, status messages |
getByPlaceholder | Placeholder attribute | Inputs without a label |
getByTestId | data-testid attribute | Last resort when nothing else fits |
Role-based locators double as an accessibility check: if getByRole("button", { name: "Sign in" }) can’t find your control, neither can a screen reader.
Avoid CSS or XPath selectors tied to class names or DOM structure. They break on harmless refactors and tell you nothing about whether the app is usable.
Auto-waiting eliminates flakiness
The biggest source of flaky E2E tests is timing—asserting before the UI has updated. Playwright removes this by making both actions and assertions wait. Before clicking, it checks that the element is attached, visible, stable, and enabled. Web-first assertions like toBeVisible() and toHaveText() retry until they pass or a timeout elapses.
// No manual sleeps needed—Playwright polls until the row appears.
await page.getByRole("button", { name: "Add item" }).click();
await expect(page.getByText("Milk")).toBeVisible();
await expect(page.getByRole("listitem")).toHaveCount(3);
This means you should almost never reach for page.waitForTimeout(). A fixed sleep is both slower and less reliable than an assertion that waits exactly as long as needed.
When E2E beats component tests
E2E and component tests are complementary, not competing. Component tests (with React Testing Library) are fast, run in a simulated DOM, and isolate a single unit—ideal for covering many branches and edge cases cheaply. E2E tests are slower and broader; reserve them for the journeys where integration is the whole point.
- Reach for E2E when a flow crosses boundaries: login and session persistence, multi-step checkout, navigation between routes, or anything depending on the real backend or third-party redirects.
- Stick with component tests for prop variations, conditional rendering, validation messages, and hook behavior—running hundreds of them costs seconds.
A healthy suite is mostly component tests with a thin layer of E2E covering the handful of flows that, if broken, mean the product is broken.
Best Practices
- Drive a real (or realistic) build and backend in E2E so you test the same code users run; reset state between tests for isolation.
- Use
getByRoleandgetByLabelfirst; treatgetByTestIdas a fallback, and never assert on CSS classes. - Let auto-waiting do its job—use web-first assertions and delete every
waitForTimeout. - Keep the E2E suite small and high-value; push edge cases down into faster component tests.
- Enable tracing on retry (
trace: "on-first-retry") so failures come with a step-by-step replay. - Run across at least Chromium and WebKit in CI to catch engine-specific rendering bugs.