← All interview Q&A
Free · Top 25 questions

Playwright (TypeScript) interview questions

The 25 Playwright TypeScript questions hiring managers actually ask — typing, fixtures, the Expect API and how TS changes the framework.

Each answer has three parts — Concept (the real difference), Interview Strategy (how to say it in the room), and Code (a quick glimpse). This is the same format as my paid kits.

300+ questions in the full kit

SDET Playwright TypeScript Foundation Kit 2026

A glimpse of the full Foundation Kit — TypeScript for SDETs, architecture, locators, fixtures, the Expect API, network, CI/CD and AI-assisted testing, all interview-ready.

01

Playwright with TypeScript vs JavaScript — why do most teams pick TS?

Junior
Concept

Both run the exact same Playwright engine — the difference is the language layer on top. TypeScript adds static types that are checked by the compiler before a single test runs, so a wrong fixture name or a misspelt option becomes a compile error at your desk, not a two-in-the-morning CI failure. Playwright itself is written in TypeScript and ships with full type definitions, so TS is the path of least resistance — its docs and examples default to it. The cost is a compile step, but Playwright handles transpilation for you, so in practice there is almost no friction.

Interview Strategy

Lead with the one fact interviewers reward: Playwright is written in TypeScript and its documentation defaults to it, so you get the best autocomplete and the safest refactors for free. Then frame the value as catching mistakes at author time instead of run time. Avoid the lazy line that TypeScript is simply better — be clear that plain JavaScript still works and is the right call for a small suite or a mixed-skill team.

How to phrase it

Both options drive the identical Playwright engine, so the choice is really about the language layer. I pick TypeScript on almost every new suite for one concrete reason: mistakes surface at author time. If I misspell a fixture name or pass a wrong option, the compiler stops me at my desk instead of letting it fail at two in the morning in CI. Playwright itself is written in TypeScript and the official docs default to it, so the editor autocomplete and type hints are first-class — I am working with the grain of the tool, not against it. The payoff really shows once the framework grows: when I rename a page-object method, every caller that is now wrong fails to compile immediately, so refactoring stays safe at scale. I do not pretend JavaScript is obsolete. For a small suite, a team where front-end developers also write tests, or a codebase with no build tooling yet, plain JavaScript is a perfectly reasonable, lower-friction choice.

Key points to hit

  • Same Playwright engine — TypeScript only changes the language layer on top.
  • Playwright is written in TypeScript and its docs default to it, so autocomplete and types are first-class.
  • Types catch typos and wrong options at compile time, not at run time in CI.
  • Payoff grows with the suite — safe renames and refactors across every caller.
  • Plain JavaScript is still fine for small suites, mixed-skill teams, or zero-build setups.
Code
import { test, expect } from "@playwright/test";

test("types catch typos before run", async ({ page }) => {
  await page.goto("/login");
  // A misspelt option here fails to compile, not at runtime.
  await page.getByRole("button", { name: "Sign in" }).click();
  await expect(page).toHaveURL(/dashboard/);
});
02

How do TypeScript types actually help in a Playwright suite vs untyped tests?

Mid
Concept

In untyped tests a mistake only surfaces when that exact line executes — often late and flaky. With types, your fixtures, page objects and helper return values all carry a shape, so the editor autocompletes the right methods and the compiler rejects a wrong argument the moment you write it. The payoff compounds with the suite: rename a page-object method and every caller breaks at compile time, so the change cannot ship half-done. Types are also documentation that cannot go stale, because the compiler keeps them honest.

Interview Strategy

Give a concrete example rather than abstract praise — a custom fixture typed as a LoginPage means callers get autocomplete and a typo is caught instantly. Then make the senior point: the real win is not autocomplete, it is safe refactoring at scale. Avoid stopping at nice editor experience, because that is the junior answer.

How to phrase it

The honest difference is when the mistake reaches you. In an untyped suite, a wrong argument or a misspelt method only shows up when that line runs, which is often deep in a flaky end-to-end flow. With types, the same mistake is a red squiggle as I type it. A concrete example: if I type a custom fixture as a LoginPage, then inside the test the editor already knows it has a login method and what arguments that method takes, so a typo is impossible and I get autocomplete for free. But I am careful not to sell types as just a nicer editor — that is the junior answer. The real value is safe refactoring at scale. When I rename a page-object method, every test that still calls the old name fails to compile, so the change either lands completely or not at all; it can never ship half-done. And the types act as documentation that the compiler keeps honest, so they never drift out of date the way a comment does.

Key points to hit

  • Untyped: mistakes surface late, at run time, often inside flaky flows.
  • Typed: wrong arguments and method names are caught the instant you write them.
  • Typed fixtures and page objects give callers real autocomplete on their methods.
  • The senior win is safe refactoring — a rename breaks every stale caller at compile time.
  • Types are documentation the compiler keeps honest, so they cannot go stale.
Code
import { test as base } from "@playwright/test";
import type { LoginPage } from "./pages/login";

type Fixtures = { loginPage: LoginPage };

const test = base.extend<Fixtures>({ /* ... */ } as Fixtures);

test("typed fixture gives autocomplete", async ({ loginPage }) => {
  // The editor knows loginPage.login(...) and its argument types.
  await loginPage.login("sree@example.com", "secret");
});
03

Playwright vs Selenium — which should you learn in 2026?

Mid
Concept

The headline difference is architecture. Selenium speaks the W3C WebDriver protocol — historically an HTTP request per command — which adds latency and is why Selenium suites lean so heavily on explicit waits. Playwright keeps a persistent connection to the browser and bakes auto-waiting into every action, which removes a whole class of flakiness. Playwright also bundles its own browser builds and ships tracing, codegen and network interception in the box. Where Selenium still wins: the largest job pool, the widest language support, and mature Grid and real-device clouds.

Interview Strategy

Lead with architecture — Playwright's persistent connection with built-in auto-waiting versus Selenium's per-command round-trips — because that single difference explains the flakiness gap. Then be scrupulously fair about where Selenium still wins, because bashing either tool reads as inexperience. The senior signal is naming Selenium 4's BiDi protocol, which shows you know the gap has narrowed.

How to phrase it

I would not frame it as one beating the other, because that usually signals you have only used one of them. The real difference is architectural. Selenium speaks the W3C WebDriver protocol, historically an HTTP request per command, and that latency plus the lack of built-in waiting is why Selenium suites lean so heavily on explicit waits. Playwright keeps a persistent connection to the browser and bakes auto-waiting into every action, which removes a whole category of flakiness you fight constantly in Selenium. Playwright also bundles its own browsers and ships tracing, codegen and network interception out of the box. But I stay fair: Selenium still wins on job volume, on the widest language support — Java, C-sharp, Python, Ruby, JavaScript — and on mature Grid and real-device clouds. And Selenium four added BiDi and relative locators, so the gap is narrower than people assume. My honest 2026 take: for a greenfield web suite I reach for Playwright, while Selenium stays the right call where it already runs or in a polyglot org with a real Grid investment.

