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
querySelectorstrings. - Avoid
page.waitForTimeout(ms)— replace with auto-waiting assertions. - Use
page.routeto 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 1locally on suspected flakes — surfaces non-determinism. - Snapshot screenshots on failure (
screenshot: 'only-on-failure').
Gotchas / edge cases
page.clickvslocator.click— both work, but locator is the modern style (auto-wait, retries).force: trueon.click()skips actionability checks — usually a sign you have a real bug (overlay, animation), not a Playwright issue.- Selecting text inside iframes —
page.frameLocator(selector)returns a frame; query from there. page.gotodefaults toload— waits forloadevent. Use{ waitUntil: "networkidle" }for SPAs that finish hydrating afterload.- Mobile viewport —
use: { 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.routefirst — 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.routecontrols 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
- Testing strategy (where E2E fits): 01_testing_strategy.md
- Visual regression (often paired): 10_a11y_and_visual_regression.md
- Frontend system design — testing the journeys: ../14_frontend_system_design/
Further reading
- Playwright docs: https://playwright.dev/
- Auto-waiting: https://playwright.dev/docs/actionability
- Traces: https://playwright.dev/docs/trace-viewer
- Test sharding: https://playwright.dev/docs/test-sharding