frontend / testing / 09_playwright_e2e.md

E2E Testing with Playwright

6 min read source

E2E Testing with Playwright

TL;DR

Playwright is the modern default for browser-based end-to-end tests — Microsoft-owned, single binary spawning Chromium/Firefox/WebKit, auto-wait built in, network interception, parallel execution, traces for debugging. It replaces Cypress (which is single-browser and limited at scale) and Selenium (clunkier, older) as the senior pick. The discipline: few E2E tests, only the critical paths. They’re the slowest tier; over-investing makes the suite hate you.

Interview Q&A

Q: Playwright vs Cypress vs Selenium — quick comparison.

A:

Playwright Cypress Selenium / WebdriverIO
Browsers Chromium + Firefox + WebKit Chromium + Firefox + Webkit (recent) All major (driver per browser)
Architecture controls browser via DevTools/CDP runs inside the browser uses WebDriver protocol
Iframes / multiple tabs native support limited / hacky native
Parallelization built-in, multi-worker, multi-shard parallel via paid service (Dashboard) possible, more setup
Network mocking first-class first-class clunkier
Speed fast fast (in-browser) slowest
Traces / debug UI excellent (--trace on) great (time-travel debugger) basic
Ecosystem growing rapidly mature mature

For a new project: Playwright. Cypress is great for component tests + simple flows; Playwright wins for cross-browser, multi-tab, complex auth, and CI parallelism.

Q: Basic Playwright test.

A:

// e2e/login.spec.ts
import { test, expect } from "@playwright/test";

test("user can log in", async ({ page }) => {
  await page.goto("https://app.example.com/login");
  await page.getByLabel("Email").fill("ada@example.com");
  await page.getByLabel("Password").fill("secret");
  await page.getByRole("button", { name: "Sign in" }).click();
  await expect(page.getByRole("heading", { name: /welcome, ada/i })).toBeVisible();
});

Notice the Testing-Library-style queries (getByLabel, getByRole). Playwright adopted them — same role-first philosophy, same accessible-name matching. Tests survive cosmetic changes.

Q: Auto-wait — what does Playwright wait for?

A: Before each action, Playwright checks:

  • Element is attached to the DOM.
  • Element is visible (not display:none, visibility:hidden, opacity:0).
  • Element is stable (not animating).
  • Element is enabled (not disabled).
  • Element is receiving events (no overlay blocking it).

If any fails, Playwright retries until the action’s timeout (default 30s). Means you almost never write waitForSelector — the action waits automatically.

expect(locator).toBeVisible() and friends also auto-retry. Tests are stable without manual waits.

Q: Locators vs selectors.

A: A locator is a lazy reference to an element; querying happens at the moment of use. This is what makes auto-wait possible — Playwright re-queries on each retry.

const submitButton = page.getByRole("button", { name: "Submit" });
await expect(submitButton).toBeEnabled();         // re-queries until passes
await submitButton.click();

Compare to a Selenium-style upfront find_element — once it returns a reference, the reference is stale if the DOM changes. Playwright’s locators always look up fresh.

Q: Network interception.

A:

test("shows error on 500", async ({ page }) => {
  await page.route("/api/users", (route) => {
    route.fulfill({ status: 500, body: JSON.stringify({ error: "server" }) });
  });

  await page.goto("/users");
  await expect(page.getByText(/something went wrong/i)).toBeVisible();
});

test("intercepts and modifies response", async ({ page }) => {
  await page.route("/api/users", async (route) => {
    const response = await route.fetch();
    const json = await response.json();
    json.unshift({ id: 99, name: "Injected" });
    await route.fulfill({ response, json });
  });

  await page.goto("/users");
  await expect(page.getByText(/injected/i)).toBeVisible();
});

Lets you test edge cases (server errors, slow responses, specific payloads) without backend changes. Same idea as MSW but at the Playwright layer.

Q: Multi-page, multi-tab, multi-context.

A:

test("two users in chat", async ({ browser }) => {
  const aliceContext = await browser.newContext();
  const bobContext = await browser.newContext();
  const alice = await aliceContext.newPage();
  const bob = await bobContext.newPage();

  await alice.goto("/chat");
  await bob.goto("/chat");

  await alice.getByPlaceholder("Message").fill("hi bob");
  await alice.getByRole("button", { name: "Send" }).click();

  await expect(bob.getByText("hi bob")).toBeVisible();
});

Each context is a fresh browser profile (cookies, storage isolated). Perfect for chat, collaboration, presence, multiplayer testing where multiple users matter — something component tests can’t simulate.