Key points to hit

  • Selenium: W3C WebDriver, historically HTTP-per-command, hence heavy explicit waits.
  • Playwright: persistent connection plus built-in auto-waiting removes a class of flakiness.
  • Playwright bundles browsers and ships tracing, codegen and interception; Selenium has the bigger ecosystem.
  • Selenium still wins on job volume, language breadth, and mature Grid and device clouds.
  • Selenium 4 BiDi narrows the gap; choose per team and existing stack, not by tribe.
Code
// Selenium (Java): the explicit wait is on you.
// new WebDriverWait(driver, Duration.ofSeconds(10))
//   .until(ExpectedConditions.elementToBeClickable(By.id("pay")))
//   .click();

// Playwright (TypeScript): auto-waits for actionability.
await page.getByRole("button", { name: "Pay" }).click();
04

Playwright vs Cypress — what is the real difference?

Mid
Concept

The dividing line is where the test runs. Cypress runs inside the browser, in the same event loop as the application, which gives a superb time-travel debugger and direct access to app state. Playwright runs out of process over a protocol, and that is precisely what lets it do things Cypress structurally struggles with — true multi-tab and multi-origin flows, multiple independent browser contexts, real parallel workers, and first-class WebKit, Chromium and Firefox support. Cypress has a very polished local runner and a large community; the trade is breadth of control.

Interview Strategy

Lead with the architectural line — Cypress is in-browser, Playwright is out-of-process — because that single fact explains almost every downstream difference: tabs, cross-origin, real parallelism, WebKit. Then acknowledge Cypress's genuinely excellent developer experience for component-heavy front-end teams. Avoid absolute statements either way.

How to phrase it

The cleanest way to answer is by architecture, because everything else follows from it. Cypress runs inside the browser, in the same event loop as the app, which is what gives it that lovely time-travel debugger and direct access to application state. Playwright runs out of process, driving the browser over a protocol. That one design choice is why Playwright handles things Cypress structurally fights with: multiple tabs, multiple origins in a single test, several independent browser contexts at once, true parallel workers, and real WebKit, Chromium and Firefox support rather than a Chromium-first story. So when I need genuine cross-browser coverage or a complex multi-tab flow, I reach for Playwright. I am honest that Cypress is not a weak tool — its local runner and developer experience are excellent, and for a component-heavy front-end team that lives in one browser, it is a very reasonable choice. I just match the tool to the shape of the testing, and for broad end-to-end coverage that shape favours Playwright.

Key points to hit

  • Cypress runs in-browser, in the app's event loop; Playwright runs out-of-process over a protocol.
  • That architecture is why Playwright does multi-tab, multi-origin and multiple contexts natively.
  • Playwright ships real parallel workers and first-class WebKit, Chromium and Firefox.
  • Cypress offers an excellent local runner and time-travel debugger; strong for component-heavy teams.
  • Pick Playwright for cross-browser and complex flows; never make it a tribal absolute.
Code
// Out-of-process lets Playwright drive multiple tabs / origins with real workers.
const [popup] = await Promise.all([
  page.waitForEvent("popup"),
  page.getByRole("link", { name: "Open report" }).click(),
]);
await expect(popup).toHaveURL(/report/);
05

Locator vs ElementHandle — which do you use and why?

Mid
Concept

A Locator is a lazy, re-queryable description of how to find an element — it does not touch the DOM until you act, and it resolves fresh on every action, so it survives re-renders in a single-page app. An ElementHandle points at one specific node captured at a moment in time, so the moment the DOM changes that handle goes stale and throws. Locators also carry auto-waiting and the web-first expect assertions, which ElementHandles do not. In modern code you reach for ElementHandle only for rare low-level needs.

Interview Strategy

Build the answer on one line: a Locator is an instruction, an ElementHandle is a reference. Tie it directly to flakiness — the Locator re-finds the element, so re-renders do not break you, while a handle throws 'element is detached'. The senior signal is saying you almost never need ElementHandle in 2026 code.

How to phrase it

The mental model I use is that a Locator is an instruction and an ElementHandle is a reference. A Locator does not query the DOM when I create it; it re-queries fresh every time I act on it, and it auto-waits for the element to be actionable first. That is exactly why it survives a single-page-app re-render — when the framework swaps the node, the Locator simply finds the new one. An ElementHandle, by contrast, grabs one specific node at one moment. As soon as the DOM changes underneath it, that handle is detached and the next action throws. The other thing I point out is that the web-first expect assertions and auto-waiting are built around Locators, not handles, so using a Locator keeps me on the supported, retrying path. In practice I treat Locator as the default and the only API I use day to day; ElementHandle is legacy, reserved for the rare moment I genuinely need a raw node reference for some low-level operation, and in 2026 codebases that is almost never.

Key points to hit

  • Locator is a lazy description, re-queried and auto-retried on every action.
  • ElementHandle is a snapshot of one node — it goes stale and throws when the DOM changes.
  • Locators survive single-page-app re-renders; handles cause 'element is detached' errors.
  • Auto-waiting and web-first expect assertions are built around Locators, not handles.
  • Use Locator by default; ElementHandle is legacy for rare low-level needs.
Code
// Locator: re-queries on each use, auto-waits, survives re-renders.
const row = page.getByRole("row", { name: "Order 4001" });
await row.getByRole("button", { name: "Cancel" }).click();

// Legacy ElementHandle: a snapshot that can go stale.
// const handle = await page.$("#row"); // avoid in modern code
06

getByRole vs getByTestId — which locator should you prefer?

Junior
Concept

getByRole queries the accessibility tree the same way a user or a screen reader perceives the page, so it tests behaviour and resists markup churn. getByTestId targets a data-testid attribute you add specifically for tests — it is rock-stable, but it couples the test to internal markup and tells you nothing about whether the control is actually usable. Playwright recommends a priority order that leads with user-facing locators and treats test-id as a deliberate fallback, not a default.

Interview Strategy

Recommend Playwright's order out loud: prefer getByRole, getByLabel and getByText, and fall back to getByTestId only when no accessible name fits. Make the point that getByRole doubles as a basic accessibility check. The mature line is that a test-id is a deliberate, stable hook, not your first reach.

How to phrase it

I follow the priority order Playwright recommends, and I can explain why. I lead with getByRole because it queries the accessibility tree — the same thing a screen reader uses — so it tests the page the way a real user experiences it, and it does not break just because a developer changed a class name or wrapped a div. There is a bonus there too: if getByRole cannot find the button, a screen reader cannot either, so the locator doubles as a basic accessibility check, and a failure is often a real defect rather than a test problem. After getByRole I reach for getByLabel and getByText, which are still user-facing. I keep getByTestId as a deliberate fallback for the cases where there genuinely is no accessible name — a purely visual badge, an icon-only control. It is rock-stable, but it couples my test to internal markup and tells me nothing about usability, so it is a considered choice, never my default reach.

