End-to-end testing with Playwright
Test your app the way users use it: in a real browser, automatically.
In 30 seconds
End-to-end (E2E) tests drive a real browser through real user flows, such as signing up or checking out, to prove the whole system works together. Playwright, by Microsoft, runs these tests in Chromium, Firefox and WebKit with auto-waiting, reliable locators, parallel runs and a trace viewer for debugging. It is the most widely chosen modern E2E framework, with a new minor release roughly every month (1.6x in late 2026).
Not in my daily stack — I know the concepts and would learn the details when a project needs it.
Why it matters
- Unit tests can all pass while the login button does nothing. E2E tests catch the problems between frontend, backend, database and third-party scripts.
- Playwright's auto-waiting and web-first assertions remove most of the flakiness that gave older E2E tools a bad name.
- One API covers three browser engines, mobile emulation, API calls and visual comparisons, so a team needs fewer tools.
- It sits at the top of the testing pyramid, above unit and integration tests with PHPUnit or Jest: fewer tests, but they protect the flows that make money.
How it works
01Where E2E fits in the testing pyramid
The testing pyramid has many fast unit tests at the bottom, fewer integration tests in the middle, and a small number of E2E tests at the top. E2E tests are the most realistic but also the slowest and most expensive to maintain, so use them for critical journeys: sign up, log in, search, add to cart, checkout, password reset, key admin actions. Do not test every validation message through the browser; that belongs in unit or component tests. A healthy suite has tens to a few hundred E2E tests that run in minutes in parallel, and each one protects something that would hurt users or revenue if it broke.
02Setup and your first test
Install with npm init playwright@latest, which creates a config, an example test and a GitHub Actions workflow, and downloads browsers. A test is an async function receiving fixtures; the most important is page, a fresh browser tab in an isolated browser context (like a new incognito profile) for every test. Locate elements with user-facing locators such as getByRole, getByLabel and getByText, act on them, and assert with expect. Playwright waits automatically for elements to be visible, enabled and stable before clicking, and web-first assertions such as toBeVisible and toHaveURL retry until they pass or time out. You almost never need manual waits.
import { test, expect } from "@playwright/test";
test("user can search and open a product", async ({ page }) => {
await page.goto("/");
await page.getByRole("searchbox", { name: "Search" }).fill("running shoes");
await page.getByRole("button", { name: "Search" }).click();
const results = page.getByRole("listitem").filter({ hasText: "Trail" });
await expect(results.first()).toBeVisible();
await results.first().getByRole("link").click();
await expect(page).toHaveURL(/\/products\//);
await expect(page.getByRole("heading", { level: 1 })).toContainText("Trail");
});
03Locators that survive redesigns
Flaky and brittle tests usually come from bad selectors. A CSS path like div > div:nth-child(3) > button breaks when a designer adds a wrapper. Prefer locators that reflect what users and assistive technology see: getByRole with an accessible name first, then getByLabel for form fields, getByPlaceholder, getByText, and getByTestId as an explicit contract when nothing else is stable. Locators are lazy and strict: they are re-evaluated on each action, and if one matches several elements, Playwright throws instead of clicking the wrong one. Chain and filter locators to scope them, for example a row containing a specific order number. A bonus: role-based locators push the app toward better accessibility.
- Good: page.getByRole("button", { name: "Place order" })
- Good: page.getByLabel("Email")
- Avoid: page.locator(".btn.btn-primary:nth-child(2)")
04Configuration, projects and authentication
playwright.config.ts defines the base URL, browsers (projects), retries, reporters and how to start the app. A common pattern is a setup project that logs in once and saves the browser storage state (cookies and localStorage) to a file; other projects reuse it, so most tests start already authenticated without repeating the login UI. webServer starts your app before tests and waits for it. Keep traces for failed retries, which gives you a full recording to debug CI failures. Do not commit the saved auth file; add the folder to .gitignore.
import { defineConfig, devices } from "@playwright/test";
export default defineConfig({
testDir: "./e2e",
fullyParallel: true,
forbidOnly: !!process.env.CI,
retries: process.env.CI ? 2 : 0,
reporter: [["html"], ["list"]],
use: { baseURL: "http://localhost:3000", trace: "on-first-retry" },
projects: [
{ name: "setup", testMatch: /.*\.setup\.ts/ },
{ name: "chromium", dependencies: ["setup"],
use: { ...devices["Desktop Chrome"], storageState: "playwright/.auth/user.json" } },
{ name: "mobile", dependencies: ["setup"],
use: { ...devices["iPhone 15"], storageState: "playwright/.auth/user.json" } },
],
webServer: { command: "npm run start", url: "http://localhost:3000", reuseExistingServer: !process.env.CI },
});
05Fixtures and page objects
Fixtures are Playwright's dependency injection: a test declares what it needs (page, request, or your own fixture such as a logged-in admin or a seeded product) and Playwright sets it up and tears it down around the test. Custom fixtures replace beforeEach chains and keep tests short. Page object models wrap the locators and actions of a page in a class, so a UI change is fixed in one place. Keep page objects thin: they expose actions in user language (addToCart, checkout) and leave assertions to tests. Use the request fixture to create test data through the API, which is much faster than clicking through the UI.
import { test as base, expect } from "@playwright/test";
type Fixtures = { product: { slug: string } };
export const test = base.extend<Fixtures>({
product: async ({ request }, use) => {
const res = await request.post("/api/test/products", { data: { name: "E2E shoe", price: 50 } });
const product = await res.json();
await use(product); // run the test
await request.delete("/api/test/products/" + product.slug); // clean up
},
});
test("product page shows price", async ({ page, product }) => {
await page.goto("/products/" + product.slug);
await expect(page.getByTestId("price")).toHaveText("$50.00");
});
06Debugging and CI
Playwright ships strong tools. npx playwright test --ui opens UI mode with a watch mode and time-travel through each step. --debug opens the inspector to step through actions. codegen records your clicks into a test, a good starting point you then clean up. The trace viewer shows every action, DOM snapshot, network request and console log of a failed test, which is how you debug CI failures without reproducing them locally. In CI, install browsers with npx playwright install --with-deps, run in parallel with workers, split large suites across machines with --shard, and upload the HTML report and traces as artifacts. The official Docker image contains all browser dependencies.
# .github/workflows/e2e.yml (steps)
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with: { node-version: 22, cache: npm }
- run: npm ci
- run: npx playwright install --with-deps
- run: npx playwright test --shard=${{ matrix.shard }}/4
- uses: actions/upload-artifact@v4
if: ${{ !cancelled() }}
with: { name: report-${{ matrix.shard }}, path: playwright-report/ }
Use it when
- Protecting critical journeys such as sign-up, login, checkout and payments against regressions.
- Cross-browser checks, especially Safari behaviour through WebKit, and mobile viewport emulation.
- Smoke tests after each deployment to confirm the live environment works.
- Visual regression checks on key pages and components.
Skip it when
- Testing every edge case of business logic or validation; unit and integration tests are faster and more precise.
- Load testing; a browser per user is far too heavy, use k6 or similar for traffic.
- Unstable prototypes whose UI changes daily; the tests will cost more than they save until flows settle.
Alternatives
- Cypress
Popular E2E tool with an excellent interactive runner; runs inside the browser, with more limits on multi-tab and multi-origin flows.
- Selenium / WebDriver
The long-standing standard with every language and Selenium Grid; more boilerplate, manual waits more common.
- WebdriverIO
Node.js framework over WebDriver and WebDriver BiDi, also used for mobile with Appium.
- Vitest browser mode / Testing Library
Component tests in a real browser; faster than E2E for UI logic.
Best practices
- Test user-visible behaviour with role, label and text locators; use data-testid only as an explicit contract.
- Rely on auto-waiting and web-first assertions; never use fixed waitForTimeout sleeps.
- Keep tests independent: each creates its own data, ideally through the API, and can run in any order.
- Log in once in a setup project and reuse storageState instead of logging in through the UI in every test.
- Mock only what you do not own (third-party payment widgets, analytics) with page.route; keep your own backend real.
- Collect traces on first retry and upload reports in CI; treat a flaky test as a bug to fix, not to retry forever.
- Run tests in parallel and shard in CI so the suite stays under about ten minutes.
Common mistakes
- Using brittle CSS or XPath selectors tied to layout and class names.
- Adding waitForTimeout(3000) to fix flakiness, making tests both slow and still flaky.
- Tests that depend on each other's data or order, which break under parallel runs.
- Testing everything through the UI, producing a slow suite that nobody runs locally.
- Hiding real failures with high retry counts instead of investigating traces.
Deep dive
07How Playwright drives browsers
Playwright runs your test code in Node.js and talks to browsers over a persistent connection: the Chrome DevTools Protocol for Chromium and its own patched protocols for Firefox and WebKit. This out-of-process design lets one test control several pages, contexts and even browsers, and observe network traffic directly. Browser contexts are cheap, isolated sessions inside one browser process, which is why each test can get a fresh context in milliseconds. Playwright pins specific browser builds to each release; since 1.57 it uses Chrome for Testing builds for Chromium, and you install them with npx playwright install. You can also run against branded Chrome or Edge with the channel option for extra confidence.
08Network control: mocking, HAR and API testing
page.route intercepts requests matching a URL pattern so you can fulfil them with fixed data, modify them, or abort them to simulate failures. That is how you test “payment provider is down” without breaking a real provider. routeFromHAR replays recorded traffic for stable demos or offline tests. The request fixture is a full HTTP client that shares cookies with the browser context if you want, so you can create data, call internal endpoints, or write pure API tests in the same framework. Combine them: create an order through the API, then verify the UI shows it, which is fast and checks both layers.
test("shows a friendly error when payments fail", async ({ page }) => {
await page.route("**/api/payments/intent", (route) =>
route.fulfill({ status: 503, json: { error: "unavailable" } }),
);
await page.goto("/checkout");
await page.getByRole("button", { name: "Pay now" }).click();
await expect(page.getByRole("alert")).toContainText("try again");
});
09Visual regression testing
expect(page).toHaveScreenshot() captures a screenshot and compares it with a stored baseline pixel by pixel, failing if the difference exceeds a threshold. The first run creates the baseline; update it intentionally with --update-snapshots. Screenshots differ across operating systems and font rendering, so generate and compare baselines in the same environment, usually the official Playwright Docker image in CI. Reduce noise: mask dynamic areas (dates, ads, avatars) with the mask option, disable animations, wait for fonts, and screenshot specific components instead of full pages where possible. Accessibility snapshots with toMatchAriaSnapshot compare the accessibility tree as text, which is less brittle than pixels and catches structural regressions.
await expect(page).toHaveScreenshot("checkout.png", {
fullPage: true,
mask: [page.getByTestId("order-date"), page.locator(".promo-banner")],
animations: "disabled",
maxDiffPixelRatio: 0.01,
});
10Scaling a suite: parallelism, sharding and flakiness control
Playwright runs test files in parallel across worker processes by default; fullyParallel also parallelises tests within a file. Each worker gets its own browser, so tests must not share mutable data: create unique records per test or use worker-scoped fixtures that create one account per worker (testInfo.workerIndex helps). In CI, shard across machines with --shard=1/4 and merge the blob reports with npx playwright merge-reports into one HTML report. Track flakiness: a test that passes on retry is reported as flaky; review that list weekly and fix the root cause, usually shared state, missing awaits or animations. --repeat-each and --fail-on-flaky-tests help reproduce and enforce. Recent versions also offer AI-assisted test agents that plan, generate and heal tests, which still need review.
Real-world scenarios
A checkout test fails only in CI
Problem
The checkout E2E test passes locally every time but fails in CI about one run in five with “element not found”.
How to solve it
Enable trace: "on-first-retry" and download the trace artifact from a failed run. Step to the failing action and check the DOM snapshot and network tab: often a request was still pending, a toast covered the button, or test data from another parallel test changed the cart. Replace any locator that matches by position with a role and name, replace manual waits with web-first assertions, and give the test its own cart through a fixture that creates data via the API. Reproduce locally with --repeat-each=20 --workers=4 to confirm the fix before merging.
Suite takes 45 minutes and nobody waits for it
Problem
The E2E suite has grown to 600 tests that run serially in one CI job. Developers merge without waiting, and failures are discovered days later.
How to solve it
First, prune: move validation and edge-case tests down to unit and component tests, keeping E2E for critical journeys. Replace UI login in each test with a setup project and storageState, and seed data through the API instead of the UI. Make tests independent so fullyParallel can be enabled, then shard across four or more CI machines and merge blob reports. Tag a fast @smoke subset to run on every pull request and run the full suite on the main branch and nightly. Target under ten minutes for pull request checks, and track flaky tests as bugs.
Adding E2E tests to a legacy app with no test ids
Problem
An older web app has no tests, inconsistent markup and few accessible labels. The team wants E2E coverage for login and ordering before a big refactor.
How to solve it
Start with codegen to record the two main flows, then rewrite the generated selectors into role and label locators where possible. Where the markup gives no stable hook, add data-testid attributes to the few key elements, which is a tiny, safe change. Fix missing labels and button names as you go, which improves accessibility too. Use a seeded test database and a setup project for authentication. Add a visual snapshot of the order confirmation page as a safety net. Run these tests in CI before starting the refactor, so they act as a characterisation suite that protects current behaviour.
Cheat sheet
- Create a new Playwright project
npm init playwright@latest- Run all tests
npx playwright test- Interactive UI mode
npx playwright test --ui- Debug one test
npx playwright test checkout.spec.ts --debug- Record a test
npx playwright codegen http://localhost:3000- Open a trace
npx playwright show-trace trace.zip- Install browsers in CI
npx playwright install --with-deps- Shard across machines
npx playwright test --shard=2/4- Update visual baselines
npx playwright test --update-snapshots- Run only tagged tests
npx playwright test --grep @smoke
Questions & answers
Q1Why is Playwright less flaky than older E2E tools?
Most flakiness comes from timing: clicking before an element is ready, or asserting before data loads. Playwright performs actionability checks before every action, waiting until the element is attached, visible, stable, enabled and able to receive events. Its web-first assertions like toBeVisible or toHaveText retry until the condition is met or a timeout passes, so there is no need for manual sleeps. Each test also runs in a fresh browser context, isolating cookies and storage. Strict locators throw when they match more than one element instead of clicking the wrong one. Together these remove the common causes of random failures.
Q2Which locators should you use?
Use locators that reflect how users perceive the page. The recommended order is getByRole with an accessible name, such as a button named Place order, then getByLabel for form fields, getByPlaceholder, getByText and getByAltText. Use getByTestId when no user-facing attribute is stable, treating the test id as an explicit contract between developers and tests. Avoid CSS and XPath selectors tied to structure or styling classes, because they break on redesigns. Role locators also double as a light accessibility check: if you cannot find a button by role and name, screen reader users probably cannot either.
Q3How do you handle authentication in E2E tests?
Logging in through the UI before every test is slow and adds flakiness. The recommended approach is a setup project that logs in once, through the UI or an API, and saves the browser storage state, meaning cookies and localStorage, to a file. Other projects depend on the setup and load that state, so every test starts already authenticated in its own isolated context. For different roles, save one state per role, such as admin and customer. Keep the auth file out of version control, use dedicated test accounts with secrets from environment variables, and keep one test that covers the actual login flow.
Q4What is the trace viewer and why does it matter?
The trace viewer is a recording of a test run: every action, a DOM snapshot before and after it, network requests, console messages, screenshots and the source line. You enable it in the config, typically trace on-first-retry, so CI keeps a trace whenever a test fails once. You open it locally with npx playwright show-trace or at trace.playwright.dev, and step through the failure as if you were watching over the user's shoulder. It turns “works on my machine” debugging of CI-only failures into minutes instead of hours, which is one of Playwright's biggest practical advantages.
Q5Should E2E tests mock the backend?
Mostly no. The purpose of E2E testing is to verify that the frontend, backend and database work together, so mocking your own backend turns it into a slower component test. Mock what you do not control and cannot run reliably: third-party payment pages, analytics, maps, email providers or a flaky partner API. Playwright's page.route lets you intercept those requests and return fixed responses or errors, which is also great for testing error states. For your own system, seed data through test APIs or database fixtures, and run against a dedicated environment you can reset.
Q6Playwright or Cypress?
Both are strong. Cypress runs inside the browser and offers a very polished interactive runner, and many teams know it well. Playwright controls browsers from outside through their protocols, which gives it native support for multiple tabs, multiple origins, iframes, downloads and WebKit for Safari coverage. Parallel execution and sharding are built in and free, and it supports TypeScript, JavaScript, Python, Java and .NET. For a new project in 2026 I would usually choose Playwright for that breadth and speed; for a team with a large, healthy Cypress suite, migration only makes sense if its limits really hurt.
Senior-level questions
S1How do you design test data management for a parallel E2E suite?
Parallel tests fail when they share mutable state, so every test needs data it owns. I prefer creating data through a test-only API or factories exposed in non-production environments, wrapped in fixtures that create and clean up. For expensive setup, worker-scoped fixtures create one account per worker, keyed by workerIndex. Shared read-only reference data, such as a product catalogue, is seeded once per environment. Unique identifiers (timestamps or UUIDs in emails and names) prevent collisions. The environment should be resettable: an ephemeral database per pipeline is ideal; otherwise a nightly reset. The trade-off is effort: test APIs must be secured and disabled in production, but they make tests faster and far more reliable than UI-driven setup.
S2How do you decide what belongs in E2E versus lower test levels?
I map risk and cost. E2E tests are best for verifying integration across the whole stack along journeys that matter most: revenue, security and onboarding. Each such journey gets one happy path and maybe one important failure path. Business rules with many combinations, such as discount calculation, belong in unit tests; API contracts and database behaviour in integration tests; UI state and validation in component tests. A useful rule: if a bug could only be caught with a real browser and real backend together, it deserves E2E coverage. I also review production incidents: if one escaped, I add a test at the lowest level that would have caught it, and only climb to E2E when necessary.
S3How would you introduce Playwright into a team with a slow, flaky Selenium suite?
I would not rewrite everything at once. First, measure the current suite: duration, flake rate and which tests catch real bugs. Then pick the five most valuable journeys and implement them in Playwright with role-based locators, fixtures, storageState and traces, running in CI next to the old suite. Show the difference in duration and stability over a few weeks. Agree on conventions in a short guide: locator priority, no fixed waits, data via API, page objects for shared pages. Migrate in priority order and delete each Selenium test once its replacement is stable, rather than maintaining duplicates. The main risks are underestimating test data work and the learning curve, so pair programming and code reviews on tests matter as much as the tool.
Glossary
- End-to-end test
- A test that drives the real application through a full user flow, frontend to database.
- Locator
- A lazy, re-evaluated description of how to find an element on the page.
- Auto-waiting
- Playwright waits until an element is ready before acting on it, without manual sleeps.
- Web-first assertion
- An expect check that retries until the condition holds or times out.
- Browser context
- An isolated browser session with its own cookies and storage, like a fresh profile.
- Fixture
- A reusable piece of setup and teardown that a test requests by name.
- storageState
- Saved cookies and local storage used to start tests already logged in.
- Trace
- A recording of a test run with actions, DOM snapshots, network and console logs.
- Sharding
- Splitting a test suite across several machines to run it faster.
- Flaky test
- A test that sometimes passes and sometimes fails without any code change.
Keep learning
Have a product in mind?Let's build it.
Tell me about your idea, your product or your team. I usually reply within one working day.