Q: Authentication setup — how do you avoid logging in in every test?

A: Storage state: log in once, save cookies + localStorage, reuse.

// e2e/auth.setup.ts
import { test as setup, expect } from "@playwright/test";

setup("authenticate", async ({ page }) => {
  await page.goto("/login");
  await page.getByLabel("Email").fill("test@example.com");
  await page.getByLabel("Password").fill("password");
  await page.getByRole("button", { name: "Sign in" }).click();
  await expect(page.getByRole("heading", { name: /dashboard/i })).toBeVisible();
  await page.context().storageState({ path: ".auth/user.json" });
});
// playwright.config.ts
projects: [
  { name: "setup", testMatch: /.*\.setup\.ts/ },
  {
    name: "authenticated",
    use: { storageState: ".auth/user.json" },
    dependencies: ["setup"],
  },
],

Tests in the “authenticated” project start already logged in. Saves minutes per CI run.

Q: Parallelization.

A: Playwright runs tests in workers (separate processes). Files run in parallel; tests within a file run serially by default (can flip with test.describe.configure({ mode: "parallel" })).

// playwright.config.ts
fullyParallel: true,
workers: process.env.CI ? 4 : undefined,    // 4 workers in CI

For massive suites, sharding splits across CI machines:

npx playwright test --shard=1/4
npx playwright test --shard=2/4
# ... run in parallel jobs

Each shard runs a subset; combine in CI for total wall-clock minutes.

Q: Traces — the killer debugging feature.

A: A trace records actions, network, DOM snapshots, screenshots — replayable in a UI.

// playwright.config.ts
use: {
  trace: "on-first-retry",     // record only on retries (saves disk)
},

When a CI test fails, the trace artifact is uploaded; open with npx playwright show-trace trace.zip. You see the test as a timeline — every action, the DOM at that moment, the network calls, console logs. Debugging an obscure E2E failure goes from “log-fishing” to “watch a replay.”

Q: When are E2E tests the right tool?

A:

  • Critical user journeys that must work end-to-end (sign up, log in, purchase, primary task).
  • Cross-browser bugs that only appear in WebKit / Firefox.
  • Multi-tab / multi-window flows (collaboration, OAuth popups).
  • Real network interactions (third-party iframes, OAuth providers, payment SDKs in sandbox).

When not:

  • Component logic — RTL is faster, more focused.
  • API contracts — write integration tests against the API directly.
  • Visual styling — visual regression tools are better.
  • Pure utility logic — unit tests.

A senior team has 5-30 E2E tests, not 500. The “everything is E2E” pattern was the Selenium era; modern testing rebalances toward integration.

Q: Anti-flake habits.

A:

  • Use locators, never raw querySelector strings.
  • Avoid page.waitForTimeout(ms) — replace with auto-waiting assertions.
  • Use page.route to control network — real network is the #1 flake source.
  • Isolate state — each test gets a fresh context or a known user.
  • Run --repeat-each 10 --workers 1 locally on suspected flakes — surfaces non-determinism.
  • Snapshot screenshots on failure (screenshot: 'only-on-failure').

Gotchas / edge cases

  • page.click vs locator.click — both work, but locator is the modern style (auto-wait, retries).
  • force: true on .click() skips actionability checks — usually a sign you have a real bug (overlay, animation), not a Playwright issue.
  • Selecting text inside iframespage.frameLocator(selector) returns a frame; query from there.
  • page.goto defaults to load — waits for load event. Use { waitUntil: "networkidle" } for SPAs that finish hydrating after load.
  • Mobile viewportuse: { viewport: { width: 375, height: 667 } } per project for responsive testing.
  • Test data pollution — E2E mutates real DB if hitting a real backend. Use a per-test cleanup or a staging environment with reset capability.
  • Service workers can intercept Playwright’s page.route first — disable SW for the test or be aware.

What a senior is expected to say

  • “Playwright by default — cross-browser, auto-wait, native multi-tab/multi-context, traces for debugging, built-in parallelization.”
  • “Few E2E tests — only critical journeys. Most tests live at integration; E2E for what can’t be tested below.”
  • “Storage state for auth — log in once, reuse across tests. Saves minutes per CI run.”
  • “Locators not selectors — lazy queries enable auto-wait and retry. Never page.waitForTimeout.”
  • “Network interception via page.route controls the failure modes — test 500s, slow responses, specific payloads.”
  • “Traces are the killer feature — recorded timeline of actions + DOM + network. Debugging E2E goes from log-fishing to watching a replay.”

Cross-references

Further reading