Key points to hit

  • getByRole queries the accessibility tree, testing behaviour and resisting markup churn.
  • getByRole doubles as an accessibility check — a miss often signals a real defect.
  • Priority: getByRole, then getByLabel and getByText, then getByTestId as a fallback.
  • getByTestId is stable but couples tests to internal markup and ignores usability.
  • Treat test-id as a deliberate, stable hook — not your first reach.
Code
// Prefer: behaviour-led and accessible.
await page.getByRole("button", { name: "Add to cart" }).click();

// Deliberate fallback when no accessible name fits:
await page.getByTestId("cart-badge").click();
07

Web-first assertions (expect) vs plain assertions — what changes?

Mid
Concept

Playwright's expect on a Locator — toBeVisible, toHaveText — is web-first: it auto-retries the condition until it holds or the timeout elapses. A plain assertion on a value you read once, such as reading textContent into a variable and then asserting on it, checks a single snapshot, so it races the UI and fails the instant timing slips. The rule that follows is simple: assert on the locator, never on a value you grabbed earlier.

Interview Strategy

State the rule plainly: assert on the locator, not on a value you read earlier, because the locator version retries for you and kills a whole bucket of timing flakiness. Then show the anti-pattern — reading textContent into a variable then asserting — against toHaveText. That exact contrast is what interviewers are listening for.

How to phrase it

The rule I work to is: assert on the locator, never on a value I read earlier. Playwright's expect on a locator is web-first, which means it keeps re-checking the condition — is this visible, does this have this text — and only fails if it still has not come true by the timeout. That retrying is what kills a whole bucket of timing flakiness for free. The anti-pattern, which I have seen flake plenty of suites, is reading a value once into a variable — say grabbing textContent of the total into a string — and then asserting on that string. That is a single snapshot taken at one instant, so if the UI updates a fraction of a second later, the assertion has already failed and there is no retry to save it. So instead of reading the total and comparing, I assert directly on the locator with toHaveText, and Playwright retries until the page settles. Same intent, but one version races the UI and the other waits it out. Naming that difference is usually exactly what the interviewer wants to hear.

Key points to hit

  • Web-first expect on a locator auto-retries until the condition holds or the timeout elapses.
  • A plain assertion on a once-read value is a single snapshot that races the UI.
  • Anti-pattern: read textContent into a variable, then assert on the variable.
  • Fix: assert on the locator directly, for example toHaveText, and let it retry.
  • Rule of thumb: assert on the locator, never on a value you grabbed earlier.
Code
// Flaky: a single snapshot, no retry.
// const t = await page.locator("#total").textContent();
// expect(t).toBe("\u20b94000");

// Web-first: retries until it matches or times out.
await expect(page.getByTestId("total")).toHaveText("\u20b94000");
08

Auto-waiting vs explicit waits — does Playwright remove the need for waits?

Junior
Concept

Before each action Playwright runs actionability checks — the element must be visible, stable, enabled and able to receive events — and it waits for them automatically, so you rarely write a wait at all. You still occasionally need an explicit wait for a condition that is not tied to an action, such as a specific network response or a custom application state. The thing to avoid is a hard-coded sleep, which is slow when the app is fast and flaky when it is slow.

Interview Strategy

Say auto-waiting covers the common cases, so hard-coded sleeps are an anti-pattern. When you genuinely need to wait, reach for a targeted wait — waitForResponse or expect.poll — never page.waitForTimeout. Naming waitForTimeout as the thing to avoid is the experience signal.

How to phrase it

Auto-waiting removes most waits, but not the idea of waiting entirely. Before every action, Playwright runs actionability checks — it waits for the element to be visible, stable, enabled and able to receive events — and only then performs the click or fill. So for the common case of acting on an element, I write no wait at all; the framework already does the right thing. Where I still wait explicitly is for conditions that are not tied to an action — for instance, waiting for a particular network response to come back, or for some custom application state to settle. The key is that I wait for a specific thing, not for a fixed amount of time. The anti-pattern I actively avoid is page.waitForTimeout, a hard-coded sleep, because it is slow when the app is quick and flaky when the app is slow — it never matches reality. So my targeted tools are waitForResponse for a network call and expect.poll for a computed value, and a blanket sleep almost never belongs in the suite.

Key points to hit

  • Actionability checks — visible, stable, enabled, receives events — run automatically before each action.
  • For ordinary element actions you write no wait at all.
  • Explicit waits are for conditions not tied to an action, like a network response or app state.
  • Wait for a specific thing, never for a fixed duration.
  • Avoid page.waitForTimeout; prefer waitForResponse or expect.poll.
Code
// No manual wait needed before the click — auto-waiting handles it.
await page.getByRole("button", { name: "Save" }).click();

// Targeted wait when you genuinely need one:
await page.waitForResponse((r) => r.url().includes("/api/save"));
09

Typed custom fixtures (test.extend<>) vs beforeEach hooks?

Senior
Concept

A beforeEach hook runs setup imperatively and usually leaks state through shared, mutable variables, and it runs for every test in the block whether that test needs it or not. test.extend<Fixtures>() defines named, typed fixtures that are injected only into the tests that actually request them, with lazy setup and automatic teardown that runs even when the test fails. Typing the Fixtures generic gives every test autocomplete and compile-time safety on what it receives.

Interview Strategy

Contrast composability with globality: fixtures are lazy, composable and typed; beforeEach is global and order-dependent. Make the point that a test only pays for the fixtures it names, and teardown is guaranteed even on failure. The senior move is showing the typed Fixtures generic so callers get autocomplete.

How to phrase it

A beforeEach hook is imperative and global — it runs for every test in the block, and it usually shares state through outer variables that one test can quietly mutate for the next. Fixtures fix all three problems. With test.extend and a typed Fixtures generic, I define named fixtures, and a test only receives the ones it actually names in its arguments, so it pays only for what it needs and there is no shared mutable state leaking across tests. Setup is lazy — the fixture only runs if a test asks for it — and whatever comes after the use call is teardown that runs even when the test fails, so I never leak a context or a record from a broken test into the next one. Because they compose, one fixture can depend on another and Playwright resolves the whole graph for me. And typing the generic means every test gets autocomplete on its fixtures and the compiler rejects a wrong name. So I describe fixtures as dependency injection for tests: declare what you need, and let the framework own the wiring and the cleanup.

Key points to hit

  • beforeEach is imperative, global, and prone to shared mutable state.
  • Fixtures are injected only into tests that name them, so a test pays for what it uses.
  • Setup is lazy; the code after use() is teardown that runs even on failure.
  • Fixtures compose — one can depend on another and Playwright resolves the graph.
  • Typing the Fixtures generic gives autocomplete and compile-time safety.
Code
import { test as base } from "@playwright/test";
import { LoginPage } from "./pages/login";

type Fixtures = { loginPage: LoginPage };

export const test = base.extend<Fixtures>({
  loginPage: async ({ page }, use) => {
    const lp = new LoginPage(page);
    await lp.goto();
    await use(lp);
    // Teardown here runs even if the test failed.
  },
});
10

Project config (multi-browser) vs a single config — when do you need projects?

Mid
Concept

A single config runs everything one way. The projects array in playwright.config.ts defines multiple named runs — Chromium, Firefox, WebKit, mobile viewports, or a setup project — each with its own settings, all triggered from one command. Projects are how you get true cross-browser coverage and dependency-ordered runs, where a setup project authenticates once and the rest depend on it. You can also scope a project to a device descriptor for mobile emulation.

Interview Strategy

Say projects are the built-in answer to 'run on three browsers' and to setup-then-test ordering via dependencies. Mention you can scope a project to a device descriptor for mobile emulation. Showing a setup project that the others depend on signals real config experience.

How to phrase it

Projects are how Playwright models more than one way of running the same suite from a single config and a single command. The classic use is cross-browser: I define a chromium project, a webkit project and a firefox project, each pulling in the right device descriptor, and one test run covers all three. But projects are also how I model ordering. I can declare a setup project that logs in once and saves the storage state, and then make my browser projects depend on it — Playwright runs the setup first and only then the tests that need to be authenticated. That dependency graph is much cleaner than scattering login into hooks. The same mechanism gives me mobile emulation: I scope a project to a device descriptor like Pixel five and get that viewport, user agent and touch behaviour. So whenever the requirement is three browsers, a mobile run, or a do-this-before-that ordering, projects are the built-in answer rather than something I hand-roll.

Key points to hit

  • A single config runs one way; the projects array defines many named runs from one command.
  • Projects deliver true cross-browser coverage — Chromium, Firefox, WebKit.
  • A setup project plus dependencies models do-this-before-that ordering cleanly.
  • Scope a project to a device descriptor for mobile emulation.
  • Reach for projects whenever you need multiple browsers, mobile, or ordered runs.
Code
import { defineConfig, devices } from "@playwright/test";

export default defineConfig({
  projects: [
    { name: "setup", testMatch: /auth\.setup\.ts/ },
    {
      name: "chromium",
      use: { ...devices["Desktop Chrome"] },
      dependencies: ["setup"],
    },
    { name: "webkit", use: { ...devices["Desktop Safari"] }, dependencies: ["setup"] },
  ],
});
11

Workers (parallelism) vs sharding — how do you scale a big suite?

Senior
Concept

Workers are parallel processes on one machine — Playwright splits tests across them automatically, and each worker is fully isolated. Sharding splits the whole suite across multiple machines or CI jobs, each running one slice with the --shard flag. The two compose: each shard runs its slice across several workers. Because each shard produces its own report, you merge the per-shard blob reports into one HTML report at the end.

Interview Strategy

Say workers scale you within a machine and sharding scales you across machines, and you combine both on CI. Then add the detail that wins the point: each shard emits its own report, so you merge the blob reports into a single HTML report at the end. Knowing they stack and that reports must be merged is the senior signal.

How to phrase it

Workers and sharding solve scale at two different levels, and on a real pipeline I use both. Workers are parallelism within one machine: Playwright spins up several worker processes and splits the tests across them, and because each worker is fully isolated, there is no cross-test bleed. That gets me good throughput on a single CI runner. Sharding is parallelism across machines: I pass the shard flag — say shard one of four — to four separate CI jobs, and each job runs a quarter of the suite. The important detail is that they stack. Each shard internally still runs its slice across several workers, so four shards times, say, four workers gives me sixteen-way parallelism. The piece people forget is reporting: each shard produces its own blob report, so at the end of the pipeline I run merge-reports to combine those blobs into one HTML report. So my answer is: workers within a machine, sharding across machines, they compose, and you must merge the per-shard reports.

Key points to hit

  • Workers are isolated parallel processes on one machine; Playwright splits tests automatically.
  • Sharding splits the whole suite across machines or CI jobs via --shard.
  • They compose — each shard runs its slice across several workers.
  • Each shard emits its own blob report.
  • Merge the blob reports into one HTML report at the end with merge-reports.
Code
# CI: 4 machines, each a shard, each running several workers.
npx playwright test --shard=1/4
npx playwright test --shard=2/4

# Then combine the per-shard blob reports into one HTML report.
npx playwright merge-reports --reporter=html ./blob-reports
12

Trace viewer vs video and screenshots — what should you turn on for debugging?

Mid
Concept

Screenshots and video show what appeared on screen. The trace viewer captures far more — a timeline of every action, before-and-after DOM snapshots you can inspect, the network calls, the console logs and the source line for each step — so you can step through a failure as if you were sitting there when it happened. It is the strongest debugging artefact Playwright offers, and the standard recommendation is to record it on first retry on CI so you pay the cost only when a test actually fails.

Interview Strategy

Recommend trace set to on-first-retry on CI, so you get a full trace exactly when a test fails without the cost on every green run. Position screenshots and video as a lightweight extra. Naming the on-first-retry policy shows you have actually triaged CI failures, not just read the docs.

How to phrase it

Screenshots and video answer what the screen looked like, but the trace answers why the test failed, so the trace is what I rely on. The trace viewer gives me a timeline of every action with a DOM snapshot before and after each step, the network calls, the console logs and the source line — I can scrub through the whole run as if I had been watching live. The configuration detail that matters is the policy. I do not record a full trace on every run, because that has a real cost; I set trace to on-first-retry. That way, a test that passes first time costs nothing, but the moment a test fails and Playwright retries it, I get a complete trace of the failing attempt waiting in the report. I usually pair that with screenshot only-on-failure and video retain-on-failure as cheap extras. So my answer is: trace is the primary tool, on-first-retry is the policy, and screenshots and video are the lightweight backup for a quick visual.

Key points to hit

  • Trace captures actions, DOM snapshots, network, console and source — a full timeline.
  • Screenshots and video only show what was on screen.
  • Set trace to on-first-retry on CI so you pay only when a test fails.
  • Pair with screenshot only-on-failure and video retain-on-failure as cheap extras.
  • Trace is the strongest debugging artefact Playwright offers.
Code
import { defineConfig } from "@playwright/test";

export default defineConfig({
  use: {
    trace: "on-first-retry",
    screenshot: "only-on-failure",
    video: "retain-on-failure",
  },
});
13

storageState auth vs logging in each test — how do you handle login?

Senior
Concept

Logging in inside every test is slow and brittle — you pay the full UI login cost on each spec, and an unrelated login flake fails a test that was really about something else. With storageState you authenticate once in a setup project, save the cookies and local storage to a JSON file, and every other test starts already logged in by loading that state. For multiple roles you save multiple state files and point each project at the right one.

Interview Strategy

Say you authenticate once in a setup project, save the storageState, then point projects at that file via use.storageState. Note it cuts run time sharply and removes login flakiness from unrelated tests. Mention handling multiple roles with multiple state files — that is the senior nuance.

How to phrase it

Logging in inside every test is the thing I move away from early, because it is slow and it spreads risk. Every spec pays the full UI login cost, and worse, if the login flow flakes, it fails a test that was really about the cart or the checkout — the failure points at the wrong place. The pattern I use instead is storage state. I have a setup project that logs in once through the UI, then calls context.storageState and writes the cookies and local storage to a JSON file. Every other project sets use.storageState to that file, so each test opens already authenticated and jumps straight to the behaviour it actually verifies. That cuts run time sharply and takes login flakiness out of unrelated tests entirely. The senior nuance is multiple roles: I save a separate state file per role — an admin file, a standard-user file — and point the relevant project or test at the right one, so I can cover role-based behaviour without re-logging in for every case.

Key points to hit

  • Logging in per test is slow and spreads login flakiness into unrelated tests.
  • Authenticate once in a setup project, then save context.storageState to a JSON file.
  • Point projects at the file via use.storageState so tests start logged in.
  • Cuts run time and removes login flakiness from the rest of the suite.
  • For multiple roles, save one state file per role and target the right one.
Code
// auth.setup.ts
import { test as setup } from "@playwright/test";

setup("authenticate", async ({ page }) => {
  await page.goto("/login");
  await page.getByLabel("Email").fill("sree@example.com");
  await page.getByRole("button", { name: "Sign in" }).click();
  await page.context().storageState({ path: "auth/user.json" });
});
// In config: use: { storageState: "auth/user.json" }
14

expect.poll vs page.waitForFunction — which retry tool when?

Senior
Concept

expect.poll repeatedly runs a function in the Node test process and retries the assertion until it passes — it is ideal for polling an API or a value you compute on the test side. page.waitForFunction runs a predicate inside the browser and resolves when it returns truthy — for conditions only the page's own JavaScript can see, such as a global the app sets. The deciding question is simply where the condition lives.

Interview Strategy

Frame the choice by where the condition lives: a value on your test or Node side uses expect.poll; a condition inside the browser's runtime uses waitForFunction. Add that expect.poll gives the readable retry-with-message style, so prefer it unless you genuinely need in-page state.

How to phrase it

I decide between the two by asking one question: where does the condition I am waiting on actually live? If it lives on my side — in the Node test process — I use expect.poll. The classic case is polling an API: I write a function that hits the endpoint and returns a field, and expect.poll re-runs it and retries the assertion until it is true or it times out, and I get a readable failure message out of it. If, instead, the condition only exists inside the browser — say the application sets a global flag, or some in-page state that I cannot see from Node — then I use page.waitForFunction, which runs my predicate inside the browser's own runtime and resolves the moment it returns truthy. So the rule is: test-side or API value, reach for expect.poll; in-page state that only the browser knows, reach for waitForFunction. Because expect.poll reads more clearly and gives a proper assertion message, I default to it and only drop to waitForFunction when I genuinely need something inside the page.

Key points to hit

  • expect.poll runs a function in the Node process and retries the assertion until it passes.
  • page.waitForFunction runs a predicate inside the browser and resolves when truthy.
  • Decide by where the condition lives — test/Node side versus in-page state.
  • expect.poll is ideal for polling an API or a computed test-side value.
  • Prefer expect.poll for its readable retry-with-message; use waitForFunction only for in-page state.
Code
// Node-side polling of an API value — readable, retry-with-message.
await expect.poll(async () => {
  const res = await request.get("/api/status");
  return (await res.json()).ready as boolean;
}).toBe(true);

// In-page state only the browser can see:
// await page.waitForFunction(() => (window as any).appReady === true);
15

toHaveScreenshot (visual) vs DOM assertions — when is visual testing worth it?

Senior
Concept

DOM assertions check structure and text — they are fast, precise, and they ignore pixels entirely. toHaveScreenshot compares a rendered image against a stored baseline, catching layout, CSS and rendering regressions that DOM checks simply cannot see. Visual tests are powerful but need stable rendering, a way to mask dynamic regions, and a deliberate baseline-update workflow, or they become a source of noise.

Interview Strategy

Say you use DOM assertions for logic and content, and reserve visual snapshots for what only the eye catches — layout shifts, theming, charts. Mention masking dynamic regions and updating baselines with --update-snapshots to control noise. That maintenance awareness separates the senior answer.

How to phrase it

I treat them as answering different questions, so I use both deliberately. DOM assertions are my default — they check structure and text, they are fast and precise, and they do not care about pixels, so they are stable. But there is a class of bug they are blind to: a layout that shifts, a broken theme, a chart that renders wrong, a CSS regression. The DOM can be perfectly correct while the page looks broken. That is where toHaveScreenshot earns its place — it compares the rendered image against a baseline and catches exactly the things only the eye would notice. The reason I am selective is maintenance. Visual tests are noisy if I am careless, so I mask the dynamic regions — a live clock, an avatar, anything that legitimately changes — and I treat baseline updates as a deliberate, reviewed step with update-snapshots, not something I run reflexively to make red go green. So: DOM assertions for logic and content, visual snapshots reserved for what only the eye catches, with masking and a controlled baseline workflow to keep the noise down.

Key points to hit

  • DOM assertions check structure and text — fast, precise, pixel-blind.
  • toHaveScreenshot catches layout, CSS, theming and rendering regressions the DOM cannot.
  • Reserve visual tests for what only the eye catches; keep DOM checks for logic and content.
  • Mask dynamic regions like clocks and avatars to control noise.
  • Treat baseline updates with --update-snapshots as a deliberate, reviewed step.
Code
await expect(page).toHaveScreenshot("dashboard.png", {
  mask: [page.getByTestId("live-clock")],
  maxDiffPixelRatio: 0.01,
});
16

Typed Page Object Model vs an untyped POM — does TS change POM?

Mid
Concept

The pattern is identical — encapsulate a page's locators and actions in a class. TypeScript adds a typed Page constructor parameter, typed method signatures and readonly Locator fields, so callers get autocomplete and the compiler rejects a wrong argument. An untyped POM relies entirely on discipline and breaks silently when a method signature changes. The strong convention is to keep assertions out of the page object and leave them in the test.

Interview Strategy

Say a typed POM makes refactoring safe: rename a method and every test that calls it fails to compile, not at run time. Add the convention to keep assertions out of the page object where possible. Showing typed Locator fields and a typed Page parameter is the concrete detail interviewers look for.

How to phrase it

The Page Object Model itself does not change — I am still wrapping a page's locators and actions in a class so tests speak in business terms, not selectors. What TypeScript changes is the safety around it. I type the constructor's page parameter as Page, I declare the locator fields as readonly Locator, and I give every method a real signature with typed arguments and a typed return. The immediate benefit is autocomplete: in a test, the editor knows exactly what the LoginPage offers and what each method expects. The bigger benefit is refactoring. If I rename login or change its parameters, every test still calling the old shape fails to compile straight away, so the change cannot half-ship the way it can in an untyped POM that only breaks at run time. One convention I hold to regardless of language: I keep assertions out of the page object and leave them in the test, so the page object stays about interaction and the test stays about expectations. That separation, plus the typed fields and signatures, is what makes a typed POM genuinely safer to evolve.

Key points to hit

  • Same pattern — a class encapsulating a page's locators and actions.
  • TypeScript adds a typed Page constructor param, readonly Locator fields, typed method signatures.
  • Callers get autocomplete on the page object's methods and arguments.
  • Rename a method and every stale caller fails to compile, not at run time.
  • Keep assertions in the test, not in the page object.
Code
import { type Page, type Locator } from "@playwright/test";

export class LoginPage {
  private readonly email: Locator;
  private readonly signIn: Locator;
  constructor(private readonly page: Page) {
    this.email = page.getByLabel("Email");
    this.signIn = page.getByRole("button", { name: "Sign in" });
  }
  async login(user: string, pass: string): Promise<void> {
    await this.email.fill(user);
    await this.signIn.click();
  }
}
17

APIRequestContext vs driving the UI — when do you test the API directly?

Mid
Concept

The request fixture, an APIRequestContext, makes HTTP calls directly while sharing cookies and auth with the browser context but skipping the UI entirely. Driving the UI exercises the real user path but is slower and noisier. They complement each other: use the API to seed and tear down data fast and to assert backend contracts, and reserve the UI for the journeys that actually matter to a user. In TypeScript you can type the response body so a contract change breaks the test loudly.

Interview Strategy

Say you use the API to seed data and tear it down fast, and to assert backend contracts, while keeping UI tests for true end-to-end flows. Make the point that mixing levels keeps the suite fast and stable. Using request to set up a UI test is a strong, practical example.

How to phrase it

I think of it as choosing the cheapest level that still proves what I care about. The request fixture lets me make HTTP calls directly, and crucially it shares cookies and auth with the browser context, so it is authenticated just like my UI test. I use it for two things. First, setup and teardown: instead of clicking through five screens to create an order so I can test cancelling it, I create that order with one API call, then let the UI test focus only on the cancel flow — that is faster and far less flaky. Second, contract checks: I can assert the backend returns the right shape and status directly, without any UI in the way. The UI tests I keep are the genuine end-to-end journeys that a user actually performs, because those are the ones worth the cost. Mixing the levels like this keeps the whole suite fast and stable. And because this is TypeScript, I type the response body, so if the API contract changes underneath me, the test fails to compile rather than passing on stale assumptions.

Key points to hit

  • The request fixture makes direct HTTP calls, sharing cookies and auth, skipping the UI.
  • Use the API to seed and tear down data fast for UI tests.
  • Use the API to assert backend contracts directly, without UI noise.
  • Reserve UI tests for the genuine end-to-end journeys users perform.
  • Type the response body so a contract change breaks the test loudly.
Code
import { test, expect } from "@playwright/test";

type Order = { id: string; item: string };

test("seed via API, verify in UI", async ({ page, request }) => {
  const res = await request.post("/api/orders", { data: { item: "kit", qty: 1 } });
  const order = (await res.json()) as Order;
  await page.goto(`/orders/${order.id}`);
  await expect(page.getByText("kit")).toBeVisible();
});
18

Typing process.env vs reading raw env vars — how do you handle config in TS?

Mid
Concept

Every value on process.env is typed string | undefined, so reading it raw means each usage carries the risk that an undefined slips through and surfaces as a confusing failure deep in a test. In TypeScript the clean approach is to validate and narrow the environment once — at startup or in a small typed config module — fail fast if a required variable is missing, and let the rest of the suite consume typed, guaranteed-present values. dotenv covers local, CI secrets cover the pipeline.

Interview Strategy

Say you never scatter process.env across tests; you read and validate it once, fail fast if a required variable is missing, and export typed config. Mention dotenv for local and CI secrets for pipelines. Failing fast on a missing BASE_URL is the detail that shows you have been burned before.

How to phrase it

The trap with process.env in TypeScript is that every value is typed string-or-undefined, so if I read it raw all over the suite, each read is a small landmine — an undefined can slip through and blow up somewhere far from the cause, with a useless error. So I never scatter process.env across the tests. I read and validate the environment exactly once, in a small typed config module, right at startup. If a required variable like BASE_URL is missing, I throw immediately with a clear message — fail fast, before any test runs, rather than discover it three steps into a flow. After that single validation, I export a typed config object, and the rest of the suite consumes those values knowing they are present and correctly typed; the undefined is gone by construction. Operationally, I load a dot-env file for local development and rely on the CI system's secrets for the pipeline, so credentials never live in the repo. That fail-fast-on-a-missing-variable habit usually signals to an interviewer that I have actually been burned by this before.

Key points to hit

  • process.env values are string | undefined — raw reads risk an undefined slipping through.
  • Validate and narrow the environment once, in a small typed config module.
  • Fail fast with a clear error if a required variable is missing.
  • Export typed config so the rest of the suite consumes guaranteed-present values.
  • dotenv for local, CI secrets for the pipeline — never credentials in the repo.
Code
function required(name: string): string {
  const value = process.env[name];
  if (!value) throw new Error(`${name} is not set`);
  return value;
}

export const config = {
  baseURL: required("BASE_URL"),
  apiToken: required("API_TOKEN"),
} as const;
19

tsconfig paths for tests vs relative imports — why bother?

Mid
Concept

Relative imports like ../../../pages/login get fragile and ugly as the suite grows and files move — one moved file and a chain of dots breaks. tsconfig path aliases let you define names such as @pages/* that map to a fixed root, so imports stay short and survive file moves. Playwright reads tsconfig directly, so the aliases just work with no extra bundler or runtime tooling. The discipline is to keep the alias set small and meaningful.

Interview Strategy

Say path aliases make imports readable and refactor-proof, and that Playwright picks them up from tsconfig with no extra tooling. Note you keep the alias set small and meaningful — @pages, @fixtures, @utils. It is a small thing that signals you have maintained a real codebase.

How to phrase it

This looks like a cosmetic thing but it pays off on any suite that lives long enough to be reorganised. Relative imports with three or four levels of dot-dot-slash are both ugly and fragile — the moment I move a spec into a subfolder, every one of those relative paths is wrong and I am fixing imports instead of doing the work. So I set up path aliases in tsconfig: I map something like at-pages-slash-star to the pages root, and then I import from at-pages-slash-login regardless of where the test file sits. The import reads cleanly and it does not break when files move, because the alias points at a fixed root, not a relative position. The nice part with Playwright is that it reads tsconfig directly, so these aliases just work — there is no extra bundler config or runtime resolver to wire up. The discipline I keep is to stay minimal and meaningful: a small set like at-pages, at-fixtures and at-utils, not an alias for every folder, so the imports stay self-explanatory.

Key points to hit

  • Deep relative imports are ugly and break the moment a file moves.
  • tsconfig path aliases map names like @pages/* to a fixed root.
  • Imports stay short and survive file moves and reorganisation.
  • Playwright reads tsconfig directly — aliases work with no extra tooling.
  • Keep the alias set small and meaningful: @pages, @fixtures, @utils.
Code
// tsconfig.json
// "compilerOptions": {
//   "baseUrl": ".",
//   "paths": { "@pages/*": ["./pages/*"] }
// }

import { LoginPage } from "@pages/login";
// instead of: import { LoginPage } from "../../../pages/login";
20

Retries on CI vs fixing flakiness — are retries a real solution?

Senior
Concept

Retries re-run a failed test and let it pass if a later attempt succeeds, which keeps the pipeline green through genuine infrastructure blips. But they are a safety net, not a cure — a test that needs a retry is signalling a real timing or isolation bug, and a retry simply hides it. The mature posture is a small retry count on CI plus active investigation of anything that lands in the report's flaky bucket, using its trace.

Interview Strategy

Say you enable a small retry count on CI — often one or two — to absorb genuine infrastructure blips, but you treat any flaky test as a bug to investigate via its trace, not to paper over. Mention watching the flaky bucket in the report. That distinction — net versus cure — is exactly the senior signal.

How to phrase it

My honest position is that retries are a safety net, not a cure, and I am careful to say that out loud because it is exactly what separates a thoughtful answer from a lazy one. I do enable a small retry count on CI, usually one or two, set only when the CI environment variable is present so local runs stay at zero. That absorbs genuine infrastructure noise — a momentary network blip, a slow cold start — without failing the whole pipeline over something that is not a real defect. What I do not do is treat retries as the fix. When a test passes only on a retry, Playwright marks it flaky in the report, and I watch that flaky bucket deliberately. Each flaky test is a bug to me — a missing wait, a shared-state leak, a race — and I open its trace, find the real cause and fix it, rather than letting the retry quietly mask it forever. So the framing I land on is: retries keep the pipeline honest through blips, but a flaky test is still a bug I owe an investigation.

Key points to hit

  • Retries re-run a failed test and pass it if a later attempt succeeds.
  • Enable a small count on CI only — often one or two — to absorb infrastructure blips.
  • Retries hide intermittent bugs; they are a net, not a cure.
  • A flaky test signals a real timing or isolation bug to investigate.
  • Watch the report's flaky bucket and use the trace to find the root cause.
Code
import { defineConfig } from "@playwright/test";

export default defineConfig({
  retries: process.env.CI ? 2 : 0,
  reporter: [["html"], ["list"]],
});
21

Playwright Test vs Vitest or Jest — which runner for what?

Mid
Concept

Vitest and Jest are unit and integration runners built for fast, in-process JavaScript and component tests — no real browser, just the module under test. Playwright Test is an end-to-end runner with browser fixtures, parallel workers, tracing and cross-browser projects built in. They sit at different levels of the testing pyramid, so the right answer is rarely one over the other.

Interview Strategy

Say you do not choose one over the other — you use Vitest or Jest for units and component logic and Playwright Test for real browser end-to-end. Note Playwright can also do component testing, but most teams keep units in Vitest. Placing each on the pyramid shows you understand test strategy, not just tools.

How to phrase it

I would push back gently on the framing, because it is not really an either-or — they live at different levels of the testing pyramid. Vitest and Jest are unit and integration runners: they run fast and in-process, with no real browser, so I use them for pure logic, utility functions and component behaviour where spinning up a browser would be wasteful. Playwright Test sits at the top of the pyramid as the end-to-end runner — it brings real browser fixtures, parallel workers, tracing and cross-browser projects, so it is where I verify that the whole application actually works for a user across Chromium, WebKit and Firefox. There is some overlap — Playwright does offer component testing — but in practice most teams keep their unit and component layer in Vitest and reserve Playwright for true end-to-end, and I think that is the right division. So my answer is to use both deliberately: many fast Vitest or Jest tests underneath, a focused set of Playwright end-to-end tests on top, each doing the job it is built for.

Key points to hit

  • Vitest and Jest are in-process unit and integration runners — no real browser.
  • Playwright Test is an end-to-end runner with browser fixtures, workers, tracing and projects.
  • They sit at different levels of the testing pyramid, not in competition.
  • Playwright can do component testing, but most teams keep units in Vitest.
  • Use both — many fast unit tests underneath, focused end-to-end tests on top.
Code
// Playwright Test: browser fixture, real end-to-end.
import { test, expect } from "@playwright/test";

test("checkout", async ({ page }) => {
  await page.goto("/checkout");
  await expect(page.getByRole("heading")).toHaveText("Pay");
});
22

codegen --target typescript vs hand-writing tests — is generated code enough?

Junior
Concept

playwright codegen records your clicks and emits ready-to-run TypeScript, and it picks sensible role-based locators as it goes, which makes it a genuinely useful way to discover good locators. But it produces linear, unstructured scripts — no page objects, no assertion strategy, no reuse. It is a starting point, not the finished test.

Interview Strategy

Say codegen is excellent for bootstrapping a flow and learning locators, but you always refactor the output into typed page objects and add real assertions before committing. Framing it as a starting point, not a finished test, is the answer interviewers want.

How to phrase it

Codegen is a tool I genuinely use, but I am clear about what it is for. I run playwright codegen with the TypeScript target, click through a flow, and it records ready-to-run TypeScript for me. The really useful part is that it tends to pick good role-based locators automatically, so it is a fast way both to scaffold a new flow and to learn what a robust locator for a given control looks like. What it produces, though, is a flat linear script — one long sequence of actions with no page objects, no shared fixtures, and barely any assertions. That is fine as a draft and useless as a committed test. So my workflow is: generate to discover the flow and the locators, then refactor. I lift the locators and actions into a typed page object, wire in the fixtures, and add the real assertions that actually express what the test is verifying. Only then does it go into the suite. Framed simply: codegen gives me a starting point, and the engineering is in turning that starting point into a maintainable test.

Key points to hit

  • codegen records clicks and emits ready-to-run TypeScript.
  • It picks sensible role-based locators, so it is great for discovering good locators.
  • Output is linear and unstructured — no page objects, fixtures or assertion strategy.
  • Always refactor into typed page objects and add real assertions before committing.
  • Treat codegen as a starting point, not the finished test.
Code
# Record a flow and emit TypeScript:
npx playwright codegen --target=javascript https://example.com
# Then refactor the generated script into a typed POM with real assertions.
23

Headed vs headless — when do you run each?

Junior
Concept

Headless runs the browser with no visible window — it is faster and the default on CI. Headed shows the real window, which you use locally to watch a test and understand a failure. The rendering engine is the same in both, so behaviour should not differ; if a test only fails in one mode, that points at timing or viewport, not the mode itself. UI mode and --debug give you stepping on top of headed.

Interview Strategy

Say you run headless everywhere by default and switch to headed locally for debugging, often with --debug or UI mode for stepping. Note that if a test only fails headless, suspect timing or viewport, not the mode. That instinct is the experienced answer.

How to phrase it

My default everywhere is headless — no visible window, which is faster and exactly what CI wants since there is no display to render to anyway. I switch to headed when I am debugging locally and want to actually watch the browser do what the test tells it, because seeing the flow is often the quickest way to understand a failure. On top of headed I lean on the richer debugging tools: the debug flag pauses and lets me step through actions, and UI mode gives me a time-travel view where I can scrub the run and re-run individual steps. The instinct I would highlight is what to think when a test only fails in one mode. If something passes headed but fails headless, I do not blame the mode — the rendering engine is the same in both — I suspect timing, because headless is faster and can expose a race, or viewport, because the default window size may differ from what I had open locally. So: headless by default, headed and UI mode for debugging, and a mode-specific failure is a clue about timing or viewport, not about headless itself.

Key points to hit

  • Headless has no visible window — faster, and the default on CI.
  • Headed shows the real window for watching a test locally.
  • Use --debug to step through, and UI mode for time-travel and re-running steps.
  • Same rendering engine in both, so behaviour should not differ.
  • A headless-only failure usually points at timing or viewport, not the mode.
Code
# CI default: headless.
npx playwright test

# Local debugging: watch it run, or step through.
npx playwright test --headed
npx playwright test --ui
24

Strict mode locators vs loose selectors — what does strict mode protect you from?

Mid
Concept

Locators are strict by default: if a locator resolves to more than one element, the action throws instead of silently using the first match. Loose CSS-style selection in older tools would just act on element zero, quietly hiding the fact that your selector was ambiguous and that the page may have changed underneath you. When you genuinely want one of many, you narrow it explicitly — by name, with .first(), or with .nth().

Interview Strategy

Say strict mode turns an ambiguous selector into a loud, immediate error, which forces precise locators and catches duplicate matches early. When you genuinely want one of many, narrow with a name, .first() or .nth(). Explaining why the error is a feature, not a nuisance, is the mature take.

How to phrase it

Strict mode is one of those things that feels annoying for a day and then you are grateful for it. Playwright locators are strict by default: if a locator matches more than one element, the action does not quietly pick the first one — it throws a strict-mode violation and tells me exactly how many it found. Compare that to the old loose-selector behaviour in some tools, where a CSS selector that matched ten elements would just act on element zero and say nothing. That silence is the real danger, because the day a second matching element appears on the page, your test starts clicking the wrong thing and you have no warning. Strict mode turns that hidden ambiguity into a loud, immediate error at author time, which pushes me to write precise locators in the first place. And when I genuinely do want one of several — the first row, the third tab — I say so explicitly with a name filter, or first, or nth. So I describe the error as a feature: it is the framework refusing to guess on my behalf.

Key points to hit

  • Locators are strict by default — more than one match makes the action throw.
  • Loose selectors silently act on element zero, hiding ambiguity.
  • The danger is silence: a new matching element breaks the test with no warning.
  • Strict mode forces precise locators and catches duplicates at author time.
  • When you want one of many, narrow explicitly with a name, .first() or .nth().
Code
// Throws if two buttons match — strict mode refusing to guess.
await page.getByRole("button", { name: "Delete" }).click();

// Intentional pick from many:
await page.getByRole("listitem").first().click();
25

page.route mocking vs hitting the real backend — when do you mock?

Mid
Concept

Hitting the real backend tests the whole stack but is slower and flakes when the API is down, rate-limited or returning changing data. page.route intercepts a request and lets you fulfil it with a fixed response, so you can test the UI against an exact payload — including the error states a real backend rarely produces on demand, like a 500 or an empty list. In TypeScript you type the mock payload to your API type so a contract change breaks the test loudly.

Interview Strategy

Frame it as 'what am I testing right now'. Say you keep a few true end-to-end tests against the real backend for confidence, and mock with page.route for everything else — especially edge cases like a 500, an empty list or a slow response. Mention typing the mock payload to your API type.

How to phrase it

I decide by asking what I am actually testing in this particular test. If the point is genuine end-to-end confidence — does the real system work together — I hit the real backend, and I keep a small number of those tests precisely because they are the high-value, full-stack ones. But for everything else, especially edge cases, I mock with page.route. I intercept the request and fulfil it with an exact payload, and that gives me two things the real backend cannot reliably give me. First, determinism: the response is fixed, so the test does not flake because the API is slow, rate-limited or returning changing data. Second, control over states that are hard to trigger for real — an empty cart, a five-hundred error, a malformed response — which is exactly where UI bugs hide. Because this is TypeScript, I type the mock payload to my API type, so it is not just a loose blob: if the real contract changes, my typed mock stops compiling and the test fails loudly instead of passing on a stale shape. So: a few real-backend tests for confidence, page.route mocks for determinism and edge cases, and typed payloads to keep the mocks honest.

Key points to hit

  • Real backend gives full-stack confidence but is slower and flakes on outages or changing data.
  • page.route intercepts a request and fulfils it with an exact, fixed payload.
  • Mocking makes edge cases easy — a 500, an empty list, a malformed response.
  • Keep a few real-backend tests for confidence; mock the rest.
  • Type the mock payload to your API type so a contract change breaks the test loudly.
Code
type CartResponse = { items: string[]; total: number };

await page.route("**/api/cart", (route) =>
  route.fulfill({ json: { items: [], total: 0 } satisfies CartResponse })
);
await page.goto("/cart");
await expect(page.getByText("Your cart is empty")).toBeVisible();

Want more free SDET prep?

Get my free interview resources and new Q&A drops straight to your inbox.

300+ questions in the full kit

SDET Playwright TypeScript Foundation Kit 2026

A glimpse of the full Foundation Kit — TypeScript for SDETs, architecture, locators, fixtures, the Expect API, network, CI/CD and AI-assisted testing, all interview-ready.

Other stacks