DEV Community

Cover image for 🎭 Playwright + TypeScript β€” 100 In-Depth Interview Questions & Answers
Himanshu Agarwal
Himanshu Agarwal

Posted on

🎭 Playwright + TypeScript β€” 100 In-Depth Interview Questions & Answers

For SDET / QA Automation Engineers with 5–15 Years of Experience (L1 & L2 Rounds)

How to use this guide: Questions are ordered from foundational (L1 screening) to architectural/scenario-based (L2 panel + hiring-manager rounds). Each answer includes the why, not just the what β€” plus runnable TypeScript snippets you can paste into a real project.


🎁 MEGA SALE β€” Flat 90% OFF on ALL 100+ eBooks & Bundles

Coupon code: JUPITER90

πŸ“š Explore the complete HimanshuAI Digital Playbook Store:
πŸ‘‰ https://himanshuai.gumroad.com/

If you're planning to master Playwright, TypeScript, AI Testing, Salesforce, API Testing, Java, Python, Cypress, Selenium, or System Design β€” this is the best opportunity to build your complete technical library at a fraction of the regular price.


πŸ“‘ Table of Contents

# Section Questions
1 Architecture & Fundamentals Q1 – Q12
2 Locators, Selectors & Web-First Assertions Q13 – Q26
3 Auto-Waiting, Actionability & Flakiness Q27 – Q36
4 Fixtures, Hooks & Test Isolation Q37 – Q48
5 Configuration, Projects & Parallelism Q49 – Q58
6 Network Interception, Mocking & API Testing Q59 – Q70
7 Authentication, Storage State & Sessions Q71 – Q78
8 TypeScript Deep Dive for Playwright Q79 – Q88
9 Framework Design, POM & Scalability Q89 – Q94
10 CI/CD, Reporting, Debugging & Leadership Scenarios Q95 – Q100

SECTION 1 β€” Architecture & Fundamentals (Q1–Q12)


Q1. Explain Playwright's architecture. How does it differ fundamentally from Selenium WebDriver?

Answer.

Playwright uses a single WebSocket connection carrying the Chrome DevTools Protocol (CDP) β€” or browser-specific equivalents for Firefox (a patched Juggler protocol) and WebKit β€” between the Node.js test process and the browser.

Selenium's model:

Test Code β†’ JSON Wire / W3C WebDriver (HTTP) β†’ Browser Driver (chromedriver.exe) β†’ Browser
Enter fullscreen mode Exit fullscreen mode

Every command is a separate blocking HTTP request/response. High latency, no event stream back from the browser.

Playwright's model:

Test Code β†’ Playwright Server (Node) β†’ single persistent WebSocket β†’ Browser
Enter fullscreen mode Exit fullscreen mode

Key architectural consequences:

Aspect Selenium Playwright
Transport HTTP, request/response WebSocket, bidirectional
Driver binary Yes (version-matched) No β€” bundled browser builds
Event awareness Polling only Real-time browser events (console, request, dialog, download)
Waiting Explicit/implicit waits you write Auto-waiting built into every action
Isolation New browser process per test (slow) BrowserContext β€” incognito-like, ~milliseconds
Network control Requires proxy (BrowserMob) Native page.route()

The bidirectional channel is the differentiator. Because the browser pushes events to Playwright, Playwright knows when the DOM mutated, when a request fired, when navigation committed. Selenium has to ask repeatedly.

Interview tip: Interviewers want you to say "single WebSocket + browser events enable auto-waiting." That one sentence signals you understand the cause, not just the feature list.


Q2. What is a BrowserContext and why is it the single most important performance concept in Playwright?

Answer.

A BrowserContext is an isolated browser session inside a single browser process. Think of it as an incognito profile: its own cookies, localStorage, sessionStorage, IndexedDB, cache, permissions, and geolocation β€” but sharing the already-launched browser binary.

const browser = await chromium.launch();          // ~500ms β€” expensive, done ONCE
const context = await browser.newContext();       // ~2ms  β€” cheap, done PER TEST
const page = await context.newPage();             // ~5ms
Enter fullscreen mode Exit fullscreen mode

Why this matters:

  1. Speed. Launching a browser is 300–800 ms. Creating a context is 1–3 ms. Playwright Test reuses the browser across the whole worker and gives each test a fresh context β€” so you get Selenium-grade isolation at near-zero cost.
  2. True isolation. No cookie bleed, no leftover localStorage, no "test 7 fails only when test 3 runs first."
  3. Multi-user scenarios. Two contexts = two logged-in users in one browser.
test('chat delivers message between two users', async ({ browser }) => {
  const alice = await browser.newContext({ storageState: 'auth/alice.json' });
  const bob   = await browser.newContext({ storageState: 'auth/bob.json' });

  const alicePage = await alice.newPage();
  const bobPage   = await bob.newPage();

  await alicePage.goto('/chat/room-42');
  await bobPage.goto('/chat/room-42');

  await alicePage.getByPlaceholder('Message').fill('ping');
  await alicePage.keyboard.press('Enter');

  await expect(bobPage.getByTestId('message-list')).toContainText('ping');

  await alice.close();
  await bob.close();
});
Enter fullscreen mode Exit fullscreen mode

L2 follow-up you should pre-empt: "What's the hierarchy?" β†’ Browser β†’ BrowserContext β†’ Page β†’ Frame β†’ Locator. Contexts do NOT share memory; pages within a context DO share cookies and storage.


Q3. What exactly is a Locator and how does it differ from an ElementHandle?

Answer.

This is the most commonly failed L1 question by Selenium migrants.

A Locator is a lazy, re-resolvable query β€” a recipe for finding an element, not the element itself. Nothing is queried at creation time.

An ElementHandle is a live pointer to a specific DOM node captured at a moment in time.

// Locator β€” no DOM query happens on this line
const saveBtn = page.getByRole('button', { name: 'Save' });

await saveBtn.click();   // resolves NOW
await page.reload();
await saveBtn.click();   // resolves AGAIN, fresh β€” still works βœ…

// ElementHandle β€” DOM query happens immediately
const handle = await page.$('button#save');
await page.reload();
await handle.click();    // ❌ Error: Element is not attached to the DOM
Enter fullscreen mode Exit fullscreen mode
Locator ElementHandle
Evaluation Lazy, on every action Eager, once
Stale element risk None High
Auto-waiting Yes No
Strictness check Yes No
Recommended βœ… Always ⚠️ Rare escape hatch

When is ElementHandle still legitimate? Passing a real DOM node into page.evaluate() for something Playwright's API can't express:

const handle = await page.locator('canvas').elementHandle();
const pixelData = await page.evaluate(
  (canvas: HTMLCanvasElement) =>
    canvas.getContext('2d')!.getImageData(0, 0, 10, 10).data.length,
  handle
);
Enter fullscreen mode Exit fullscreen mode

Playwright's docs explicitly discourage ElementHandle β€” in ~5 years of modern Playwright code you should be able to say "I've used it twice."


Q4. What is "strict mode" in Playwright and why was it introduced?

Answer.

Strict mode means: if a locator resolves to more than one element, the action throws instead of silently acting on the first match.

// Page has 3 "Delete" buttons
await page.getByRole('button', { name: 'Delete' }).click();
Enter fullscreen mode Exit fullscreen mode
Error: strict mode violation: getByRole('button', { name: 'Delete' })
resolved to 3 elements:
  1) <button>Delete</button> aka getByRole('row', {name:'Invoice 1'}).getByRole('button')
  2) ...
Enter fullscreen mode Exit fullscreen mode

Why it exists: Selenium's findElement() returns the first match. That silently passes today and silently clicks the wrong row six months later when the DOM order changes. Strict mode converts a silent wrong-behaviour bug into a loud, immediate failure.

How to resolve a violation properly:

// ❌ Lazy fix β€” hides the real problem
await page.getByRole('button', { name: 'Delete' }).first().click();

// βœ… Correct fix β€” scope to the intended parent
await page.getByRole('row', { name: 'Invoice #1042' })
          .getByRole('button', { name: 'Delete' })
          .click();

// βœ… Also correct β€” narrow by ancestor filter
await page.getByRole('listitem')
          .filter({ hasText: 'Invoice #1042' })
          .getByRole('button', { name: 'Delete' })
          .click();
Enter fullscreen mode Exit fullscreen mode

Which methods are NOT strict? .all(), .count(), .first(), .last(), .nth(), and expect(locator).toHaveCount() β€” these are explicitly multi-element operations.


Q5. Explain Playwright's auto-waiting mechanism in precise terms. What are the actionability checks?

Answer.

Before every action, Playwright polls a set of actionability checks until all pass or the timeout expires. This replaces WebDriverWait + ExpectedConditions entirely.

The five checks:

Check Meaning
Attached Element is present in the DOM
Visible Non-empty bounding box AND not visibility: hidden
Stable Bounding box unchanged for 2 consecutive animation frames
Receives Events Hit-target test: the point being clicked actually resolves to this element (catches overlays, modals, sticky headers, cookie banners)
Enabled Not disabled (for form controls)

Which checks apply to which action:

Action Attached Visible Stable Receives Events Enabled Editable
click() βœ… βœ… βœ… βœ… βœ… β€”
fill() βœ… βœ… β€” β€” βœ… βœ…
check() βœ… βœ… βœ… βœ… βœ… β€”
hover() βœ… βœ… βœ… βœ… β€” β€”
textContent() βœ… β€” β€” β€” β€” β€”
focus() βœ… β€” β€” β€” β€” β€”

The "Receives Events" check is the killer feature. It's why Playwright doesn't click a cookie banner sitting on top of your button β€” it retries until the banner is gone, then clicks the real target.

What auto-waiting does NOT cover: custom application state. If a button is enabled but your React state hasn't hydrated, Playwright will happily click. That's what web-first assertions are for.


Q6. What are "web-first assertions" and why should you never use expect(await locator.textContent())?

Answer.

Web-first assertions retry until the condition is met or the timeout expires. Non-retrying assertions snapshot a value once and immediately compare.

// ❌ NON-RETRYING β€” reads the DOM once, at whatever moment this line runs
expect(await page.locator('.status').textContent()).toBe('Complete');
// Flaky: if the status updates 50ms later, this has already failed.

// βœ… WEB-FIRST β€” polls every ~100ms until 'Complete' appears or timeout
await expect(page.locator('.status')).toHaveText('Complete');
Enter fullscreen mode Exit fullscreen mode

The tell: web-first assertions take a Locator, not a value, and are always awaited on the expect itself:

await expect(locator).toBeVisible();     ← await is OUTSIDE, on expect
expect(await locator.isVisible()).toBe(true);  ← await INSIDE = non-retrying = flaky
Enter fullscreen mode Exit fullscreen mode

Full list of retrying assertions (memorise the common ones):

await expect(loc).toBeVisible();
await expect(loc).toBeHidden();
await expect(loc).toBeEnabled();
await expect(loc).toBeDisabled();
await expect(loc).toBeChecked();
await expect(loc).toBeEditable();
await expect(loc).toBeEmpty();
await expect(loc).toBeFocused();
await expect(loc).toBeInViewport();
await expect(loc).toBeAttached();
await expect(loc).toHaveText('exact');
await expect(loc).toContainText('partial');
await expect(loc).toHaveValue('input value');
await expect(loc).toHaveValues(['a', 'b']);        // multi-select
await expect(loc).toHaveAttribute('href', '/home');
await expect(loc).toHaveClass(/active/);
await expect(loc).toHaveCount(5);
await expect(loc).toHaveCSS('color', 'rgb(255, 0, 0)');
await expect(loc).toHaveId('submit');
await expect(loc).toHaveScreenshot('baseline.png');
await expect(page).toHaveTitle(/Dashboard/);
await expect(page).toHaveURL('/dashboard');
await expect(apiResponse).toBeOK();
Enter fullscreen mode Exit fullscreen mode

Interview gold: "Web-first assertions are the single largest source of flakiness elimination when migrating a Selenium suite, because they replace Thread.sleep() and hand-written explicit waits with a polling contract."


Q6b. How do you configure assertion timeouts independently of action timeouts?

Answer.

Three distinct timeout layers β€” knowing the difference is an L2 discriminator:

// playwright.config.ts
export default defineConfig({
  timeout: 60_000,               // 1. Whole TEST timeout
  expect: {
    timeout: 10_000,             // 2. Web-first ASSERTION timeout
  },
  use: {
    actionTimeout: 15_000,       // 3. Individual ACTION timeout (click/fill)
    navigationTimeout: 30_000,   // 4. goto / waitForURL timeout
  },
});
Enter fullscreen mode Exit fullscreen mode

Per-call overrides:

await expect(slowWidget).toBeVisible({ timeout: 30_000 });
await page.getByRole('button').click({ timeout: 5_000 });
test.setTimeout(120_000);                    // inside a test
test.slow();                                 // triples the timeout
Enter fullscreen mode Exit fullscreen mode

Precedence: per-call > use config > global default (30s action, 5s expect, 30s test).


Q7. Explain the Playwright test runner's worker model. What is a "worker" and when is it restarted?

Answer.

Playwright Test spawns N independent Node.js processes ("workers"). Each worker:

  • Launches its own browser instance
  • Runs test files (by default) one at a time to completion
  • Is restarted after any test failure to guarantee a clean state
export default defineConfig({
  workers: process.env.CI ? 4 : '50%',   // '50%' = half the CPU cores
  fullyParallel: true,                    // parallelise at TEST level, not just file level
});
Enter fullscreen mode Exit fullscreen mode

Default behaviour: files run in parallel, tests within a file run sequentially.
With fullyParallel: true: every individual test can go to any worker.

Key facts interviewers probe:

  1. process.env.TEST_WORKER_INDEX β€” 0-based index of the current worker. Use it to pick a dedicated test data account and avoid data collisions:
   const account = TEST_ACCOUNTS[Number(process.env.TEST_WORKER_INDEX)];
Enter fullscreen mode Exit fullscreen mode
  1. process.env.TEST_PARALLEL_INDEX β€” the parallel slot; stays stable even when a worker restarts.
  2. Workers do not share memory. Anything in a module-level variable is per-worker, not global. This is why you cannot "share a login token across all tests" via a plain variable.
  3. workers: 1 forces full serialisation β€” the nuclear option for stateful legacy suites.

Serialising a subset without killing global parallelism:

test.describe.configure({ mode: 'serial' });   // these tests share a worker + stop on first failure
test.describe.configure({ mode: 'parallel' }); // opt this file INTO parallel
Enter fullscreen mode Exit fullscreen mode

Q8. What happens under the hood when you call page.goto()? Explain waitUntil options.

Answer.

page.goto(url, options) navigates and waits for a lifecycle event, then returns the main resource Response (or null for same-document navigations like hash changes).

await page.goto('/dashboard', { waitUntil: 'domcontentloaded' });
Enter fullscreen mode Exit fullscreen mode
waitUntil Fires when Use case
'commit' Network response received, document starts loading Fastest; you'll assert on something specific next
'domcontentloaded' DOMContentLoaded event Best default for SPAs
'load' load event (all images/CSS/subframes) Playwright's default
'networkidle' No network connections for 500 ms ⚠️ Discouraged

Why networkidle is discouraged: modern apps use analytics beacons, WebSockets, polling, and long-lived SSE connections. Network never goes idle β†’ 30 s timeout β†’ flaky. Playwright's own docs say "don't use it for testing, rely on web assertions instead."

The correct modern pattern:

// ❌
await page.goto('/dashboard', { waitUntil: 'networkidle' });

// βœ… navigate fast, then assert on the thing you actually care about
await page.goto('/dashboard');
await expect(page.getByRole('heading', { name: 'Revenue' })).toBeVisible();
Enter fullscreen mode Exit fullscreen mode

Gotcha: goto throws on network errors and on SSL errors, but not on HTTP 4xx/5xx β€” a 404 page is a successful navigation. Check explicitly:

const response = await page.goto('/orders/999');
expect(response?.status()).toBe(200);
Enter fullscreen mode Exit fullscreen mode

Q9. How does Playwright handle iframes and Shadow DOM?

Answer.

Shadow DOM β€” automatic. Playwright's CSS and text engines pierce open shadow roots by default. No getShadowRoot(), no JS executor.

// Works even if <my-widget> renders its button inside an open shadow root
await page.getByRole('button', { name: 'Submit' }).click();
await page.locator('my-widget >> button.confirm').click();
Enter fullscreen mode Exit fullscreen mode

Limitation: closed shadow roots (attachShadow({mode:'closed'})) are not accessible β€” same as a real user.

Iframes β€” explicit but simple.

// Modern API (preferred) β€” returns a FrameLocator, fully lazy + auto-waiting
const frame = page.frameLocator('iframe[name="payment"]');
await frame.getByLabel('Card number').fill('4242424242424242');

// Nested iframes chain naturally
await page.frameLocator('#outer')
          .frameLocator('#inner')
          .getByRole('button', { name: 'Pay' })
          .click();

// Legacy Frame API β€” needed for frame-level navigation or evaluate
const f = page.frame({ name: 'payment' });
await f?.goto('https://gateway.example.com/retry');
Enter fullscreen mode Exit fullscreen mode

Critical distinction (L2 favourite):

  • frameLocator() β†’ lazy, re-resolves, no stale frame errors β†’ use this
  • page.frame() / page.frames() β†’ returns a Frame object bound to a specific frame instance β†’ goes stale on reload

Auto-piercing shortcut: since v1.43, page.getByRole() does not cross iframe boundaries β€” you must use frameLocator. Only shadow DOM is pierced automatically. Candidates constantly confuse these two.


Q10. What is the difference between page.waitForLoadState(), page.waitForURL(), page.waitForResponse() and page.waitForFunction()?

Answer.

// 1. Wait for a lifecycle event on the CURRENT document
await page.waitForLoadState('domcontentloaded');

// 2. Wait for the URL to match (handles client-side routing)
await page.waitForURL('**/dashboard');
await page.waitForURL(url => url.searchParams.get('tab') === 'billing');

// 3. Wait for a specific network response
const respPromise = page.waitForResponse(r =>
  r.url().includes('/api/orders') && r.status() === 200
);
await page.getByRole('button', { name: 'Load orders' }).click();
const response = await respPromise;
const body = await response.json();

// 4. Wait for arbitrary JS in the page to become truthy
await page.waitForFunction(() => window.__APP_READY__ === true);
await page.waitForFunction(
  (expected) => document.querySelectorAll('.row').length >= expected,
  10
);
Enter fullscreen mode Exit fullscreen mode

THE most important pattern here β€” always set up the waiter before triggering the action, otherwise you race:

// ❌ RACE CONDITION β€” response may arrive before you start listening
await page.getByRole('button').click();
await page.waitForResponse('**/api/save');

// βœ… CORRECT β€” promise registered first, awaited after
const [response] = await Promise.all([
  page.waitForResponse('**/api/save'),
  page.getByRole('button').click(),
]);

// βœ… EQUIVALENT & MORE READABLE (modern preferred style)
const responsePromise = page.waitForResponse('**/api/save');
await page.getByRole('button').click();
const response = await responsePromise;
Enter fullscreen mode Exit fullscreen mode

When do you actually need these? Rarely. 90% of waits should be web-first assertions. Reach for waitForResponse only when you need the response payload, and waitForFunction only for app-specific readiness flags no DOM state exposes.


Q11. What is page.evaluate() vs page.evaluateHandle() vs page.addInitScript()?

Answer.

// evaluate β€” runs JS in the BROWSER, returns a SERIALISABLE value back to Node
const title = await page.evaluate(() => document.title);
const count = await page.evaluate((sel) => document.querySelectorAll(sel).length, '.row');

// Passing args: everything must be JSON-serialisable
await page.evaluate(({ user, id }) => {
  window.localStorage.setItem('user', JSON.stringify(user));
  window.__DEBUG_ID__ = id;
}, { user: { name: 'Ana' }, id: 42 });

// evaluateHandle β€” returns a JSHandle for NON-serialisable objects (window, functions, DOM nodes)
const windowHandle = await page.evaluateHandle(() => window);

// addInitScript β€” runs BEFORE any page script, on EVERY navigation in the context
await context.addInitScript(() => {
  window.localStorage.setItem('featureFlags', JSON.stringify({ newCheckout: true }));
  // Freeze time for deterministic date tests
  const FIXED = new Date('2026-01-15T10:00:00Z').getTime();
  Date.now = () => FIXED;
});
Enter fullscreen mode Exit fullscreen mode

Why addInitScript is an L2 answer: it's the correct way to seed feature flags, stub Date, mock navigator.geolocation, or disable animations before the app boots β€” page.evaluate() runs too late for that.

Serialisation gotchas interviewers love:

  • undefined, NaN, Infinity, -0 survive (Playwright uses a superset of JSON)
  • Functions, Symbol, DOM nodes, Window β†’ must use evaluateHandle
  • Closures do not transfer: variables from your test file are not in scope inside evaluate unless passed as arguments. This is the #1 beginner error.
const userId = 42;
// ❌ ReferenceError: userId is not defined
await page.evaluate(() => console.log(userId));
// βœ…
await page.evaluate((id) => console.log(id), userId);
Enter fullscreen mode Exit fullscreen mode

Q12. How does Playwright handle downloads, uploads, dialogs, and new tabs?

Answer.

All four follow the same "register the listener, then trigger" pattern.

Downloads:

const downloadPromise = page.waitForEvent('download');
await page.getByRole('link', { name: 'Export CSV' }).click();
const download = await downloadPromise;

expect(download.suggestedFilename()).toBe('report.csv');
await download.saveAs(`./artifacts/${download.suggestedFilename()}`);
const path = await download.path();          // temp path
const stream = await download.createReadStream();
Enter fullscreen mode Exit fullscreen mode

Requires acceptDownloads: true (default true since v1.28).

Uploads:

// Standard <input type="file">
await page.getByLabel('Avatar').setInputFiles('./fixtures/avatar.png');
await page.getByLabel('Docs').setInputFiles(['./a.pdf', './b.pdf']);  // multiple
await page.getByLabel('Avatar').setInputFiles([]);                     // clear

// In-memory buffer β€” no fixture file needed
await page.getByLabel('Upload').setInputFiles({
  name: 'data.csv',
  mimeType: 'text/csv',
  buffer: Buffer.from('id,name\n1,Ana'),
});

// Hidden input behind a custom "Browse" button
const fileChooserPromise = page.waitForEvent('filechooser');
await page.getByRole('button', { name: 'Browse' }).click();
const fileChooser = await fileChooserPromise;
await fileChooser.setFiles('./fixtures/report.xlsx');
Enter fullscreen mode Exit fullscreen mode

Dialogs (alert / confirm / prompt):

// Playwright AUTO-DISMISSES dialogs by default. Register a handler to accept.
page.on('dialog', async dialog => {
  expect(dialog.type()).toBe('confirm');
  expect(dialog.message()).toContain('Delete permanently?');
  await dialog.accept();
  // await dialog.accept('prompt answer');
  // await dialog.dismiss();
});
await page.getByRole('button', { name: 'Delete' }).click();
Enter fullscreen mode Exit fullscreen mode

New tabs / popups:

const popupPromise = context.waitForEvent('page');   // context-level!
await page.getByRole('link', { name: 'Open docs' }).click();
const popup = await popupPromise;
await popup.waitForLoadState();
await expect(popup).toHaveTitle(/Documentation/);
await popup.close();
Enter fullscreen mode Exit fullscreen mode

Gotcha: page.waitForEvent('popup') works for window.open, but context.waitForEvent('page') is more reliable because it also catches target="_blank" links that the browser routes at context level.


SECTION 2 β€” Locators, Selectors & Web-First Assertions (Q13–Q26)


Q13. What is Playwright's recommended locator priority order, and why?

Answer.

Playwright's official guidance is user-facing first, test-id second, CSS/XPath last:

1. getByRole()        ← accessibility role + accessible name  (BEST)
2. getByLabel()       ← form controls
3. getByPlaceholder() ← inputs without labels
4. getByText()        ← non-interactive text
5. getByAltText()     ← images
6. getByTitle()       ← title attribute
7. getByTestId()      ← data-testid  (BEST when semantics are absent)
8. locator('css')     ← structural fallback
9. locator('xpath')   ← last resort
Enter fullscreen mode Exit fullscreen mode

The reasoning:

  • Resilience. CSS classes (.btn-primary-v2-lg) change every design refresh. The accessible name "Submit order" changes only when the product changes β€” which is exactly when your test should break.
  • Free accessibility coverage. If getByRole('button', {name:'Save'}) fails, either the test is wrong or the app has an a11y defect. Your functional suite becomes a partial a11y suite.
  • Readability. getByRole('row', {name:'Invoice 1042'}).getByRole('button', {name:'Delete'}) reads like the manual test case.
  • XPath is worst because it's whitespace/structure-sensitive, doesn't pierce shadow DOM, and produces unreadable failures.
// ❌ Brittle
await page.locator('div.container > div:nth-child(3) > button.btn.btn-sm').click();
await page.locator('//div[@class="modal"]//button[2]').click();

// βœ… Resilient
await page.getByRole('dialog', { name: 'Confirm deletion' })
          .getByRole('button', { name: 'Yes, delete' })
          .click();
Enter fullscreen mode Exit fullscreen mode

Nuanced L2 answer: "I prefer role-based, but I mandate data-testid for anything with no meaningful accessible name β€” icon-only buttons, chart canvases, virtualised list containers. I never allow raw XPath in PRs without a written justification comment."


Q14. Explain getByRole() in depth. What does the name option actually match against?

Answer.

getByRole(role, options) queries the accessibility tree, not the DOM.

await page.getByRole('button', { name: 'Sign in' }).click();
await page.getByRole('heading', { level: 2, name: 'Orders' });
await page.getByRole('checkbox', { name: 'Remember me', checked: true });
await page.getByRole('link', { name: /terms/i });
await page.getByRole('textbox', { name: 'Email', exact: true });
await page.getByRole('tab', { name: 'Billing', selected: true });
await page.getByRole('option', { name: 'India' });
await page.getByRole('row', { name: 'INV-1042' });
Enter fullscreen mode Exit fullscreen mode

name matches the ACCESSIBLE NAME, computed by the browser via the W3C accname algorithm, in this priority:

  1. aria-labelledby (points to another element's text)
  2. aria-label
  3. Native label: <label for>, <caption>, alt, title, placeholder
  4. Text content of the element
<button aria-label="Close dialog">βœ•</button>
Enter fullscreen mode Exit fullscreen mode
await page.getByRole('button', { name: 'Close dialog' }).click();  // βœ… works β€” text content is just "βœ•"
Enter fullscreen mode Exit fullscreen mode

Critical name matching semantics:

  • Case-insensitive by default
  • Whitespace-normalised (collapses runs of spaces/newlines)
  • Substring match by default β†’ { name: 'Sign' } matches "Sign in" AND "Sign up" β†’ strict-mode violation
  • Use { exact: true } for full-string, case-sensitive matching
  • Regex is supported: { name: /^Sign in$/ }

Common roles cheat-sheet:

HTML Implicit role
<button>, <input type="submit"> button
<a href> link
<input type="text">, <textarea> textbox
<input type="checkbox"> checkbox
<input type="radio"> radio
<select> combobox (or listbox if multiple)
<h1>–<h6> heading
<table> / <tr> / <td> table / row / cell
<ul>/<ol> / <li> list / listitem
<nav> / <main> / <aside> navigation / main / complementary
<dialog>, [role=dialog] dialog
<img alt="x"> img
<img alt=""> none β€” presentational

Q15. What's the difference between getByText() and locator('text=...')? Explain exact vs substring matching.

Answer.

They're near-equivalent; getByText() is the modern, typed API.

// Substring, case-insensitive, whitespace-normalised (DEFAULT)
page.getByText('Welcome back');           // matches "  Welcome  back, Ana! "

// Exact β€” full string, case-sensitive, still whitespace-normalised
page.getByText('Welcome back', { exact: true });

// Regex β€” NOT whitespace-normalised, NOT case-insensitive unless you add /i
page.getByText(/^Order #\d{4}$/);

// Legacy selector-engine equivalents
page.locator('text=Welcome back');        // substring
page.locator('text="Welcome back"');      // exact (quoted)
page.locator('text=/^Order #\\d+$/i');    // regex
Enter fullscreen mode Exit fullscreen mode

Crucial behaviour β€” text matching finds the SMALLEST enclosing element:

<div>
  <span>Hello</span> world
</div>
Enter fullscreen mode Exit fullscreen mode

getByText('Hello') β†’ matches <span>, not <div>.
getByText('Hello world') β†’ matches <div>.

getByText only matches direct-ish text, so it's poor for buttons/links β€” prefer getByRole. Use getByText for paragraphs, error messages, toast content, empty-state copy.

Filtering by text is usually what you actually want:

// ❌ finds the text node's element, not the row
await page.getByText('INV-1042').click();

// βœ… finds the ROW that contains that text
await page.getByRole('row').filter({ hasText: 'INV-1042' })
          .getByRole('button', { name: 'View' }).click();
Enter fullscreen mode Exit fullscreen mode

Q16. Explain filter(), and(), or(), not() chaining on locators with real examples.

Answer.

These are Playwright's locator combinators β€” the modern replacement for gnarly XPath.

const rows = page.getByRole('row');

// filter by text content (substring, case-insensitive)
rows.filter({ hasText: 'Pending' });
rows.filter({ hasText: /^INV-\d{4}$/ });

// filter by NOT containing text
rows.filter({ hasNotText: 'Archived' });

// filter by containing a DESCENDANT matching another locator
rows.filter({ has: page.getByRole('button', { name: 'Retry' }) });

// filter by NOT containing a descendant
rows.filter({ hasNot: page.getByTestId('locked-icon') });

// AND β€” element must match BOTH locators (same element)
page.getByRole('button').and(page.getByTitle('Save draft'));

// OR β€” either locator (useful for A/B or loading-vs-loaded races)
const result = page.getByText('No results').or(page.getByRole('table'));
await expect(result.first()).toBeVisible();

// Chaining multiple filters (AND semantics)
const target = rows
  .filter({ hasText: 'Acme Corp' })
  .filter({ has: page.getByRole('cell', { name: 'Overdue' }) })
  .filter({ hasNot: page.getByTestId('disabled-badge') });

await target.getByRole('button', { name: 'Send reminder' }).click();
Enter fullscreen mode Exit fullscreen mode

Real-world scenario answer for L2:

"In a data grid with 200 virtualised rows, I never use nth() β€” row order changes. I locate by business identity: getByRole('row').filter({ hasText: orderId }). That makes the test independent of sorting, pagination, and rendering order."

or() gotcha: or() can match multiple elements β†’ strict-mode violation. Almost always pair with .first() or toHaveCount().


Q17. How do you handle dynamic tables and grids? Give a production-grade example.

Answer.

// pages/OrdersGrid.ts
import { Page, Locator, expect } from '@playwright/test';

export class OrdersGrid {
  private readonly table: Locator;

  constructor(private readonly page: Page) {
    this.table = page.getByRole('table', { name: 'Orders' });
  }

  /** Row identified by BUSINESS KEY, never by index. */
  row(orderId: string): Locator {
    return this.table.getByRole('row').filter({ hasText: orderId });
  }

  /** Read a cell by its column HEADER, not its position. */
  async cellByHeader(orderId: string, header: string): Promise<string> {
    const headers = await this.table.getByRole('columnheader')
                                    .allInnerTexts();
    const index = headers.findIndex(h => h.trim() === header);
    if (index === -1) throw new Error(`Column "${header}" not found. Got: ${headers}`);

    return (await this.row(orderId).getByRole('cell').nth(index).innerText()).trim();
  }

  async clickRowAction(orderId: string, action: string): Promise<void> {
    await this.row(orderId).getByRole('button', { name: action }).click();
  }

  async expectRowStatus(orderId: string, status: string): Promise<void> {
    await expect(this.row(orderId).getByTestId('status-badge')).toHaveText(status);
  }

  /** Extract the whole table into typed objects for bulk assertions. */
  async toObjects<T extends Record<string, string>>(): Promise<T[]> {
    const headers = (await this.table.getByRole('columnheader').allInnerTexts())
      .map(h => h.trim());

    const rows = await this.table.locator('tbody tr').all();

    return Promise.all(rows.map(async (r) => {
      const cells = await r.getByRole('cell').allInnerTexts();
      return Object.fromEntries(
        headers.map((h, i) => [h, cells[i]?.trim() ?? ''])
      ) as T;
    }));
  }
}
Enter fullscreen mode Exit fullscreen mode

Usage:

const grid = new OrdersGrid(page);
await grid.expectRowStatus('INV-1042', 'Overdue');
expect(await grid.cellByHeader('INV-1042', 'Amount')).toBe('β‚Ή12,400.00');

const all = await grid.toObjects<{ Order: string; Status: string }>();
expect(all.filter(r => r.Status === 'Overdue')).toHaveLength(3);
Enter fullscreen mode Exit fullscreen mode

Why this scores well: header-index lookup survives column reordering; business-key row lookup survives sorting/pagination; toObjects() enables set-based assertions instead of 40 individual expects.


Q18. What is page.getByTestId() and how do you configure a custom attribute?

Answer.

// Default attribute is data-testid
await page.getByTestId('checkout-submit').click();
Enter fullscreen mode Exit fullscreen mode

Change it globally:

// playwright.config.ts
export default defineConfig({
  use: {
    testIdAttribute: 'data-qa',        // or 'data-cy', 'data-test', 'data-automation-id'
  },
});
Enter fullscreen mode Exit fullscreen mode

Or at runtime:

import { selectors } from '@playwright/test';
selectors.setTestIdAttribute('data-pw');
Enter fullscreen mode Exit fullscreen mode

Test-id policy that impresses interviewers:

"I treat data-testid as a public API contract between QA and dev. Rules I enforce:

  1. Test-ids are added by developers in the same PR as the feature, reviewed by QA.
  2. Naming convention: {page}-{component}-{element} β†’ checkout-payment-submit.
  3. They are stripped from production builds via a Babel/SWC plugin so they cost zero bytes to end users.
  4. Test-ids are only used where no stable accessible name exists β€” icon buttons, canvases, virtualised containers. Every other locator is role-based."

Why not test-ids everywhere? They test your markup contract, not user-perceivable behaviour. A button with a perfect data-testid but a broken aria-label passes your suite and fails your users.


Q19. How do you assert on a list of elements? Compare allTextContents(), allInnerTexts(), and toHaveText([]).

Answer.

const items = page.getByRole('listitem');

// βœ… BEST β€” retrying, whole-array assertion, great diff on failure
await expect(items).toHaveText(['Apple', 'Banana', 'Cherry']);
await expect(items).toContainText(['Apple', 'Cherry']);   // subset, in order
await expect(items).toHaveCount(3);

// ⚠️ Non-retrying snapshots β€” only when you need to compute/transform
const raw  = await items.allTextContents();  // textContent β€” includes HIDDEN text, no normalisation
const seen = await items.allInnerTexts();    // innerText  β€” VISIBLE text only, browser-normalised
Enter fullscreen mode Exit fullscreen mode
allTextContents() allInnerTexts()
Source node.textContent element.innerText
Hidden elements Included Excluded
<br> handling Ignored Becomes \n
CSS text-transform Not applied Applied (uppercase shows uppercase)
Whitespace Raw Normalised
Retrying ❌ No ❌ No

Iterating properly:

// βœ… modern β€” .all() returns Locator[] AFTER resolving once
for (const item of await items.all()) {
  await expect(item).toBeVisible();
}

// βœ… index-based when you need the position
const count = await items.count();
for (let i = 0; i < count; i++) {
  await expect(items.nth(i)).toContainText('β‚Ή');
}
Enter fullscreen mode Exit fullscreen mode

Gotcha: .all() resolves the list once. If the DOM re-renders mid-loop (React re-key), the handles go stale. For dynamic lists, prefer whole-array retrying assertions (toHaveText([...])) over loops.


Q20. Explain nth(), first(), last() and when using them is a code smell.

Answer.

page.getByRole('row').first();
page.getByRole('row').last();
page.getByRole('row').nth(2);     // 0-based
page.getByRole('row').nth(-1);    // last (negative indexing supported)
Enter fullscreen mode Exit fullscreen mode

These bypass strict mode β€” that's their purpose and their danger.

When they're legitimate:

  • The list is genuinely ordered and order is the thing under test: "the newest notification appears first"
  • Pagination controls: .first() / .last() page buttons
  • Deliberately checking a known-fixed structure (a 3-step wizard indicator)

When they're a code smell:

// ❌ "It failed with strict mode so I slapped .first() on it"
await page.getByRole('button', { name: 'Delete' }).first().click();
Enter fullscreen mode Exit fullscreen mode

This test now silently deletes whichever record happens to render first. It will pass forever and delete the wrong thing.

The refactor:

await page.getByRole('row', { name: 'INV-1042' })
          .getByRole('button', { name: 'Delete' })
          .click();
Enter fullscreen mode Exit fullscreen mode

Interview line: ".first() is an assertion that order matters. If order doesn't matter to the business rule I'm testing, .first() is a bug I haven't found yet."


Q21. How do you handle elements that appear only on hover, or inside a context menu?

Answer.

// Hover-revealed action buttons in a row
const row = page.getByRole('row', { name: 'INV-1042' });
await row.hover();
await row.getByRole('button', { name: 'More actions' }).click();
await page.getByRole('menuitem', { name: 'Duplicate' }).click();

// Right-click context menu
await page.getByTestId('file-node').click({ button: 'right' });
await page.getByRole('menuitem', { name: 'Rename' }).click();

// Modifier clicks
await page.getByRole('link').click({ modifiers: ['Control'] });   // open in new tab
await page.getByRole('row').nth(0).click();
await page.getByRole('row').nth(4).click({ modifiers: ['Shift'] }); // range select

// Click a precise coordinate inside the element (canvas / chart)
await page.locator('canvas#chart').click({ position: { x: 120, y: 45 } });

// Force click β€” SKIPS actionability checks. Use as a last resort ONLY.
await page.getByRole('button').click({ force: true });
Enter fullscreen mode Exit fullscreen mode

When is force: true acceptable? Almost never in product tests. Two defensible cases:

  1. An element intentionally covered by a decorative overlay with pointer-events: none misconfigured β€” and you've filed the bug.
  2. Testing a deliberately-disabled-looking control that is functionally enabled.

Otherwise force: true is disabling the exact safety net (hit-target testing) that catches real overlay bugs.

Hover flakiness fix: if hover-revealed controls animate in, the stable check handles it β€” but if the hover is lost because Playwright moves the mouse, chain the hover into the same locator subtree (as above) so the pointer stays inside the parent.


Q22. How do you test drag-and-drop?

Answer.

Two approaches β€” try the simple one first.

// 1. High-level API β€” works for HTML5 drag events
await page.getByTestId('card-1').dragTo(page.getByTestId('column-done'));

// with a target position
await page.getByTestId('card-1').dragTo(page.getByTestId('column-done'), {
  targetPosition: { x: 20, y: 10 },
});

// 2. Manual mouse sequence β€” required for custom JS drag (react-dnd, dnd-kit, canvas)
const source = page.getByTestId('card-1');
const target = page.getByTestId('column-done');

const srcBox = (await source.boundingBox())!;
const tgtBox = (await target.boundingBox())!;

await page.mouse.move(srcBox.x + srcBox.width / 2, srcBox.y + srcBox.height / 2);
await page.mouse.down();
// Intermediate moves are ESSENTIAL β€” many libraries need >1 mousemove to start a drag
await page.mouse.move(tgtBox.x + tgtBox.width / 2, tgtBox.y + tgtBox.height / 2, { steps: 10 });
await page.mouse.move(tgtBox.x + tgtBox.width / 2, tgtBox.y + tgtBox.height / 2);
await page.mouse.up();

await expect(target.getByTestId('card-1')).toBeVisible();
Enter fullscreen mode Exit fullscreen mode

Why dragTo() sometimes fails: it dispatches HTML5 dragstart/drop events. Libraries like react-beautiful-dnd listen to raw mousedown/mousemove/mouseup and require a movement threshold plus multiple frames β€” hence steps: 10 and the duplicate final move.


Q23. How do you do visual regression testing in Playwright? What causes flaky screenshots and how do you fix them?

Answer.

// Full page
await expect(page).toHaveScreenshot('dashboard.png', { fullPage: true });

// Single component β€” far more stable than full-page
await expect(page.getByTestId('price-card')).toHaveScreenshot('price-card.png');

// Tolerances & masking
await expect(page).toHaveScreenshot('home.png', {
  maxDiffPixelRatio: 0.01,           // allow 1% of pixels to differ
  maxDiffPixels: 200,
  threshold: 0.2,                     // per-pixel YIQ colour tolerance (0–1)
  mask: [page.getByTestId('live-clock'), page.locator('.ad-slot')],
  maskColor: '#FF00FF',
  animations: 'disabled',             // freezes CSS animations & transitions
  caret: 'hide',                      // hides the text cursor
  scale: 'css',                       // ignore devicePixelRatio differences
  clip: { x: 0, y: 0, width: 800, height: 600 },
});
Enter fullscreen mode Exit fullscreen mode

Global config:

export default defineConfig({
  expect: {
    toHaveScreenshot: {
      maxDiffPixelRatio: 0.01,
      animations: 'disabled',
      scale: 'css',
    },
  },
  snapshotPathTemplate: '{testDir}/__screenshots__/{projectName}/{testFilePath}/{arg}{ext}',
});
Enter fullscreen mode Exit fullscreen mode

The five causes of screenshot flakiness β€” and the fix for each:

Cause Fix
Font rendering differs on macOS vs Linux CI Generate baselines in Docker, identical to CI. mcr.microsoft.com/playwright:v1.5x.x-jammy
Animations / transitions mid-flight animations: 'disabled' + await expect(el).toBeVisible() before shooting
Dynamic content (dates, IDs, avatars, ads) mask: [...] or freeze Date.now() via addInitScript
Lazy-loaded images not yet decoded await page.waitForFunction(() => [...document.images].every(i => i.complete))
Scrollbar / devicePixelRatio differences Fix viewport + deviceScaleFactor in config; scale: 'css'

Update baselines: npx playwright test --update-snapshots (or -u). In CI, never auto-update β€” a visual diff should require a human PR review.


Q24. What are soft assertions and when should you use them?

Answer.

expect.soft() records a failure but does not stop the test.

test('order confirmation shows all details', async ({ page }) => {
  await page.goto('/orders/1042/confirmation');

  // Collect ALL mismatches in one run instead of fixing them one at a time
  await expect.soft(page.getByTestId('order-id')).toHaveText('INV-1042');
  await expect.soft(page.getByTestId('total')).toHaveText('β‚Ή12,400.00');
  await expect.soft(page.getByTestId('eta')).toHaveText('28 Jul 2026');
  await expect.soft(page.getByTestId('address')).toContainText('Bengaluru');

  // Hard assertion β€” the test cannot continue meaningfully without this
  await expect(page.getByRole('button', { name: 'Download invoice' })).toBeEnabled();
});
Enter fullscreen mode Exit fullscreen mode

Use soft assertions for:

  • Verifying many independent fields on one screen (forms, receipts, detail pages)
  • Content/copy audits
  • Cross-field consistency checks

Do NOT use soft assertions for:

  • Preconditions β€” if login failed, running 12 more soft assertions produces 12 useless failures
  • Anything the next step depends on

Fail fast within a block:

expect(test.info().errors).toHaveLength(0);   // bail if any soft assertion has failed so far
Enter fullscreen mode Exit fullscreen mode

Custom failure messages (huge for triage speed):

await expect(page.getByTestId('total'), 'Order total must include GST')
  .toHaveText('β‚Ή12,400.00');
Enter fullscreen mode Exit fullscreen mode

Q25. How do you write a custom matcher / extend expect in Playwright + TypeScript?

Answer.

// support/custom-matchers.ts
import { expect as baseExpect, Locator } from '@playwright/test';

export const expect = baseExpect.extend({
  async toHaveCurrencyValue(
    locator: Locator,
    expected: number,
    options?: { timeout?: number }
  ) {
    const assertionName = 'toHaveCurrencyValue';
    let pass: boolean;
    let actualText = '';

    try {
      // Use baseExpect with .poll to get RETRYING behaviour for free
      await baseExpect
        .poll(async () => {
          actualText = (await locator.innerText()).trim();
          return Number(actualText.replace(/[^0-9.-]/g, ''));
        }, { timeout: options?.timeout ?? 5000 })
        .toBe(expected);
      pass = true;
    } catch {
      pass = false;
    }

    return {
      name: assertionName,
      pass,
      expected,
      actual: actualText,
      message: () =>
        pass
          ? `Expected ${locator} NOT to have currency value ${expected}`
          : `Expected ${locator} to have currency value ${expected}, but got "${actualText}"`,
    };
  },
});
Enter fullscreen mode Exit fullscreen mode

Type declaration so TypeScript knows about it:

// types/global.d.ts
import type { Locator } from '@playwright/test';

declare global {
  namespace PlaywrightTest {
    interface Matchers<R, T> {
      toHaveCurrencyValue(expected: number, options?: { timeout?: number }): Promise<R>;
    }
  }
}
export {};
Enter fullscreen mode Exit fullscreen mode

Usage:

import { expect } from '../support/custom-matchers';
await expect(page.getByTestId('total')).toHaveCurrencyValue(12400);
Enter fullscreen mode Exit fullscreen mode

expect.poll() β€” the underrated cousin:

// Retry ANY async function until it satisfies a matcher
await expect.poll(async () => {
  const res = await request.get('/api/job/1042');
  return (await res.json()).status;
}, {
  timeout: 60_000,
  intervals: [1_000, 2_000, 5_000, 10_000],   // backoff
  message: 'Batch job never reached COMPLETED',
}).toBe('COMPLETED');
Enter fullscreen mode Exit fullscreen mode

expect.toPass() β€” retry a whole block:

await expect(async () => {
  await page.reload();
  await expect(page.getByTestId('status')).toHaveText('Ready');
}).toPass({ timeout: 60_000, intervals: [2_000, 5_000] });
Enter fullscreen mode Exit fullscreen mode

Q26. How do you handle a "select" dropdown vs a custom React/Angular dropdown?

Answer.

// NATIVE <select> β€” use selectOption()
await page.getByLabel('Country').selectOption('IN');                    // by value
await page.getByLabel('Country').selectOption({ label: 'India' });      // by visible label
await page.getByLabel('Country').selectOption({ index: 3 });            // by index
await page.getByLabel('Skills').selectOption(['js', 'ts', 'py']);       // multi-select
await page.getByLabel('Country').selectOption([]);                      // deselect all

// Assert
await expect(page.getByLabel('Country')).toHaveValue('IN');
await expect(page.getByLabel('Skills')).toHaveValues(['js', 'ts']);

// CUSTOM dropdown (div-based, MUI/AntD/Radix) β€” it's a click sequence
const combo = page.getByRole('combobox', { name: 'Country' });
await combo.click();
await page.getByRole('option', { name: 'India' }).click();
await expect(combo).toHaveText('India');

// Type-ahead / autocomplete
await combo.fill('Ind');
await expect(page.getByRole('listbox')).toBeVisible();
await page.getByRole('option', { name: 'India', exact: true }).click();

// Portal-rendered menus (rendered at document.body, OUTSIDE the trigger)
// ⚠️ Do NOT scope to the trigger's parent β€” the options aren't there.
await combo.click();
await page.getByRole('listbox').getByRole('option', { name: 'India' }).click();
Enter fullscreen mode Exit fullscreen mode

The trap: many candidates try selectOption() on a <div role="combobox"> and it throws Element is not a <select> element. Recognising native vs ARIA-custom instantly is an L1 filter.


SECTION 3 β€” Auto-Waiting, Actionability & Flakiness (Q27–Q36)


Q27. Your suite is 8% flaky in CI but 0% locally. Walk me through your diagnostic process.

Answer. (This is THE most common L2 scenario question. Structure your answer.)

Step 1 β€” Make flakiness visible, not tolerable.

export default defineConfig({
  retries: process.env.CI ? 2 : 0,
  reporter: [['html'], ['blob'], ['json', { outputFile: 'results.json' }]],
  use: {
    trace: 'on-first-retry',        // trace ONLY on the retry β€” cheap + always available
    video: 'retain-on-failure',
    screenshot: 'only-on-failure',
  },
});
Enter fullscreen mode Exit fullscreen mode

Retries mask flakiness. So I always report flaky count separately and treat a rising flaky rate as a P2 bug, not noise.

Step 2 β€” Categorise from traces. Open the failing trace (npx playwright show-trace trace.zip) and classify:

Symptom in trace Root cause Fix
Action retried until timeout, element visible in snapshot Hit-target blocked by overlay Dismiss the overlay; don't force
Assertion passed on retry #2 Non-retrying assertion used Convert to web-first await expect(loc)
Different element clicked than expected .first() / index-based locator Scope by business key
Network tab shows 500 or slow API Backend/test-data instability Mock the dependency or seed data via API
Test passes alone, fails in suite Shared state / test pollution Isolate: unique data per test, fresh context
Fails only on worker N Data collision between parallel workers Per-worker fixture accounts
Timing varies wildly CI machine under-resourced Reduce workers, raise CI runner size

Step 3 β€” The five structural fixes I apply, in order of ROI:

  1. Eliminate every non-retrying assertion. Lint rule: ban expect(await in test files.
  2. Ban waitForTimeout(). ESLint no-restricted-syntax rule fails the build.
  3. Unique test data per test. const email = \user-${crypto.randomUUID()}@test.io``. Never a shared "automation@test.com".
  4. API-based setup. Log in, create the order, seed the cart via request β€” only the assertion path goes through the UI. Cuts runtime ~60% and removes whole classes of flake.
  5. Deterministic environment. Freeze time, mock third-party widgets (chat, analytics, ads), disable animations globally.

`ts
// Global animation kill-switch
await context.addInitScript(() => {
const style = document.createElement('style');
style.textContent = `*, *::before, *::after {
animation-duration: 0s !important;
animation-delay: 0s !important;
transition-duration: 0s !important;
transition-delay: 0s !important;
scroll-behavior: auto !important;
}`;
document.head.appendChild(style);
});
`

Step 4 β€” Quarantine, don't delete. Tag chronically flaky tests @quarantine, run them in a non-blocking CI job, and give them an owner + a 2-sprint SLA. A deleted test is coverage silently lost.


Q28. Why is page.waitForTimeout() an anti-pattern, and what do you use instead in each situation?

Answer.

waitForTimeout() is a hard sleep. It is simultaneously too short (fails on a slow CI runner) and too long (wastes minutes across a 2000-test suite). Playwright's own docs state it should not be used in production tests.

Situation you were tempted to sleep for Correct replacement
Waiting for element to appear await expect(loc).toBeVisible()
Waiting for spinner to go await expect(page.getByTestId('spinner')).toBeHidden()
Waiting for an API to finish page.waitForResponse(...) set up before the click
Waiting for navigation await page.waitForURL('**/dashboard')
Waiting for a debounce (search-as-you-type) await expect(results).toHaveCount(5) β€” assert the outcome
Waiting for animation animations:'disabled' + toBeVisible() (stability check covers it)
Waiting for a background job expect.poll() against the status API with backoff
Waiting for a toast to auto-dismiss await expect(toast).toBeHidden({ timeout: 10_000 })

The one legitimate use: deliberately not interacting for a period, e.g. verifying a session-idle timeout or a "no autosave within 2s" rule.

`ts
test('does not autosave before the 3s debounce', async ({ page }) => {
const saveCalls: string[] = [];
await page.route('**/api/autosave', route => { saveCalls.push(route.request().url()); route.fulfill({status:200, body:'{}'}); });

await page.getByRole('textbox').fill('draft');
await page.waitForTimeout(1500); // βœ… intentional idle
expect(saveCalls).toHaveLength(0);
await expect.poll(() => saveCalls.length, { timeout: 5000 }).toBe(1);
});
`

Enforce it in the repo:
`js
// eslint.config.js
rules: {
'no-restricted-syntax': ['error', {
selector: "CallExpression[callee.property.name='waitForTimeout']",
message: 'Use web-first assertions instead of hard sleeps. See TESTING.md#waiting',
}],
}
`


Q29. Explain test.step() and why it matters for a large suite.

Answer.

`ts
import { test, expect } from '@playwright/test';

test('checkout happy path', async ({ page }) => {
await test.step('Add item to cart', async () => {
await page.goto('/products/sku-991');
await page.getByRole('button', { name: 'Add to cart' }).click();
await expect(page.getByTestId('cart-count')).toHaveText('1');
});

await test.step('Apply coupon JUPITER90', async () => {
await page.getByRole('link', { name: 'Cart' }).click();
await page.getByLabel('Coupon code').fill('JUPITER90');
await page.getByRole('button', { name: 'Apply' }).click();
await expect(page.getByTestId('discount')).toContainText('90%');
});

await test.step('Complete payment', async () => {
await page.getByRole('button', { name: 'Checkout' }).click();
await expect(page.getByRole('heading', { name: 'Order confirmed' })).toBeVisible();
});
});
`

Why it matters at scale:

  1. Triage speed. The HTML report and trace viewer group actions under named steps. A PM or dev can read "failed at Apply coupon" without knowing Playwright.
  2. Timing per step. The report shows duration per step β€” you find the slow business operation, not just the slow test.
  3. Steps return values and can be nested: `ts const orderId = await test.step('Create order via API', async () => { const res = await request.post('/api/orders', { data: payload }); return (await res.json()).id as string; }); `
  4. box: true collapses internal noise so failures point at your call site, not inside the POM: `ts await test.step('login', async () => { ... }, { box: true }); `
  5. Auto-instrument your Page Objects with a decorator so every POM method becomes a step for free: `ts function step(name?: string) { return function (target: Function, ctx: ClassMethodDecoratorContext) { return function (this: any, ...args: any[]) { const label = name ?? ${this.constructor.name}.${String(ctx.name)}; return test.step(label, async () => target.call(this, ...args), { box: true }); }; }; }

class LoginPage {
@step('Login as user')
async login(u: string, p: string) { /* ... */ }
}
`


Q30. What is Trace Viewer? What exactly does a trace contain and how do you use it?

Answer.

`ts
use: { trace: 'on-first-retry' } // options: 'off' | 'on' | 'retain-on-failure' | 'on-first-retry' | 'on-all-retries'
`

`bash
npx playwright show-trace trace.zip

or drop the zip at https://trace.playwright.dev (runs fully client-side)

`

A trace contains:

  • Action timeline β€” every Playwright call with duration
  • DOM snapshots β€” before, action (with the click target highlighted), and after for every action. These are live, inspectable DOM, not screenshots β€” you can open DevTools on a snapshot from three weeks ago.
  • Screencast frames β€” filmstrip across the timeline
  • Network log β€” every request/response with headers, timing, and body
  • Console log β€” including page errors
  • Source β€” your test code with the failing line highlighted
  • Metadata β€” browser version, viewport, test annotations

How I actually use it (say this, it's concrete):

"I go to the failing action β†’ look at the before snapshot β†’ hover the element in the DOM tab. If the element exists but the action timed out, it's a hit-target problem β€” I check the Network tab for a modal-triggering call. If the element is missing entirely, it's a data or timing problem β€” I check whether the API returned 200 with an empty array. That two-branch decision resolves ~80% of failures without ever reproducing locally."

Trace cost: meaningful (10–50 MB per test). Hence on-first-retry in CI β€” you pay only for tests that already failed once.

Other debug tools worth naming:
`bash
npx playwright test --debug # Playwright Inspector, step through
npx playwright test --ui # UI Mode β€” watch mode + time-travel (best DX)
npx playwright codegen example.com # record and generate
PWDEBUG=1 npx playwright test # opens inspector, disables timeouts
DEBUG=pw:api npx playwright test # verbose protocol logging
`

`ts
await page.pause(); // breakpoint inside a test
`


Q31. How do retries work? What is a "flaky" test in Playwright's report and how does it differ from a failure?

Answer.

`ts
export default defineConfig({
retries: 2,
maxFailures: process.env.CI ? 10 : undefined, // bail out of a doomed run early
});
`

Semantics:

  • Failed = failed on the initial run and every retry
  • Flaky = failed at least once, passed on a retry
  • Each retry runs in a fresh worker with a fresh browser context β€” retries never inherit contaminated state
  • beforeAll/afterAll re-run because the worker is new
  • test.info().retry gives the 0-based retry index:

`ts
test('checkout', async ({ page }, testInfo) => {
if (testInfo.retry > 0) {
await resetTestData(); // heal state before retrying
test.slow(); // triple timeout on retries
}
});
`

Per-file / per-block retries:
`ts
test.describe.configure({ retries: 3 });
`

The governance point that separates senior candidates:

"Retries are a reporting tool, not a quality tool. I set retries: 2 in CI to keep the pipeline green, but I pipe the flaky count into a dashboard. My rule: flaky rate above 2% blocks the release train. Otherwise retries become a place where real race-condition bugs go to hide β€” and those are exactly the bugs that hit production at 3 a.m."

Blob reports + merging (essential for sharded CI):
`bash
npx playwright test --shard=1/4 --reporter=blob
npx playwright merge-reports --reporter=html ./blob-report
`


Q32. How do you handle a third-party widget (chat, ads, analytics, captcha) that makes tests slow and flaky?

Answer.

Block it at the network layer. This is the single highest-ROI stability fix in most real suites.

`ts
// fixtures/blocked-third-parties.ts
const BLOCKED = [
'/*.google-analytics.com/',
'/*.googletagmanager.com/',
'/*.doubleclick.net/',
'/*.hotjar.com/',
'/*.intercom.io/',
'/*.facebook.net/',
'/*.segment.com/',
];

export const test = base.extend({
page: async ({ page }, use) => {
for (const pattern of BLOCKED) {
await page.route(pattern, route => route.abort());
}
await use(page);
},
});
`

Block by resource type (kills images/fonts/media for a big speed win on non-visual tests):
`ts
await page.route('**/*', route => {
const type = route.request().resourceType();
return ['image', 'font', 'media'].includes(type) ? route.abort() : route.continue();
});
`

Captcha: never solve it. Options, in order of preference:

  1. Ask the team for a test-mode bypass key (reCAPTCHA and hCaptcha both ship official test keys that always pass).
  2. Env-flag the captcha off in non-prod.
  3. Bypass the UI entirely β€” authenticate via API and inject storageState.
  4. Absolute last resort: allowlist the CI IP range.

Interview framing: "Third-party scripts aren't my system under test. Blocking them isn't cheating β€” it's scoping. If I need to verify the analytics contract, I write a separate focused test that asserts the outgoing beacon payload via page.route, rather than letting a real network call to a vendor destabilise 900 functional tests."


Q33. Explain test.fixme(), test.skip(), test.fail(), test.only() and test.slow().

Answer.

`ts
test.skip('not applicable on mobile', async ({ page }) => {}); // never runs
test.skip(({ browserName }) => browserName === 'webkit', 'WebKit bug #1234');
test.skip(process.env.ENV === 'prod', 'Destructive β€” staging only');

test.fixme('broken by JIRA-4421', async ({ page }) => {}); // known-broken, not run, flagged in report
test.fail(); // test is EXPECTED to fail; passing = report failure
test.only('debug this one', async ({ page }) => {}); // ⚠️ blocks in CI via forbidOnly

test.slow(); // 3Γ— the timeout
test.setTimeout(120_000);
`

skip vs fixme β€” the distinction interviewers check:

  • skip = "this test is not applicable in this environment/browser." Expected, permanent, no action needed.
  • fixme = "this test should pass but doesn't, because of a known bug." It's a tracked debt item.

Using skip for broken tests is how suites rot silently. fixme shows up distinctly in the report and is greppable for your tech-debt review.

Guard only in CI:
`ts
export default defineConfig({
forbidOnly: !!process.env.CI, // build FAILS if someone commits .only
});
`

Conditional skip inside a test body:
`ts
test('premium feature', async ({ page }) => {
test.skip(!process.env.PREMIUM_ACCOUNT, 'Needs a premium account');
// ...
});
`

Annotations for traceability:
`ts
test('checkout', { tag: ['@smoke', '@payments'],
annotation: [{ type: 'issue', description: 'https://jira/PROJ-4421' }] },
async ({ page }) => {});
`

Then: npx playwright test --grep @smoke / --grep-invert @slow.


Q34. How do you make tests independent when the application has inherently sequential workflows (e.g., multi-step approval)?

Answer.

Two valid strategies β€” you should present both and state your preference.

Strategy A (preferred): API-based state setup.
`ts
test('manager can approve a submitted expense', async ({ page, request }) => {
// Arrange via API β€” fast, deterministic, no UI dependency
const expense = await createExpenseViaApi(request, { amount: 4500, status: 'SUBMITTED' });

// Act + Assert via UI β€” only the behaviour under test
await page.goto(/expenses/${expense.id});
await page.getByRole('button', { name: 'Approve' }).click();
await expect(page.getByTestId('status')).toHaveText('Approved');
});
`
Each test owns its data end-to-end. Fully parallel. No ordering. This is the answer that wins the interview.

Strategy B: serial mode, when the API genuinely doesn't exist.
`ts
test.describe.configure({ mode: 'serial' });

test.describe('Expense approval workflow', () => {
let expenseId: string;
let page: Page;

test.beforeAll(async ({ browser }) => {
page = await browser.newPage(); // shared page across the block
});
test.afterAll(async () => await page.close());

test('employee submits', async () => {
await page.goto('/expenses/new');
// ...
expenseId = await page.getByTestId('expense-id').innerText();
});

test('manager approves', async () => {
await page.goto(/expenses/${expenseId});
// ...
});
});
`

In serial mode: all tests run on one worker, in order, and if one fails the rest are skipped (not run and marked failed) β€” because their preconditions are invalid.

The trade-off you must articulate:

"Serial mode costs me parallelism and turns one bug into a blind spot across four tests. I use it only when there's no API to seed state, and I raise a ticket to build that seeding endpoint. It's a temporary accommodation, not a design."


Q35. What is test.describe.configure({ mode }) and what are the three modes?

Answer.

`ts
test.describe.configure({ mode: 'parallel' }); // tests in this file/block run on ANY worker, concurrently
test.describe.configure({ mode: 'serial' }); // one worker, in order, skip-rest-on-failure
test.describe.configure({ mode: 'default' }); // sequential within the file, but the FILE is a parallel unit
`

Mode Same worker? Order guaranteed? On failure
default Yes Yes (within file) Remaining tests still run
parallel No No Independent
serial Yes Yes Remaining tests skipped; whole block retried together

Also configurable: retries and timeout at describe level.
`ts
test.describe.configure({ mode: 'serial', retries: 1, timeout: 90_000 });
`

Interaction with fullyParallel: true: the global flag sets every file to parallel mode by default; a local test.describe.configure({ mode: 'serial' }) overrides it for that block. That's how you keep a 95%-parallel suite with three legacy serial blocks.


Q36. How do you deal with tests that depend on time (scheduled jobs, expiry, timezone-sensitive UI)?

Answer.

1. Clock API (v1.45+) β€” the modern, correct answer.
`ts
test('session expires after 30 minutes', async ({ page }) => {
await page.clock.install({ time: new Date('2026-07-24T09:00:00Z') });
await page.goto('/dashboard');

await page.clock.fastForward('29:00'); // advance 29 minutes instantly
await expect(page.getByTestId('session-warning')).toBeHidden();

await page.clock.fastForward('02:00');
await expect(page.getByRole('dialog', { name: 'Session expired' })).toBeVisible();
});

// Other clock controls
await page.clock.setFixedTime(new Date('2026-01-01')); // Date.now() frozen, timers still run
await page.clock.setSystemTime(new Date('2026-01-01')); // jump the clock, timers fire
await page.clock.pauseAt(new Date('2026-12-25T00:00:00'));
await page.clock.runFor(2000); // advance exactly 2s of timers
await page.clock.resume();
`

2. Timezone + locale pinning β€” never rely on the CI machine's TZ.
`ts
export default defineConfig({
use: {
timezoneId: 'Asia/Kolkata',
locale: 'en-IN',
},
projects: [
{ name: 'IN', use: { timezoneId: 'Asia/Kolkata', locale: 'en-IN' } },
{ name: 'US', use: { timezoneId: 'America/New_York', locale: 'en-US' } },
{ name: 'JP', use: { timezoneId: 'Asia/Tokyo', locale: 'ja-JP' } },
],
});
`

3. Relative test data, never hardcoded dates.
`ts
// ❌ passes today, fails on 2026-08-01
await page.getByLabel('Due date').fill('2026-07-31');

// βœ…
const dueDate = new Date(Date.now() + 7 * 864e5).toISOString().slice(0, 10);
await page.getByLabel('Due date').fill(dueDate);
`

4. Freeze at boot for pre-Clock-API codebases:
`ts
await context.addInitScript(() => {
const FIXED = new Date('2026-07-24T10:00:00Z').getTime();
const OriginalDate = Date;
// @ts-ignore
window.Date = class extends OriginalDate {
constructor(...args: any[]) { super(...(args.length ? args : [FIXED])); }
static now() { return FIXED; }
};
});
`


SECTION 4 β€” Fixtures, Hooks & Test Isolation (Q37–Q48)


Q37. What are Playwright fixtures? How do they differ from beforeEach/afterEach hooks?

Answer.

Fixtures are dependency-injected, lazily-instantiated, automatically-torn-down test resources.

`ts
test('example', async ({ page, context, browser, request, browserName }) => {});
// ^^^^ these are all fixtures
`

beforeEach hook Fixture
Instantiation Always runs Only if the test requests it
Sharing state Via outer-scope let variables (untyped, mutable) Via injected parameters (typed)
Teardown Separate afterEach, easy to forget Same function, guaranteed after use()
Composition Copy-paste between files Import and extend
Scope control Test only test or worker scope
Ordering Declaration order Automatic dependency graph resolution
Reusability across files Poor Excellent

Why lazy instantiation matters: in a 500-test suite where only 40 tests need a seeded database, a beforeEach pays the DB cost 500 times. A fixture pays it 40 times.

A fixture's anatomy:
`ts
myFixture: async ({ dependency }, use, testInfo) => {
const resource = await setup(); // ← SETUP (before test)
await use(resource); // ← test runs here; resource is injected
await teardown(resource); // ← TEARDOWN (after test, ALWAYS runs, even on failure)
}
`

Everything before use() is setup; everything after is teardown. There is no way to forget the teardown.


Q38. Write a complete custom fixture setup with TypeScript typing (test-scoped and worker-scoped).

Answer.

`ts
// fixtures/index.ts
import { test as base, expect, Page, APIRequestContext } from '@playwright/test';
import { LoginPage } from '../pages/LoginPage';
import { DashboardPage } from '../pages/DashboardPage';
import { ApiClient } from '../api/ApiClient';

// ---- Types -------------------------------------------------------------
type TestFixtures = {
loginPage: LoginPage;
dashboardPage: DashboardPage;
authedPage: Page;
testUser: { email: string; password: string; id: string };
};

type WorkerFixtures = {
apiClient: ApiClient;
workerAccount: { email: string; password: string };
};

// ---- Fixture definitions ----------------------------------------------
export const test = base.extend({

/* WORKER-SCOPED: created once per worker process, reused by every test in it */
apiClient: [async ({ playwright }, use, workerInfo) => {
const ctx: APIRequestContext = await playwright.request.newContext({
baseURL: process.env.API_URL,
extraHTTPHeaders: { 'X-Api-Key': process.env.API_KEY! },
});
const client = new ApiClient(ctx);
await client.authenticate();

await use(client);

await ctx.dispose();
Enter fullscreen mode Exit fullscreen mode

}, { scope: 'worker' }],

/* WORKER-SCOPED: a dedicated account per worker β†’ zero data collisions */
workerAccount: [async ({ apiClient }, use, workerInfo) => {
const email = worker-${workerInfo.workerIndex}-${Date.now()}@test.io;
const account = await apiClient.createUser({ email, password: 'Pw!23456' });

await use(account);

await apiClient.deleteUser(account.id);
Enter fullscreen mode Exit fullscreen mode

}, { scope: 'worker' }],

/* TEST-SCOPED: fresh per test */
testUser: async ({ apiClient }, use) => {
const user = await apiClient.createUser({
email: u-${crypto.randomUUID()}@test.io,
password: 'Pw!23456',
});
await use(user);
await apiClient.deleteUser(user.id); // cleanup runs even if the test failed
},

loginPage: async ({ page }, use) => {
await use(new LoginPage(page));
},

dashboardPage: async ({ page }, use) => {
await use(new DashboardPage(page));
},

/* Composed fixture: depends on two others; Playwright resolves the order */
authedPage: async ({ page, testUser, loginPage }, use) => {
await loginPage.goto();
await loginPage.login(testUser.email, testUser.password);
await expect(page.getByRole('heading', { name: 'Dashboard' })).toBeVisible();
await use(page);
},
});

export { expect };
`

Usage β€” clean, typed, zero boilerplate:
`ts
import { test, expect } from '../fixtures';

test('user sees their orders', async ({ authedPage, dashboardPage }) => {
await dashboardPage.openOrders();
await expect(authedPage.getByRole('table')).toBeVisible();
});
`

Points that score: worker-scoped accounts eliminate parallel data collisions; the dependency graph means you never manually order setup; teardown after use() is failure-safe.


Q39. What is an "automatic" fixture ({ auto: true }) and when do you use it?

Answer.

An auto fixture runs for every test, whether or not the test names it in its parameters.

`ts
export const test = base.extend<{ failureArtifacts: void; consoleGuard: void }>({

// Attach extra artifacts on failure
failureArtifacts: [async ({ page }, use, testInfo) => {
await use();
if (testInfo.status !== testInfo.expectedStatus) {
await testInfo.attach('final-dom', {
body: await page.content(),
contentType: 'text/html',
});
await testInfo.attach('local-storage', {
body: JSON.stringify(await page.evaluate(() => ({ ...localStorage })), null, 2),
contentType: 'application/json',
});
}
}, { auto: true }],

// Fail any test that produced a console error β€” catches silent JS regressions
consoleGuard: [async ({ page }, use) => {
const errors: string[] = [];
page.on('console', m => { if (m.type() === 'error') errors.push(m.text()); });
page.on('pageerror', e => errors.push(Uncaught: ${e.message}));

await use();

const ignorable = /favicon|ResizeObserver loop|third-party-cdn/;
const real = errors.filter(e => !ignorable.test(e));
expect(real, `Console errors detected:\n${real.join('\n')}`).toHaveLength(0);
Enter fullscreen mode Exit fullscreen mode

}, { auto: true }],
});
`

Classic uses: global network blocking, animation disabling, console-error guard, per-test tracing metadata, DB transaction rollback wrappers.

Caution: auto fixtures apply everywhere, so a heavy one (DB seed) silently taxes your entire suite. Keep them cheap or scope them via a separate test export used by only some files.


Q40. Explain globalSetup / globalTeardown vs worker-scoped fixtures. When would you use each?

Answer.

`ts
// playwright.config.ts
export default defineConfig({
globalSetup: require.resolve('./global-setup'),
globalTeardown: require.resolve('./global-teardown'),
});
`

`ts
// global-setup.ts
import { chromium, FullConfig, request } from '@playwright/test';

async function globalSetup(config: FullConfig) {
// Runs ONCE, before ALL workers, in its own Node process
await seedReferenceData();

const browser = await chromium.launch();
const page = await browser.newPage();
await page.goto(${config.projects[0].use.baseURL}/login);
await page.getByLabel('Email').fill(process.env.ADMIN_EMAIL!);
await page.getByLabel('Password').fill(process.env.ADMIN_PASS!);
await page.getByRole('button', { name: 'Sign in' }).click();
await page.context().storageState({ path: 'auth/admin.json' });
await browser.close();

// Return value becomes globalTeardown's argument
process.env.RUN_ID = crypto.randomUUID();
}
export default globalSetup;
`

globalSetup Worker-scoped fixture
Runs Once per entire run Once per worker
Isolation Shared by all workers Per worker
Trace/report integration ❌ Not traced, failures are cryptic βœ… Fully traced, appears in the report
Access to fixtures ❌ No βœ… Yes
Typed Weakly βœ… Strongly
Retries No Inherits worker restart semantics

Modern recommendation: prefer a setup project over globalSetup β€” you get tracing, retries, and reporting.

`ts
export default defineConfig({
projects: [
{ name: 'setup', testMatch: /global\.setup\.ts/ },
{
name: 'chromium',
use: { ...devices['Desktop Chrome'], storageState: 'auth/user.json' },
dependencies: ['setup'], // ← runs 'setup' project first
},
{ name: 'cleanup', testMatch: /global\.teardown\.ts/ },
],
});
`

`ts
// global.setup.ts
import { test as setup, expect } from '@playwright/test';

setup('authenticate as standard user', async ({ page }) => {
await page.goto('/login');
await page.getByLabel('Email').fill(process.env.USER_EMAIL!);
await page.getByLabel('Password').fill(process.env.USER_PASS!);
await page.getByRole('button', { name: 'Sign in' }).click();
await expect(page.getByRole('heading', { name: 'Dashboard' })).toBeVisible();
await page.context().storageState({ path: 'auth/user.json' });
});
`

Say this: "I moved off globalSetup because when login broke, the failure was an unreadable stack trace with no trace file. As a setup project it produces a normal test failure with a full trace β€” MTTR dropped from 40 minutes to 2."


Q41. How do you override a built-in fixture like page or context?

Answer.

`ts
export const test = base.extend({

// Override CONTEXT options declaratively
locale: 'en-IN',
timezoneId: 'Asia/Kolkata',
viewport: { width: 1440, height: 900 },
colorScheme: 'dark',
permissions: ['geolocation', 'clipboard-read'],
geolocation: { latitude: 12.9716, longitude: 77.5946 }, // Bengaluru

// Override the CONTEXT fixture entirely
context: async ({ browser }, use) => {
const context = await browser.newContext({
recordHar: { path: har/${Date.now()}.har, mode: 'minimal' },
ignoreHTTPSErrors: true,
});
await context.addInitScript(() => { window.E2E = true; });
await use(context);
await context.close();
},

// Override the PAGE fixture β€” wrap it with cross-cutting behaviour
page: async ({ page }, use) => {
await page.route('/analytics/', r => r.abort());
page.setDefaultTimeout(15_000);
page.on('pageerror', e => console.error('[PAGE ERROR]', e.message));
await use(page);
},
});
`

Option fixtures β€” parameterise your suite from config:
`ts
type Options = { userRole: 'admin' | 'editor' | 'viewer' };

export const test = base.extend({
userRole: ['viewer', { option: true }], // default value + marks it an option

authedPage: async ({ page, userRole }, use) => {
await loginAs(page, userRole);
await use(page);
},
});

ts
// playwright.config.ts
projects: [
{ name: 'admin', use: { userRole: 'admin' } },
{ name: 'editor', use: { userRole: 'editor' } },
{ name: 'viewer', use: { userRole: 'viewer' } },
],
`
Now the same test file runs three times, once per role, with zero duplication. This is the answer that demonstrates senior framework design.


Q42. What is testInfo and what can you do with it?

Answer.

`ts
test('example', async ({ page }, testInfo) => {
testInfo.title; // 'example'
testInfo.titlePath; // ['file.spec.ts', 'describe block', 'example']
testInfo.file; // absolute path
testInfo.line;
testInfo.project.name; // 'chromium'
testInfo.retry; // 0, 1, 2...
testInfo.workerIndex;
testInfo.parallelIndex;
testInfo.repeatEachIndex;
testInfo.status; // available in teardown: 'passed'|'failed'|'timedOut'|'skipped'
testInfo.expectedStatus;
testInfo.duration;
testInfo.errors; // all soft-assertion + hard errors so far
testInfo.annotations;
testInfo.tags; // ['@smoke']

testInfo.setTimeout(60_000);
testInfo.slow();
testInfo.fail();
testInfo.snapshotPath('baseline.png');
testInfo.outputPath('download.csv'); // per-test artifact dir, auto-cleaned

// Attach artifacts to the HTML report
await testInfo.attach('screenshot', { body: await page.screenshot(), contentType: 'image/png' });
await testInfo.attach('api-payload', { body: JSON.stringify(payload), contentType: 'application/json' });
await testInfo.attach('server-log', { path: '/var/log/app.log' });
});
`

Outside a test signature:
`ts
import { test } from '@playwright/test';
const info = test.info(); // works inside fixtures, steps, POM methods
`

Killer use case β€” per-test unique data derived from identity:
`ts
const uniqueEmail = `${test.info().parallelIndex}-${Date.now()}@test.io`;
`


Q43. How do fixtures resolve their execution order? What happens if two fixtures depend on each other?

Answer.

Playwright builds a directed acyclic graph (DAG) of fixture dependencies and resolves it automatically.

`plaintext
Setup order: worker fixtures β†’ auto fixtures β†’ dependencies β†’ dependents β†’ test
Teardown: exact REVERSE order
`

`ts
test.extend({
db: async ({}, use) => { console.log('1 db up'); await use(); console.log('6 db down'); },
user: async ({ db }, use) => { console.log('2 user'); await use(); console.log('5 user rm'); },
session: async ({ user }, use) => { console.log('3 sess'); await use(); console.log('4 sess rm'); },
});
`

`plaintext
1 db up β†’ 2 user β†’ 3 sess β†’ [TEST RUNS] β†’ 4 sess rm β†’ 5 user rm β†’ 6 db down
`

Circular dependencies are detected at load time and throw:
`plaintext
Error: Fixtures "a" -> "b" -> "a" form a dependency cycle
`

Rules to remember:

  1. A worker-scoped fixture can NOT depend on a test-scoped fixture. (Lifetime violation β€” the worker outlives the test.) This throws.
  2. A test-scoped fixture can depend on a worker-scoped one. βœ…
  3. beforeAll runs after worker fixtures; beforeEach runs after test fixtures. So fixtures are always available in hooks.
  4. Teardown runs even when the test fails or times out β€” but a fixture whose setup threw will not have its teardown run.

Q44. How do you share authentication state across tests efficiently?

Answer.

Do not log in through the UI in every test. That's 2–4 seconds Γ— N tests.

Pattern 1 β€” one shared state file (most common):
`ts
// auth.setup.ts
import { test as setup, expect } from '@playwright/test';
const authFile = 'playwright/.auth/user.json';

setup('authenticate', async ({ page }) => {
await page.goto('/login');
await page.getByLabel('Email').fill(process.env.USER_EMAIL!);
await page.getByLabel('Password').fill(process.env.USER_PASS!);
await page.getByRole('button', { name: 'Sign in' }).click();
await expect(page.getByRole('heading', { name: 'Dashboard' })).toBeVisible();
await page.context().storageState({ path: authFile });
});

ts
// playwright.config.ts
projects: [
{ name: 'setup', testMatch: /.*.setup.ts/ },
{ name: 'chromium', use: { ...devices['Desktop Chrome'], storageState: 'playwright/.auth/user.json' },
dependencies: ['setup'] },
],
`

Pattern 2 β€” multiple roles:
`ts
setup('auth as admin', async ({ page }) => { /*...*/ await page.context().storageState({ path: 'auth/admin.json' }); });
setup('auth as viewer', async ({ page }) => { /*...*/ await page.context().storageState({ path: 'auth/viewer.json' }); });
`

`ts
test.use({ storageState: 'auth/admin.json' }); // per-file
test.describe('admin only', () => {
test.use({ storageState: 'auth/admin.json' }); // per-block
});
`

Pattern 3 β€” API login (fastest; no browser needed):
`ts
setup('authenticate via API', async ({ request }) => {
const res = await request.post('/api/auth/login', {
data: { email: process.env.USER_EMAIL, password: process.env.USER_PASS },
});
expect(res.ok()).toBeTruthy();
await request.storageState({ path: 'playwright/.auth/user.json' });
});
`

Pattern 4 β€” per-worker auth (when accounts must be unique per worker):
`ts
workerStorageState: [async ({ browser }, use, workerInfo) => {
const file = path.resolve(.auth/worker-${workerInfo.workerIndex}.json);
if (fs.existsSync(file)) return use(file); // reuse across retries

const page = await browser.newPage({ storageState: undefined });
await loginAs(page, ACCOUNTS[workerInfo.workerIndex]);
await page.context().storageState({ path: file });
await page.close();
await use(file);
}, { scope: 'worker' }],
`

Must-mention gotchas:

  • storageState captures cookies + localStorage β€” not sessionStorage. If your app stores the token in sessionStorage, inject it via addInitScript.
  • Add playwright/.auth/ to .gitignore β€” these files contain live tokens.
  • Handle token expiry: regenerate per run, or check the JWT exp claim in the setup project.

Q45. How do you handle sessionStorage if storageState doesn't cover it?

Answer.

`ts
// 1. Capture it once (in the setup project)
const sessionStorageJson = await page.evaluate(() => JSON.stringify(sessionStorage));
fs.writeFileSync('auth/session.json', sessionStorageJson);

// 2. Inject it before the app boots, on every navigation
const stored = JSON.parse(fs.readFileSync('auth/session.json', 'utf-8'));
await context.addInitScript((data: Record) => {
for (const [k, v] of Object.entries(data)) window.sessionStorage.setItem(k, v);
}, stored);
`

Wrap it into a reusable fixture:
`ts
export const test = base.extend({
context: async ({ context }, use) => {
const session = JSON.parse(fs.readFileSync('auth/session.json', 'utf-8'));
await context.addInitScript((d: Record<string,string>) => {
Object.entries(d).forEach(([k, v]) => sessionStorage.setItem(k, v));
}, session);
await use(context);
},
});
`

Why addInitScript and not evaluate? evaluate runs after the page has loaded β€” by then the app has already read (empty) sessionStorage, redirected to /login, and your token arrives too late.


Q46. How do you seed and clean up test data reliably?

Answer.

Principles I state up front:

  1. Every test owns its data. No shared "automation@test.com".
  2. Seed via API, never UI. 50Γ— faster and doesn't fail because of an unrelated UI bug.
  3. Cleanup in fixture teardown, so it runs even on failure/timeout.
  4. Unique identifiers, so parallel workers can't collide.
  5. Idempotent teardown β€” deleting an already-deleted record must not throw.

`ts
type Fixtures = { order: Order };

export const test = base.extend({
order: async ({ apiClient }, use, testInfo) => {
const order = await apiClient.createOrder({
reference: E2E-${testInfo.parallelIndex}-${Date.now()},
items: [{ sku: 'SKU-991', qty: 2 }],
});

await use(order);

// Teardown β€” never let cleanup failure mask the real test failure
try {
  await apiClient.deleteOrder(order.id);
} catch (e) {
  console.warn(`Cleanup failed for order ${order.id}:`, e);
}
Enter fullscreen mode Exit fullscreen mode

},
});
`

Fallback when there's no delete API: tag records with a run ID and sweep them nightly.
`ts
// Every entity created gets reference: `E2E-${process.env.RUN_ID}-...`
// A scheduled job deletes anything matching E2E-* older than 24h.
`

Database-level isolation (best when you own the DB): wrap each test in a transaction and roll back in teardown. Not always possible with a remote app server, but worth naming as the ideal.


Q47. What's the difference between test.use() at file level, describe level, and in config?

Answer.

`ts
// 1. CONFIG level β€” applies to a whole project
export default defineConfig({
use: { baseURL: 'https://staging.app.com', viewport: { width: 1280, height: 720 } },
projects: [{ name: 'mobile', use: { ...devices['iPhone 14'] } }],
});

// 2. FILE level β€” applies to every test in the file
import { test } from '@playwright/test';
test.use({ storageState: 'auth/admin.json', locale: 'fr-FR' });

// 3. DESCRIBE level β€” applies to that block only
test.describe('mobile checkout', () => {
test.use({ ...devices['Pixel 7'], geolocation: { latitude: 12.97, longitude: 77.59 } });
test('...', async ({ page }) => {});
});
`

Precedence (most specific wins): describe > file > project > global use > Playwright defaults.

Hard rule: test.use() cannot be called inside a test() body β€” options are resolved before the test runs. Throws:
`typescript
Error: test.use() can only be called in a describe block or at the top level
`

To change behaviour mid-test you need a new context:
`ts
test('compare desktop and mobile', async ({ browser }) => {
const mobile = await browser.newContext({ ...devices['iPhone 14'] });
const page = await mobile.newPage();
// ...
await mobile.close();
});
`


Q48. How do you run the same test against multiple datasets (data-driven testing)?

Answer.

`ts
// 1. Simple loop β€” generates N independent tests
const cases = [
{ role: 'admin', canDelete: true },
{ role: 'editor', canDelete: true },
{ role: 'viewer', canDelete: false },
] as const;

for (const { role, canDelete } of cases) {
test(${role} delete permission is ${canDelete}, async ({ page }) => {
await loginAs(page, role);
await page.goto('/documents/1');
const btn = page.getByRole('button', { name: 'Delete' });
canDelete ? await expect(btn).toBeEnabled() : await expect(btn).toBeHidden();
});
}
`

`ts
// 2. From an external CSV/JSON β€” typed
import { parse } from 'csv-parse/sync';
import fs from 'node:fs';

type Row = { username: string; password: string; expectedError: string };
const rows: Row[] = parse(fs.readFileSync('./data/login.csv'), { columns: true, skip_empty_lines: true });

test.describe('login validation', () => {
for (const row of rows) {
test(rejects "${row.username}" with "${row.expectedError}", async ({ page, loginPage }) => {
await loginPage.goto();
await loginPage.login(row.username, row.password);
await expect(page.getByRole('alert')).toHaveText(row.expectedError);
});
}
});
`

`ts
// 3. Faker for generated data
import { faker } from '@faker-js/faker';
const user = {
email: faker.internet.email(),
name: faker.person.fullName(),
phone: faker.phone.number('+91 ##########'),
};
`

Important detail interviewers probe: the loop must be outside test() so N distinct tests are registered β€” each retryable, reportable, and parallelisable independently. Looping inside a single test gives you one giant test that stops at the first failure.

`ts
// ❌ one test, stops at first failure, useless report
test('all logins', async ({ page }) => {
for (const row of rows) { /* ... */ }
});
`

repeatEach for stability soak-testing:
`bash
npx playwright test --repeat-each=20 --grep @flaky-suspect
`


SECTION 5 β€” Configuration, Projects & Parallelism (Q49–Q58)


Q49. Walk me through a production-grade playwright.config.ts.

Answer.

`ts
import { defineConfig, devices } from '@playwright/test';
import dotenv from 'dotenv';
import path from 'node:path';

dotenv.config({ path: path.resolve(__dirname, .env.${process.env.ENV ?? 'staging'}) });

const IS_CI = !!process.env.CI;

export default defineConfig({
testDir: './tests',
outputDir: './test-results',
snapshotPathTemplate: '{testDir}/screenshots/{projectName}/{testFilePath}/{arg}{ext}',

/* ---- Execution ---- */
fullyParallel: true,
workers: IS_CI ? 4 : '50%',
retries: IS_CI ? 2 : 0,
maxFailures: IS_CI ? 20 : 0,
forbidOnly: IS_CI,
timeout: 60_000,
globalTimeout: IS_CI ? 45 * 60_000 : undefined,
expect: {
timeout: 10_000,
toHaveScreenshot: { maxDiffPixelRatio: 0.01, animations: 'disabled', scale: 'css' },
},

/* ---- Reporting ---- */
reporter: IS_CI
? [
['blob'],
['github'],
['junit', { outputFile: 'results/junit.xml' }],
['json', { outputFile: 'results/results.json' }],
['./reporters/slack-reporter.ts'],
]
: [['html', { open: 'never' }], ['list']],

/* ---- Shared context options ---- */
use: {
baseURL: process.env.BASE_URL,
trace: 'on-first-retry',
screenshot: 'only-on-failure',
video: 'retain-on-failure',
actionTimeout: 15_000,
navigationTimeout: 30_000,
locale: 'en-IN',
timezoneId: 'Asia/Kolkata',
viewport: { width: 1440, height: 900 },
ignoreHTTPSErrors: true,
testIdAttribute: 'data-qa',
extraHTTPHeaders: { 'x-e2e-run': process.env.RUN_ID ?? 'local' },
},

/* ---- Projects ---- /
projects: [
{ name: 'setup', testMatch: /.
.setup.ts/, teardown: 'cleanup' },
{ name: 'cleanup', testMatch: /.*.teardown.ts/ },

{ name: 'api', testMatch: /api\/.*\.spec\.ts/ },   // no browser needed

{
  name: 'chromium',
  use: { ...devices['Desktop Chrome'], storageState: 'playwright/.auth/user.json' },
  dependencies: ['setup'],
  testIgnore: /api\//,
},
{
  name: 'firefox',
  use: { ...devices['Desktop Firefox'], storageState: 'playwright/.auth/user.json' },
  dependencies: ['setup'],
  grep: /@cross-browser/,          // only critical tests cross-browser
},
{
  name: 'webkit',
  use: { ...devices['Desktop Safari'], storageState: 'playwright/.auth/user.json' },
  dependencies: ['setup'],
  grep: /@cross-browser/,
},
{
  name: 'mobile-chrome',
  use: { ...devices['Pixel 7'], storageState: 'playwright/.auth/user.json' },
  dependencies: ['setup'],
  grep: /@mobile/,
},
Enter fullscreen mode Exit fullscreen mode

],

/* ---- Local dev server ---- */
webServer: IS_CI ? undefined : {
command: 'npm run dev',
url: 'http://localhost:3000',
reuseExistingServer: true,
timeout: 120_000,
stdout: 'ignore',
stderr: 'pipe',
},
});
`

Talk through the why, not the keys β€” that's what separates a memoriser from an engineer. Key justifications: trace: 'on-first-retry' balances debuggability against artifact size; browser matrix is filtered by grep so cross-browser cost is bounded; setup/cleanup projects replace globalSetup for traceability.


Q50. What are "projects" in Playwright and give three non-browser uses for them.

Answer.

A project is a named, independently-configured run of a test set. Most people only use them for browsers β€” senior engineers use them for orchestration.

1. Dependency chains (setup β†’ test β†’ cleanup):
`ts
projects: [
{ name: 'setup', testMatch: /.*\.setup\.ts/, teardown: 'cleanup' },
{ name: 'cleanup', testMatch: /.*\.teardown\.ts/ },
{ name: 'e2e', dependencies: ['setup'] },
]
`

teardown runs after all projects depending on setup have finished β€” perfect for tearing down seeded environments.

2. Test pyramid layering with different configs:
`ts
projects: [
{ name: 'api', testDir: './tests/api', timeout: 15_000, retries: 0 },
{ name: 'component', testDir: './tests/component', timeout: 20_000 },
{ name: 'e2e-smoke', testDir: './tests/e2e', grep: /@smoke/, retries: 1 },
{ name: 'e2e-full', testDir: './tests/e2e', grepInvert: /@smoke/, retries: 2 },
]
`

`bash
npx playwright test --project=api --project=e2e-smoke # PR gate: 4 minutes
npx playwright test # nightly: everything
`

3. Parameterising the same tests across roles / tenants / locales:
`ts
type Opts = { tenant: string };
projects: [
{ name: 'tenant-acme', use: { tenant: 'acme', baseURL: 'https://acme.app.com' } },
{ name: 'tenant-globex', use: { tenant: 'globex', baseURL: 'https://globex.app.com' } },
]
`

One test file, N tenants, zero duplication. Reported separately so you can see "tenant-globex is failing" at a glance.

Also worth naming: dependencies guarantees order between projects but each project still runs fully parallel internally.


🎁 HALFWAY THERE β€” KEEP THE MOMENTUM GOING

MEGA SALE β€” Flat 90% OFF on ALL 100+ eBooks & Bundles

Coupon code: JUPITER90

πŸ“š HimanshuAI Digital Playbook Store
πŸ‘‰ https://himanshuai.gumroad.com/

Playwright Β· TypeScript Β· AI Testing Β· Salesforce Β· API Testing Β· Java Β· Python Β· Cypress Β· Selenium Β· System Design

Build your complete technical library at a fraction of the regular price.


Q51. How does sharding work and how is it different from workers?

Answer.

Workers Shards
Level Processes on one machine Independent machines / CI jobs
Config workers: 4 --shard=1/4
Shares CPU, RAM, disk of one box Nothing
Reports One report N reports β†’ must be merged

`bash

Machine 1

npx playwright test --shard=1/4 --reporter=blob

Machine 2

npx playwright test --shard=2/4 --reporter=blob

... then on a final job:

npx playwright merge-reports --reporter=html ./all-blob-reports
`

GitHub Actions matrix:
`yaml
jobs:
test:
strategy:
fail-fast: false
matrix:
shard: [1, 2, 3, 4]
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with: { node-version: 20, cache: npm }
- run: npm ci
- run: npx playwright install --with-deps chromium
- run: npx playwright test --shard=${{ matrix.shard }}/4 --reporter=blob
- uses: actions/upload-artifact@v4
if: always()
with:
name: blob-report-${{ matrix.shard }}
path: blob-report/
retention-days: 3

merge:
if: always()
needs: [test]
steps:
- uses: actions/download-artifact@v4
with: { path: all-blob-reports, pattern: blob-report-*, merge-multiple: true }
- run: npx playwright merge-reports --reporter=html ./all-blob-reports
- uses: actions/upload-artifact@v4
with: { name: html-report, path: playwright-report/ }
`

Sharding strategy nuance: Playwright shards by test count, not duration β€” so a shard full of slow tests becomes the long pole. Since v1.44 you can pass --shard with duration data from a previous run's JSON report to balance better, or manually split slow suites into their own project. Mentioning this imbalance problem unprompted is a strong senior signal.


Q52. What is webServer in the config and what problems does it solve?

Answer.

`ts
webServer: [
{
command: 'npm run start:api',
url: 'http://localhost:4000/health',
reuseExistingServer: !process.env.CI,
timeout: 120_000,
stdout: 'pipe',
stderr: 'pipe',
env: { NODE_ENV: 'test', DB_URL: process.env.TEST_DB_URL! },
gracefulShutdown: { signal: 'SIGTERM', timeout: 5000 },
},
{
command: 'npm run start:web',
url: 'http://localhost:3000',
reuseExistingServer: !process.env.CI,
},
]
`

What it solves:

  • Playwright starts the app, waits for the URL to respond, runs tests, then kills the process. No wait-on, no concurrently, no sleep-and-pray in your npm scripts.
  • reuseExistingServer: true locally means you don't restart your dev server on every run (huge DX win); false in CI guarantees a clean process.
  • Multiple entries let you boot a whole stack (API + web + mock server).
  • url vs port: url lets you point at a health endpoint, which is far more reliable than "the port is open."

Gotcha: if the command exits immediately (e.g. docker compose up without -d behaves differently than expected, or your script backgrounds itself), Playwright reports the server as dead. Use a foreground process.


Q53. How do you manage environments (dev/staging/prod) and secrets?

Answer.

`properties
.env.local # gitignored, developer overrides
.env.dev
.env.staging
.env.prod # NO secrets committed β€” CI injects them
`

`ts
import dotenv from 'dotenv';
const ENV = process.env.TEST_ENV ?? 'staging';
dotenv.config({ path: `.env.${ENV}` });
dotenv.config({ path: '.env.local', override: true });
`

Validate env at load time β€” fail fast with a readable message instead of a mysterious undefined in a URL:
`ts
// config/env.ts
import { z } from 'zod';

const schema = z.object({
BASE_URL: z.string().url(),
API_URL: z.string().url(),
USER_EMAIL: z.string().email(),
USER_PASSWORD: z.string().min(8),
TEST_ENV: z.enum(['dev', 'staging', 'prod']).default('staging'),
});

export const env = schema.parse(process.env);
// Now env.BASE_URL is a typed string, guaranteed present
`

Secrets policy to state in the interview:

  1. Secrets never in the repo β€” CI secret store (GitHub Secrets, Vault, AWS Secrets Manager) only.
  2. Add .env* (except .env.example) to .gitignore; commit a .env.example with keys and dummy values.
  3. Run gitleaks / trufflehog as a pre-commit hook and a CI gate.
  4. Mask secrets in logs β€” core.setSecret() in Actions.
  5. Distinct low-privilege test accounts per environment; never reuse a real user's credentials.
  6. Production safety guard: `ts // global.setup.ts if (env.TEST_ENV === 'prod' && !process.env.ALLOW_PROD_RUN) { throw new Error('Refusing to run destructive suite against PROD. Set ALLOW_PROD_RUN=1.'); } `

Q54. What browsers does Playwright support and what exactly is it running?

Answer.

Engine What Playwright ships Real-world coverage
Chromium Patched Chromium build Chrome, Edge, Brave, Opera
Firefox Patched Firefox (Juggler protocol) Firefox
WebKit WebKit build Safari (macOS + iOS)

Crucially, these are Playwright's own builds, not your installed browsers. That's the point β€” the browser version is pinned to the Playwright version, so npm ci gives every dev and every CI runner byte-identical browsers. No chromedriver mismatch, ever.

Running real branded browsers when you must:
`ts
projects: [
{ name: 'chrome', use: { ...devices['Desktop Chrome'], channel: 'chrome' } },
{ name: 'edge', use: { ...devices['Desktop Edge'], channel: 'msedge' } },
{ name: 'chrome-beta', use: { channel: 'chrome-beta' } },
]
`

Use channel when you need proprietary bits: DRM/Widevine playback, proprietary codecs (H.264/AAC in some builds), or enterprise policy behaviour.

Mobile: Playwright emulates β€” viewport, user agent, device scale factor, touch events, isMobile. It does not run a real iOS Safari or a real Android WebView.
`ts
use: { ...devices['iPhone 14 Pro'] }
`

Say this honestly in interviews: "Playwright's mobile support is emulation, which catches responsive-layout and touch-handler bugs but not device-specific rendering, real network conditions, or OS-level gesture behaviour. For genuine device coverage I'd pair it with BrowserStack/LambdaTest running the same Playwright specs against real devices."

`bash
npx playwright install # all browsers
npx playwright install chromium # one
npx playwright install --with-deps # + Linux system libs (CI)
npx playwright install --only-shell # headless shell only, smaller CI image
`


Q55. Headless vs headed vs --ui mode β€” what's the difference and when do you use each?

Answer.

`bash
npx playwright test # headless (default) β€” fastest, CI standard
npx playwright test --headed # visible browser
npx playwright test --ui # UI Mode: watch, time-travel, pick-locator
npx playwright test --debug # Playwright Inspector, step-by-step
`

UI Mode is the one to enthuse about β€” it's the biggest DX difference vs Selenium:

  • Watch mode: re-runs affected tests on file save
  • Full timeline with DOM snapshots per action (Trace Viewer embedded, live)
  • Pick locator tool: click an element in the snapshot, get the recommended getByRole(...) string
  • Filter by project/tag/status
  • Toggle individual tests without editing code

Headless caveats to mention:

  • Since v1.49 Chromium's headless mode is the new headless (same binary as headed), so headless-vs-headed rendering differences have largely disappeared. Older chromium --headless=old had real differences.
  • Remaining differences: no GPU acceleration by default, different default window chrome, some window.outerHeight values.
  • If a test passes headed but fails headless, the usual culprits are viewport size, missing fonts in the container, or an animation timing difference β€” not "headless is broken."

Q56. How do you run tests in Docker and what are the common pitfalls?

Answer.

`dockerfile
FROM mcr.microsoft.com/playwright:v1.55.0-jammy

WORKDIR /app
COPY package*.json ./
RUN npm ci
COPY . .

Non-root for security; the base image ships a 'pwuser'

USER pwuser
CMD ["npx", "playwright", "test"]
`

Always pin the image tag to your Playwright version. A mismatch between the npm package and the image's bundled browsers is the #1 Docker failure:
`plaintext
Error: Executable doesn't exist at /ms-playwright/chromium-1148/chrome-linux/chrome
`

Pitfalls and fixes:

Pitfall Fix
Chromium crashes: "Target closed" --shm-size=2gb (default /dev/shm is 64 MB) or --ipc=host
Screenshots differ from local macOS baselines Generate baselines inside this exact image
Missing fonts β†’ tofu boxes in screenshots RUN apt-get install -y fonts-noto-color-emoji fonts-noto-cjk
Running as root warnings USER pwuser
Huge image Use -noble/-jammy slim variants; --only-shell if you only need headless Chromium
Tests hang in CI Set workers explicitly; containers over-report os.cpus()

`bash
docker run --rm --ipc=host --shm-size=2gb \
-v $(pwd)/playwright-report:/app/playwright-report \
-e BASE_URL=https://staging.app.com \
my-e2e-image
`

Docker Compose with the app under test:
`yaml
services:
app:
build: .
healthcheck:
test: ["CMD", "curl", "-f", "http://localhost:3000/health"]
interval: 5s
retries: 20
e2e:
image: my-e2e-image
ipc: host
depends_on:
app: { condition: service_healthy }
environment:
BASE_URL: http://app:3000
`


Q57. How do you decide the browser matrix? Isn't running everything on every browser wasteful?

Answer.

Yes β€” and saying so is the right answer.

My tiering strategy:

Tier Trigger Scope Target time
Smoke @smoke Every PR Chromium only, ~30 critical paths < 4 min
Regression Merge to main Chromium, full suite < 20 min
Cross-browser @cross-browser Nightly Chromium + WebKit + Firefox, critical paths only < 30 min
Mobile @mobile Nightly Pixel 7 + iPhone 14, responsive-sensitive flows < 15 min
Visual @visual Nightly + on design PRs Chromium, component screenshots < 10 min

Justification I give:

"Browser bugs are real but rare, and they cluster in CSS layout, date/number formatting, and newer JS APIs β€” not in business logic. Running 2,000 tests Γ— 3 browsers costs 3Γ— the CI spend to re-verify the same backend behaviour three times. So I run the full suite on Chromium, and I run WebKit on the flows most likely to break β€” Safari has the most divergent rendering and is a large share of real traffic on mobile. I drive the decision with real analytics data: if 0.4% of our users are on Firefox, Firefox gets nightly, not per-PR."

The 'shift-left' complement: most cross-browser issues are better caught by linting (browserslist + eslint-plugin-compat) and by Percy/Chromatic-style visual diffs than by running the entire functional suite three times.


Q58. How do you speed up a slow suite? Give concrete numbers.

Answer.

My prioritised playbook (real impact ranges from suites I've optimised):

# Lever Typical gain
1 Replace UI login with storageState 30–50%
2 API-based data setup instead of UI setup 25–40%
3 Delete/merge redundant tests (coverage audit) 15–30%
4 Increase workers + shard across CI machines Near-linear
5 Block images/fonts/analytics on non-visual tests 10–20%
6 Remove every waitForTimeout 5–25%
7 Push assertions down the pyramid (unit/API instead of E2E) Large, structural
8 --only-changed / affected-test selection on PRs 60–90% on PRs
9 Reuse browser, not context (already default) β€”
10 Trim trace: 'on' β†’ 'on-first-retry' I/O + upload time

`bash

Find your slow tests first β€” never optimise blind

npx playwright test --reporter=json | jq '.suites[].specs[] | {title, duration: .tests[0].results[0].duration}' | sort -k2 -rn | head -20
`

`ts
// Resource blocking fixture β€” measurable win
await page.route('**/*.{png,jpg,jpeg,webp,gif,svg,woff,woff2,ttf}', r => r.abort());
`

The structural answer that impresses most:

"The biggest win isn't making E2E faster β€” it's having fewer E2E tests. I audited a 1,400-test suite and found 60% were asserting validation rules and calculations that belonged in unit or API tests. We moved 800 assertions down the pyramid, kept 550 true user-journey E2E tests, and the suite went from 78 minutes to 11 β€” while increasing total coverage, because unit tests could cover edge cases E2E never could."


SECTION 6 β€” Network Interception, Mocking & API Testing (Q59–Q70)


Q59. Explain page.route() in depth. What are fulfill, continue, abort, and fallback?

Answer.

`ts
await page.route('**/api/users', async (route, request) => {
// 1. FULFILL β€” respond without hitting the network
await route.fulfill({
status: 200,
contentType: 'application/json',
headers: { 'x-mocked': 'true' },
body: JSON.stringify([{ id: 1, name: 'Ana' }]),
// or: path: './fixtures/users.json'
// or: json: [{ id: 1, name: 'Ana' }] // shorthand, sets content-type
});

// 2. CONTINUE β€” let it through, optionally MODIFYING the request
await route.continue({
url: 'https://other-host/api/users',
method: 'POST',
headers: { ...request.headers(), authorization: 'Bearer test-token' },
postData: JSON.stringify({ page: 2 }),
});

// 3. ABORT β€” simulate a network failure
await route.abort('failed');
// reasons: 'aborted'|'accessdenied'|'addressunreachable'|'blockedbyclient'|
// 'blockedbyresponse'|'connectionaborted'|'connectionclosed'|
// 'connectionfailed'|'connectionrefused'|'connectionreset'|
// 'internetdisconnected'|'namenotresolved'|'timedout'|'failed'

// 4. FALLBACK β€” defer to the NEXT registered handler
await route.fallback();
});
`

route.fetch() β€” the powerful middle path. Hit the real API, then mutate the real response:
`ts
await page.route('**/api/orders', async route => {
const response = await route.fetch(); // real network call
const json = await response.json();
json.orders = json.orders.map((o: any) => ({ ...o, status: 'OVERDUE' }));
await route.fulfill({ response, json }); // real headers + patched body
});
`

This is ideal for testing rare states (an overdue order, a 90-day-old invoice) without corrupting the database.

Handler ordering β€” the gotcha: routes are matched last-registered-first (LIFO). The most recently added handler wins; use route.fallback() to chain.

`ts
await page.route('**/api/**', r => { console.log('generic'); r.fallback(); }); // registered 1st
await page.route('**/api/users', r => r.fulfill({ json: [] })); // registered 2nd β€” runs FIRST
`

Scope: page.route() (one page) vs context.route() (all pages in the context, including popups) vs browser β€” no browser-level routing.

Cleanup: await page.unroute('**/api/users') or await page.unrouteAll({ behavior: 'ignoreErrors' }).


Q60. How do you mock API responses and why is it valuable in E2E tests?

Answer.

`ts
// fixtures/mock.ts
export async function mockApi(page: Page, url: string, json: unknown, status = 200) {
await page.route(url, route => route.fulfill({ status, json }));
}
`

Test the states you cannot reliably create:
`ts
test('shows empty state when user has no orders', async ({ page }) => {
await mockApi(page, '**/api/orders', { orders: [], total: 0 });
await page.goto('/orders');
await expect(page.getByText('You have no orders yet')).toBeVisible();
await expect(page.getByRole('link', { name: 'Start shopping' })).toBeVisible();
});

test('shows retry UI when the orders API fails', async ({ page }) => {
await mockApi(page, '**/api/orders', { error: 'Internal Server Error' }, 500);
await page.goto('/orders');
await expect(page.getByRole('alert')).toContainText('Something went wrong');
await expect(page.getByRole('button', { name: 'Retry' })).toBeVisible();
});

test('shows offline banner when the network drops', async ({ page }) => {
await page.goto('/orders');
await page.route('/api/', route => route.abort('internetdisconnected'));
await page.getByRole('button', { name: 'Refresh' }).click();
await expect(page.getByText('You appear to be offline')).toBeVisible();
});

test('shows skeleton loader during a slow response', async ({ page }) => {
await page.route('**/api/orders', async route => {
await new Promise(r => setTimeout(r, 3000));
await route.fulfill({ json: { orders: [] } });
});
await page.goto('/orders');
await expect(page.getByTestId('skeleton')).toBeVisible();
});

test('handles pagination boundary of exactly 1000 records', async ({ page }) => {
await mockApi(page, '*/api/orders', {
orders: Array.from({ length: 100 }, (_, i) => ({ id: i, ref: INV-${i} })),
total: 1000, page: 10, pages: 10,
});
await page.goto('/orders?page=10');
await expect(page.getByRole('button', { name: 'Next' })).toBeDisabled();
});
`

The value proposition to articulate:

  1. Determinism β€” no dependence on backend uptime or data drift.
  2. Coverage of unreachable states β€” 500s, timeouts, empty sets, boundary values, rate limits.
  3. Speed β€” no network latency, no DB writes.
  4. Frontend/backend decoupling β€” the UI suite runs even when the API team's environment is down.

The essential caveat that shows judgement:

"Mocking everything means you've tested your mocks, not your system. My rule: at least one unmocked end-to-end journey per critical flow validates real integration, and mocks cover the edge states around it. I also guard against mock drift by generating mock fixtures from the OpenAPI schema and failing CI when the schema changes β€” or by using contract tests (Pact) so a mock that no longer matches the real API breaks the build."


Q61. What is HAR recording/replay and when would you use it?

Answer.

`ts
// RECORD β€” capture every request/response into a HAR file
const context = await browser.newContext({
recordHar: { path: './har/orders.har', mode: 'full', urlFilter: '/api/' },
});
// ... exercise the app ...
await context.close(); // HAR is written on close

// REPLAY β€” serve responses from the HAR, no network
await context.routeFromHAR('./har/orders.har', {
url: '/api/',
update: false, // true = re-record and refresh the HAR
notFound: 'abort', // or 'fallback' to hit the real network for misses
});
`

Per-test:
`ts
test.use({ recordHar: { path: 'har/test.har', mode: 'minimal' } });
`

`ts
await page.routeFromHAR('./har/checkout.har');
`

Modes: 'full' records everything including response bodies; 'minimal' records only what's needed for replay (much smaller).

When HAR is the right tool:

  • Legacy apps with 80+ endpoints where hand-writing mocks is infeasible β€” record once, replay forever.
  • Reproducing a production incident β€” a customer's HAR file becomes an automated regression test.
  • Offline/air-gapped CI where the backend isn't reachable.
  • Third-party API isolation β€” record a real payment gateway response once, replay it deterministically.

Refresh workflow: update: true re-records against the real API. Run it on a schedule; a git diff on the HAR becomes an early-warning signal that the backend contract changed.

Downsides to mention: HARs are large, contain secrets and PII (scrub tokens before committing), and go stale silently.


Q62. How do you do pure API testing with Playwright's request fixture?

Answer.

`ts
import { test, expect } from '@playwright/test';

test.describe('Orders API', () => {
test('creates an order', async ({ request }) => {
const response = await request.post('/api/orders', {
data: { customerId: 'C-100', items: [{ sku: 'SKU-991', qty: 2 }] },
headers: { Authorization: Bearer ${process.env.TOKEN} },
});

expect(response.status()).toBe(201);
await expect(response).toBeOK();                 // 200–299
expect(response.headers()['content-type']).toContain('application/json');

const body = await response.json();
expect(body).toMatchObject({ status: 'PENDING', total: expect.any(Number) });
expect(body.id).toMatch(/^ORD-\d{6}$/);
Enter fullscreen mode Exit fullscreen mode

});

test('rejects an invalid payload', async ({ request }) => {
const res = await request.post('/api/orders', { data: { items: [] } });
expect(res.status()).toBe(422);
expect((await res.json()).errors).toContainEqual(
expect.objectContaining({ field: 'customerId' })
);
});
});
`

All the request methods:
`ts
await request.get(url, { params: { page: 2, q: 'test' } });
await request.post(url, { data: {} }); // JSON
await request.post(url, { form: { a: 'b' } }); // urlencoded
await request.post(url, { multipart: { file: { name:'a.csv', mimeType:'text/csv', buffer } } });
await request.put(url, { data });
await request.patch(url, { data });
await request.delete(url);
await request.head(url);
await request.fetch(url, { method: 'OPTIONS', maxRedirects: 0, timeout: 10_000, failOnStatusCode: true });
`

Response API:
`ts
response.status(); response.statusText(); response.ok(); response.url();
response.headers(); response.headersArray();
await response.json(); await response.text(); await response.body(); // Buffer
`

A standalone, authenticated API context (worker fixture):
`ts
apiContext: [async ({ playwright }, use) => {
const ctx = await playwright.request.newContext({
baseURL: process.env.API_URL,
extraHTTPHeaders: { Authorization: `Bearer ${process.env.TOKEN}` },
ignoreHTTPSErrors: true,
timeout: 20_000,
});
await use(ctx);
await ctx.dispose();
}, { scope: 'worker' }],
`

Schema validation with Zod (this is the senior-level addition):
`ts
import { z } from 'zod';

const OrderSchema = z.object({
id: z.string().regex(/^ORD-\d{6}$/),
status: z.enum(['PENDING', 'PAID', 'SHIPPED', 'CANCELLED']),
total: z.number().positive(),
createdAt: z.string().datetime(),
items: z.array(z.object({ sku: z.string(), qty: z.number().int().positive() })).min(1),
});
type Order = z.infer;

const order: Order = OrderSchema.parse(await response.json());
// Now order is fully typed AND runtime-validated. Contract drift fails loudly.
`


Q63. How do you combine API and UI in a single test? Give the canonical pattern.

Answer.

The rule: set up and tear down via API; assert only the behaviour under test through the UI.

`ts
test('user can cancel a pending order from the UI', async ({ page, request }) => {
// ── ARRANGE (API β€” fast, deterministic) ──────────────────────
const order = await test.step('Seed a pending order', async () => {
const res = await request.post('/api/orders', {
data: { customerId: 'C-100', items: [{ sku: 'SKU-991', qty: 1 }] },
});
expect(res.ok()).toBeTruthy();
return await res.json();
});

// ── ACT (UI β€” the actual thing under test) ───────────────────
await page.goto(/orders/${order.id});
await page.getByRole('button', { name: 'Cancel order' }).click();
await page.getByRole('dialog').getByRole('button', { name: 'Yes, cancel' }).click();

// ── ASSERT (UI) ──────────────────────────────────────────────
await expect(page.getByTestId('order-status')).toHaveText('Cancelled');
await expect(page.getByRole('button', { name: 'Cancel order' })).toBeHidden();

// ── ASSERT (API β€” verify the backend actually persisted it) ──
await expect.poll(async () => {
const res = await request.get(/api/orders/${order.id});
return (await res.json()).status;
}, { timeout: 10_000 }).toBe('CANCELLED');
});
`

Why the double assertion matters: a UI that optimistically renders "Cancelled" while the API call silently 500s will pass a UI-only test. Verifying persistence via API catches that class of bug β€” and it's the kind of nuance interviewers use to distinguish 5-year from 12-year candidates.

Sharing cookies between page and request: the built-in request fixture shares the context's cookie jar when you use page.request β€” so an authenticated UI session is already authenticated for API calls:
`ts
const res = await page.request.get('/api/me'); // uses the page's cookies
`


Q64. How do you test WebSockets and Server-Sent Events?

Answer.

`ts
// Observe WebSocket traffic
page.on('websocket', ws => {
console.log(`WS opened: ${ws.url()}`);
ws.on('framesent', f => console.log('β†’', f.payload));
ws.on('framereceived', f => console.log('←', f.payload));
ws.on('close', () => console.log('WS closed'));
ws.on('socketerror', e => console.error('WS error', e));
});
`

Assert on a specific frame:
`ts
test('order status streams live over WebSocket', async ({ page }) => {
const framePromise = new Promise(resolve => {
page.on('websocket', ws =>
ws.on('framereceived', f => {
const msg = JSON.parse(f.payload.toString());
if (msg.type === 'ORDER_UPDATED') resolve(f.payload.toString());
})
);
});

await page.goto('/orders/1042');
await triggerBackendStatusChange('1042', 'SHIPPED');

const frame = await framePromise;
expect(JSON.parse(frame).status).toBe('SHIPPED');
await expect(page.getByTestId('status')).toHaveText('Shipped'); // and the UI reacted
});
`

Mock the WebSocket entirely (v1.48+ routeWebSocket):
`ts
await page.routeWebSocket('wss://api.example.com/live', ws => {
ws.onMessage(message => {
if (message === 'subscribe:orders') {
ws.send(JSON.stringify({ type: 'ORDER_UPDATED', id: '1042', status: 'SHIPPED' }));
}
});
});
`

This is enormous for testing real-time UIs deterministically β€” no backend, no race, no waiting.

SSE: intercept via page.route and stream a body:
`ts
await page.route('**/api/events', route =>
route.fulfill({
status: 200,
headers: { 'content-type': 'text/event-stream', 'cache-control': 'no-cache' },
body: 'data: {"type":"PING"}\n\ndata: {"type":"ORDER_UPDATED","status":"SHIPPED"}\n\n',
})
);
`


Q65. How do you assert on outgoing requests (e.g., verify analytics payload or a correct API call)?

Answer.

`ts
test('checkout sends the correct purchase event', async ({ page }) => {
const events: any[] = [];

await page.route('**/analytics/track', async route => {
events.push(route.request().postDataJSON());
await route.fulfill({ status: 204, body: '' });
});

await completeCheckout(page);

expect(events).toContainEqual(
expect.objectContaining({
event: 'purchase',
properties: expect.objectContaining({ orderId: expect.any(String), value: 12400, currency: 'INR' }),
})
);
});
`

Inspect the request without intercepting:
`ts
const requestPromise = page.waitForRequest(r =>
r.url().includes('/api/orders') && r.method() === 'POST'
);
await page.getByRole('button', { name: 'Place order' }).click();
const request = await requestPromise;

expect(request.postDataJSON()).toMatchObject({ items: [{ sku: 'SKU-991', qty: 2 }] });
expect(request.headers()['content-type']).toBe('application/json');
expect(request.headers()['authorization']).toMatch(/^Bearer /);
`

Full Request API worth knowing:
`ts
request.url(); request.method(); request.resourceType();
request.headers(); await request.allHeaders(); await request.headerValue('accept');
request.postData(); request.postDataJSON(); request.postDataBuffer();
request.isNavigationRequest(); request.frame(); request.redirectedFrom();
await request.response(); await request.sizes(); request.timing();
request.failure(); // { errorText } if it failed
`

Negative assertion β€” verify something was NOT sent (e.g., GDPR: no tracking before consent):
`ts
const trackingCalls: string[] = [];
await page.route('/{analytics,gtm,segment}/', r => { trackingCalls.push(r.request().url()); r.abort(); });

await page.goto('/');
await expect(page.getByRole('dialog', { name: 'Cookie preferences' })).toBeVisible();
expect(trackingCalls, 'No tracking should fire before consent').toHaveLength(0);

await page.getByRole('button', { name: 'Accept all' }).click();
await expect.poll(() => trackingCalls.length).toBeGreaterThan(0);
`


Q66. How do you simulate slow networks, offline mode, and throttling?

Answer.

`ts
// Offline β€” context level
await context.setOffline(true);
await expect(page.getByText('You are offline')).toBeVisible();
await context.setOffline(false);

// Per-route latency
await page.route('/api/', async route => {
await new Promise(r => setTimeout(r, 2000));
await route.continue();
});

// Real bandwidth/latency throttling via CDP (Chromium only)
const client = await context.newCDPSession(page);
await client.send('Network.enable');
await client.send('Network.emulateNetworkConditions', {
offline: false,
latency: 400, // ms RTT
downloadThroughput: 400 * 1024 / 8, // 400 kbps β†’ bytes/sec (Slow 3G)
uploadThroughput: 400 * 1024 / 8,
connectionType: 'cellular3g',
});

// CPU throttling β€” surfaces jank and render-blocking bugs
await client.send('Emulation.setCPUThrottlingRate', { rate: 4 }); // 4Γ— slower
`

Realistic use cases:

  • Verify skeleton loaders and spinners actually appear (they're invisible on fast local networks)
  • Verify request timeouts and retry logic
  • Verify PWA offline behaviour / service worker cache
  • Verify optimistic UI rolls back correctly when the request eventually fails

`ts
test('retries and recovers from a transient 503', async ({ page }) => {
let attempts = 0;
await page.route('**/api/orders', route => {
attempts++;
return attempts < 3
? route.fulfill({ status: 503, json: { error: 'Service Unavailable' } })
: route.fulfill({ status: 200, json: { orders: [] } });
});

await page.goto('/orders');
await expect(page.getByText('You have no orders yet')).toBeVisible();
expect(attempts).toBe(3); // proves the client retried twice
});
`


Q67. How do you handle authentication flows like OAuth, SSO, and MFA in tests?

Answer.

Ranked by preference β€” I'd give all four and state my default.

1. Bypass the IdP entirely β€” inject a token (default choice).
`ts
setup('authenticate via token exchange', async ({ request, page }) => {
const res = await request.post(${process.env.IDP_URL}/oauth/token, {
form: {
grant_type: 'password', // or client_credentials for service accounts
client_id: process.env.CLIENT_ID!,
client_secret: process.env.CLIENT_SECRET!,
username: process.env.USER_EMAIL!,
password: process.env.USER_PASSWORD!,
scope: 'openid profile email',
},
});
const { access_token, id_token } = await res.json();

await page.context().addInitScript(([at, it]) => {
localStorage.setItem('access_token', at);
localStorage.setItem('id_token', it);
}, [access_token, id_token]);

await page.goto('/dashboard');
await expect(page.getByRole('heading', { name: 'Dashboard' })).toBeVisible();
await page.context().storageState({ path: 'auth/user.json' });
});
`
Fast, no third-party UI dependency, no MFA prompt.

2. Drive the real IdP UI once in a setup project. Slower and brittle (Okta/Auth0 change their login DOM), but it's the only path that actually tests your SSO integration. Do it once per run, not per test.

3. MFA/TOTP β€” generate the code, never bypass security theatre manually:
`ts
import { authenticator } from 'otplib';
const code = authenticator.generate(process.env.TOTP_SECRET!);
await page.getByLabel('Authentication code').fill(code);
`

Ask the team for a dedicated test account with a known TOTP seed.

4. Environment-level bypass: a non-prod-only X-Test-Auth header or ?impersonate= param, gated behind an env flag and a secret. Must be impossible to enable in production β€” I'd insist on a config assertion + a security review.

What I'd say about coverage:

"I keep exactly one test that walks the full real SSO + MFA journey, tagged @auth-integration, run nightly. Every other test uses injected state. That gives me integration confidence without paying the SSO tax 2,000 times."


Q68. What's the difference between page.request and the request fixture?

Answer.

`ts
test('...', async ({ page, request }) => {
await page.request.get('/api/me'); // shares the PAGE's cookies + storage state
await request.get('/api/me'); // INDEPENDENT context, own cookie jar
});
`

page.request request fixture
Cookie jar Shared with the page/context Separate
Auth state Inherits page login Needs its own auth
baseURL Context's use.baseURL
Use for Verifying backend state as the logged-in user Setup/teardown, admin ops, unauthenticated checks

Practical pattern β€” use both deliberately:
`ts
test('admin can view a user\'s private order', async ({ page, request }) => {
// request = admin API context (from a fixture with admin token)
const order = await createOrderAsAdmin(request, { ownerId: 'C-100' });

// page = logged in as the customer
await page.goto(/orders/${order.id});
await expect(page.getByTestId('order-ref')).toHaveText(order.reference);

// page.request = call the API AS the customer β€” verifies authorisation scoping
const asCustomer = await page.request.get(/api/orders/${order.id});
expect(asCustomer.status()).toBe(200);

const someoneElses = await page.request.get('/api/orders/OTHER-999');
expect(someoneElses.status()).toBe(403); // ← security regression test
});
`


Q69. How do you test file downloads that are generated server-side (PDF/Excel)?

Answer.

`ts
import { parse } from 'csv-parse/sync';
import * as XLSX from 'xlsx';
import pdf from 'pdf-parse';
import fs from 'node:fs/promises';

test('invoice export contains correct totals', async ({ page }, testInfo) => {
const downloadPromise = page.waitForEvent('download');
await page.getByRole('button', { name: 'Export invoices' }).click();
const download = await downloadPromise;

expect(download.suggestedFilename()).toMatch(/^invoices-\d{4}-\d{2}-\d{2}.xlsx$/);

const filePath = testInfo.outputPath(download.suggestedFilename());
await download.saveAs(filePath);
await testInfo.attach('export', { path: filePath }); // shows in HTML report

// ---- XLSX ----
const wb = XLSX.readFile(filePath);
const rows = XLSX.utils.sheet_to_json<{ Invoice: string; Amount: number }>(
wb.Sheets[wb.SheetNames[0]]
);
expect(rows).toHaveLength(42);
expect(rows[0]).toMatchObject({ Invoice: 'INV-1042', Amount: 12400 });
expect(rows.reduce((s, r) => s + r.Amount, 0)).toBe(486_300);

// ---- CSV ----
// const records = parse(await fs.readFile(csvPath), { columns: true });

// ---- PDF ----
// const { text, numpages } = await pdf(await fs.readFile(pdfPath));
// expect(numpages).toBe(3);
// expect(text).toContain('Total: β‚Ή12,400.00');
});
`

Alternative that's often better: skip the browser download entirely and hit the export endpoint directly:
`ts
const res = await page.request.get('/api/invoices/export?format=xlsx');
expect(res.headers()['content-disposition']).toContain('attachment');
const buffer = await res.body();
const wb = XLSX.read(buffer, { type: 'buffer' });
`

Faster and avoids all download-event flakiness β€” but it doesn't verify the button actually wires up. I'd keep one UI download test and do content assertions via the API.

Config note: acceptDownloads: true (default). Downloads land in a temp dir and are deleted when the context closes β€” always saveAs() before that.


Q70. How do you write contract tests or catch backend API changes early?

Answer.

Layer 1 β€” Runtime schema validation on every API response (cheapest, highest value):
`ts
// api/schemas.ts
export const OrderSchema = z.object({ /* ... */ });

// api/client.ts
async getOrder(id: string): Promise {
const res = await this.ctx.get(/api/orders/${id});
expect(res).toBeOK();
return OrderSchema.parse(await res.json()); // throws with a precise path on drift
}
`
Every test that touches the API becomes a contract test for free.

Layer 2 β€” Generate types from OpenAPI, fail CI on drift:
`bash
npx openapi-typescript https://api.staging.app/openapi.json -o src/types/api.d.ts
git diff --exit-code src/types/api.d.ts # non-zero if the contract changed
`

`ts
import type { paths } from './types/api';
type OrderResponse = paths['/orders/{id}']['get']['responses']['200']['content']['application/json'];
`

Now tsc catches an API change at compile time, before a single test runs.

Layer 3 β€” Consumer-driven contracts (Pact) when frontend and backend are separately deployed teams. The frontend publishes expectations; the backend's pipeline verifies them. This is the real fix for "our mocks drifted from reality."

Layer 4 β€” Snapshot the response shape:
`ts
const body = await (await request.get('/api/orders/1042')).json();
expect(normalise(body)).toMatchSnapshot('order-response.json');

function normalise(o: any) { // strip volatile fields
return { ...o, id: '', createdAt: '', updatedAt: '' };
}
`

What I'd say: "Mock drift is the single biggest hidden risk in a heavily-mocked E2E suite. Zod validation on real responses plus generated types from OpenAPI gives me 90% of contract-test value for about 5% of the Pact setup cost β€” I'd only introduce Pact when the teams genuinely deploy independently."


SECTION 7 β€” Authentication, Storage State & Sessions (Q71–Q78)


Q71. What exactly does storageState capture and what does it miss?

Answer.

`ts
await context.storageState({ path: 'auth/user.json' });
`

Produces:
`json
{
"cookies": [
{ "name": "session", "value": "abc123", "domain": ".app.com", "path": "/",
"expires": 1790000000, "httpOnly": true, "secure": true, "sameSite": "Lax" }
],
"origins": [
{ "origin": "https://app.com",
"localStorage": [{ "name": "access_token", "value": "eyJ..." }] }
]
}
`

Captured: cookies (all domains in the context) + localStorage (per origin).

NOT captured:
| Missing | Workaround |
|---|---|
| sessionStorage | addInitScript injection (see Q45) |
| IndexedDB | addInitScript + manual seeding |
| Service Worker caches / Cache API | Re-warm in a setup step or accept a cold start |
| WebSQL | Deprecated; ignore |
| In-memory JS state (Redux, Zustand) | Rehydrated from storage on boot β€” usually fine |
| HTTP/2 push, connection state | N/A |

Use it three ways:
`ts
// 1. Config-wide
use: { storageState: 'auth/user.json' }

// 2. Inline object (no file on disk β€” great for CI secrets)
use: { storageState: { cookies: [...], origins: [...] } }

// 3. Per-context
const ctx = await browser.newContext({ storageState: 'auth/admin.json' });

// 4. Explicitly UNauthenticated (override a global default)
test.use({ storageState: { cookies: [], origins: [] } });
`

The storageState: undefined gotcha: in a project with a global storageState, test.use({ storageState: undefined }) does not reliably clear it β€” pass an empty state object instead.


Q72. How do you test a login flow itself when every other test skips login?

Answer.

Login tests must run without the inherited auth state:

`ts
// tests/auth/login.spec.ts
import { test, expect } from '@playwright/test';

test.use({ storageState: { cookies: [], origins: [] } }); // ← start logged OUT

test.describe('Login', () => {
test.beforeEach(async ({ page }) => await page.goto('/login'));

test('succeeds with valid credentials', async ({ page }) => {
await page.getByLabel('Email').fill(process.env.USER_EMAIL!);
await page.getByLabel('Password').fill(process.env.USER_PASSWORD!);
await page.getByRole('button', { name: 'Sign in' }).click();
await expect(page).toHaveURL(/\/dashboard/);
await expect(page.getByRole('button', { name: 'Account menu' })).toBeVisible();
});

test('shows a generic error for a wrong password', async ({ page }) => {
await page.getByLabel('Email').fill('real.user@test.io');
await page.getByLabel('Password').fill('WrongPassword1!');
await page.getByRole('button', { name: 'Sign in' }).click();

const alert = page.getByRole('alert');
await expect(alert).toBeVisible();
// SECURITY: must NOT reveal whether the account exists
await expect(alert).toHaveText('Invalid email or password');
await expect(alert).not.toContainText(/user not found|incorrect password/i);
await expect(page).toHaveURL(/\/login/);
Enter fullscreen mode Exit fullscreen mode

});

test('locks the account after 5 failed attempts', async ({ page }) => {
for (let i = 0; i < 5; i++) {
await page.getByLabel('Email').fill('lockme@test.io');
await page.getByLabel('Password').fill(bad-${i});
await page.getByRole('button', { name: 'Sign in' }).click();
await expect(page.getByRole('alert')).toBeVisible();
}
await expect(page.getByRole('alert')).toContainText('temporarily locked');
});

test('redirects back to the originally requested page', async ({ page }) => {
await page.goto('/orders/1042'); // protected
await expect(page).toHaveURL(/\/login\?redirect=/);
await login(page);
await expect(page).toHaveURL(/\/orders\/1042/);
});
});
`

Also worth covering: logout clears the session (back button must not restore the dashboard), session timeout, "remember me" cookie persistence, and concurrent-session policy.


Q73. How do you test role-based access control (RBAC) efficiently?

Answer.

Use option fixtures + projects so one spec covers every role.

`ts
// fixtures/roles.ts
export type Role = 'admin' | 'manager' | 'editor' | 'viewer';

export const test = base.extend<{ role: Role; asRole: Page }>({
role: ['viewer', { option: true }],

asRole: async ({ browser, role }, use) => {
const context = await browser.newContext({ storageState: auth/${role}.json });
const page = await context.newPage();
await use(page);
await context.close();
},
});

ts
// playwright.config.ts
projects: [
{ name: 'admin', use: { role: 'admin' }, dependencies: ['setup'] },
{ name: 'manager', use: { role: 'manager' }, dependencies: ['setup'] },
{ name: 'editor', use: { role: 'editor' }, dependencies: ['setup'] },
{ name: 'viewer', use: { role: 'viewer' }, dependencies: ['setup'] },
]
`

A single permission-matrix spec:
`ts
const MATRIX: Record = {
admin: { create: true, edit: true, delete: true, approve: true },
manager: { create: true, edit: true, delete: false, approve: true },
editor: { create: true, edit: true, delete: false, approve: false },
viewer: { create: false, edit: false, delete: false, approve: false },
};

test('document permissions match the RBAC matrix', async ({ asRole, role }) => {
const perms = MATRIX[role];
await asRole.goto('/documents/1');

const check = async (name: string, allowed: boolean) => {
const btn = asRole.getByRole('button', { name });
allowed ? await expect.soft(btn).toBeEnabled() : await expect.soft(btn).toBeHidden();
};

await check('New document', perms.create);
await check('Edit', perms.edit);
await check('Delete', perms.delete);
await check('Approve', perms.approve);
});
`

Critical addition β€” test the API layer too. Hiding a button is not authorisation:
`ts
test('viewer cannot delete via the API even though the button is hidden', async ({ asRole }) => {
const res = await asRole.request.delete('/api/documents/1');
expect(res.status()).toBe(403);
});
`

Say this: "UI-only RBAC tests give false confidence. Every negative permission test must be paired with a direct API call, because the real vulnerability is an unprotected endpoint, not a visible button."


Q74. How do you handle multi-tenant applications in your test framework?

Answer.

`ts
type TenantOptions = { tenant: { slug: string; baseURL: string; features: string[] } };

export const test = base.extend({
tenant: [{ slug: 'default', baseURL: '', features: [] }, { option: true }],

page: async ({ page, tenant }, use) => {
await page.route('*/', async route => {
const headers = { ...route.request().headers(), 'X-Tenant-Id': tenant.slug };
await route.continue({ headers });
});
await use(page);
},
});

ts
projects: [
{ name: 'acme', use: { tenant: { slug: 'acme', baseURL: 'https://acme.app.com', features: ['sso','audit'] },
baseURL: 'https://acme.app.com' } },
{ name: 'globex', use: { tenant: { slug: 'globex', baseURL: 'https://globex.app.com', features: ['audit'] },
baseURL: 'https://globex.app.com' } },
]
`

Feature-flag-aware skipping:
`ts
test('SSO login is available', async ({ page, tenant }) => {
test.skip(!tenant.features.includes('sso'), `SSO not enabled for ${tenant.slug}`);
await page.goto('/login');
await expect(page.getByRole('button', { name: 'Sign in with SSO' })).toBeVisible();
});
`

Tenant isolation test (the one that actually finds bugs):
`ts
test('tenant A cannot access tenant B data', async ({ browser }) => {
const a = await browser.newContext({ storageState: 'auth/acme.json' });
const pageA = await a.newPage();

const res = await pageA.request.get('/api/documents/globex-doc-123');
expect(res.status()).toBe(404); // 404, not 403 β€” don't leak existence
await a.close();
});
`


Q75. How do you test the "remember me" / persistent session and session expiry?

Answer.

`ts
test('remember-me issues a long-lived cookie', async ({ page, context }) => {
await page.goto('/login');
await page.getByLabel('Email').fill(EMAIL);
await page.getByLabel('Password').fill(PASSWORD);
await page.getByLabel('Remember me').check();
await page.getByRole('button', { name: 'Sign in' }).click();
await expect(page).toHaveURL(/dashboard/);

const cookies = await context.cookies();
const session = cookies.find(c => c.name === 'session')!;

expect(session.httpOnly, 'session cookie must be HttpOnly').toBe(true);
expect(session.secure, 'session cookie must be Secure').toBe(true);
expect(session.sameSite).toBe('Lax');

const daysValid = (session.expires * 1000 - Date.now()) / 864e5;
expect(daysValid).toBeGreaterThan(29);
expect(daysValid).toBeLessThan(31);
});

test('session survives a browser restart when remembered', async ({ browser }) => {
const ctx1 = await browser.newContext();
const p1 = await ctx1.newPage();
await loginWithRemember(p1);
const state = await ctx1.storageState();
await ctx1.close(); // "browser closed"

const ctx2 = await browser.newContext({ storageState: state });
const p2 = await ctx2.newPage();
await p2.goto('/dashboard');
await expect(p2.getByRole('heading', { name: 'Dashboard' })).toBeVisible();
await ctx2.close();
});

test('expired session redirects to login', async ({ page, context }) => {
await context.addCookies([{
name: 'session', value: 'expired-token', domain: 'app.com', path: '/',
expires: Math.floor(Date.now() / 1000) - 3600,
}]);
await page.goto('/dashboard');
await expect(page).toHaveURL(/\/login/);
await expect(page.getByRole('alert')).toContainText('session has expired');
});

test('idle timeout warns then logs out', async ({ page }) => {
await page.clock.install();
await page.goto('/dashboard');
await page.clock.fastForward('14:00');
await expect(page.getByRole('dialog', { name: 'Still there?' })).toBeVisible();
await page.clock.fastForward('01:30');
await expect(page).toHaveURL(/\/login/);
});
`

Cookie manipulation API:
`ts
await context.addCookies([{ name, value, domain, path, expires, httpOnly, secure, sameSite }]);
const cookies = await context.cookies(['https://app.com']);
await context.clearCookies();
await context.clearCookies({ name: 'session' }); // selective (v1.43+)
await context.clearPermissions();
`


Q76. How do you keep secrets out of traces, videos, and reports?

Answer.

This is a question senior candidates get asked and juniors never think about. Traces contain full network payloads, including auth headers and passwords typed into forms.

Mitigations:

`ts
// 1. Mask sensitive fields in screenshots
await expect(page).toHaveScreenshot({ mask: [page.getByLabel('Password'), page.getByTestId('ssn')] });

// 2. Strip auth headers from recorded HAR
recordHar: { path: 'x.har', mode: 'minimal', urlFilter: '/api/' }
// then post-process the HAR to remove Authorization / Set-Cookie

// 3. Never log secrets
console.log(Logging in as ${email}); // βœ…
console.log(Password: ${password}); // ❌ ends up in CI logs forever

// 4. Redact in a custom reporter
class RedactingReporter implements Reporter {
onTestEnd(test: TestCase, result: TestResult) {
for (const err of result.errors) {
if (err.message) err.message = err.message.replace(/Bearer\s+[\w.-]+/g, 'Bearer ');
}
}
}

// 5. Use short-lived, low-privilege test credentials
// A leaked token that expires in 15 minutes and can only read staging is a non-event.
`

Policy points:

  • Artifacts (traces/videos) should have short retention (3–7 days) and restricted access.
  • Never upload traces from a production run to a public artifact store.
  • Add playwright-report/, test-results/, *.har, playwright/.auth/ to .gitignore.
  • Mask secrets in GitHub Actions: echo "::add-mask::$TOKEN".
  • CI secret scanning on every PR (gitleaks) β€” a .auth/user.json committed by accident is a live session token in git history forever.

Q77. A test needs two users interacting in real time. How do you structure it?

Answer.

`ts
test('reviewer sees the author\'s comment in real time', async ({ browser }) => {
const authorCtx = await browser.newContext({ storageState: 'auth/author.json' });
const reviewerCtx = await browser.newContext({ storageState: 'auth/reviewer.json' });

const author = await authorCtx.newPage();
const reviewer = await reviewerCtx.newPage();

await Promise.all([
author.goto('/documents/42'),
reviewer.goto('/documents/42'),
]);

await test.step('Author posts a comment', async () => {
await author.getByPlaceholder('Add a comment').fill('Please check section 3');
await author.getByRole('button', { name: 'Post' }).click();
await expect(author.getByTestId('comment-list')).toContainText('Please check section 3');
});

await test.step('Reviewer receives it without reloading', async () => {
await expect(reviewer.getByTestId('comment-list'))
.toContainText('Please check section 3', { timeout: 15_000 });
await expect(reviewer.getByTestId('unread-badge')).toHaveText('1');
});

await test.step('Reviewer replies; author sees typing indicator', async () => {
await reviewer.getByPlaceholder('Reply').fill('On it');
await expect(author.getByText('Reviewer is typing…')).toBeVisible();
await reviewer.getByRole('button', { name: 'Send' }).click();
await expect(author.getByTestId('comment-list')).toContainText('On it');
});

await Promise.all([authorCtx.close(), reviewerCtx.close()]);
});
`

Wrap it in a fixture so it's reusable:
`ts
type MultiUser = { pages: Record<'author' | 'reviewer', Page> };

export const test = base.extend({
pages: async ({ browser }, use) => {
const roles = ['author', 'reviewer'] as const;
const contexts = await Promise.all(
roles.map(r => browser.newContext({ storageState: auth/${r}.json }))
);
const pages = await Promise.all(contexts.map(c => c.newPage()));
await use(Object.fromEntries(roles.map((r, i) => [r, pages[i]])) as any);
await Promise.all(contexts.map(c => c.close()));
},
});
`

Key points to state: separate contexts, not separate pages in one context (otherwise they share cookies and you're the same user twice). Use generous timeouts on the cross-user assertion since it depends on WebSocket/push latency.


Q78. How do you test CSRF, security headers, and other security-adjacent concerns?

Answer.

`ts
test('security headers are present', async ({ request }) => {
const res = await request.get('/');
const h = res.headers();

expect(h['strict-transport-security']).toContain('max-age=');
expect(h['x-content-type-options']).toBe('nosniff');
expect(h['x-frame-options'] ?? h['content-security-policy']).toBeTruthy();
expect(h['content-security-policy']).toContain("default-src 'self'");
expect(h['referrer-policy']).toBeTruthy();
expect(h['permissions-policy']).toBeTruthy();
expect(h['server'], 'should not leak server version').toBeUndefined();
});

test('rejects a request with a missing CSRF token', async ({ page }) => {
await page.goto('/settings');
const res = await page.request.post('/api/settings', {
data: { theme: 'dark' },
headers: { 'X-CSRF-Token': '' },
});
expect(res.status()).toBe(403);
});

test('does not reflect unescaped input (XSS)', async ({ page }) => {
const payload = '';
await page.goto(/search?q=${encodeURIComponent(payload)});
await expect(page.getByTestId('query-echo')).toHaveText(payload); // rendered as TEXT
expect(await page.evaluate(() => (window as any).XSS)).toBeUndefined();
});

test('cookies are hardened', async ({ page, context }) => {
await page.goto('/');
for (const c of await context.cookies()) {
if (/session|token|auth/i.test(c.name)) {
expect.soft(c.httpOnly, ${c.name} must be HttpOnly).toBe(true);
expect.soft(c.secure, ${c.name} must be Secure).toBe(true);
expect.soft(c.sameSite, ${c.name} must set SameSite).not.toBe('None');
}
}
});
`

Accessibility as a security-adjacent quality gate (frequently asked in the same breath):
`ts
import AxeBuilder from '@axe-core/playwright';

test('dashboard has no critical a11y violations', async ({ page }) => {
await page.goto('/dashboard');
const results = await new AxeBuilder({ page })
.withTags(['wcag2a', 'wcag2aa', 'wcag21aa'])
.exclude('#third-party-widget')
.analyze();

const serious = results.violations.filter(v => ['critical', 'serious'].includes(v.impact!));
expect(serious, JSON.stringify(serious.map(v => ({ id: v.id, nodes: v.nodes.length })), null, 2))
.toHaveLength(0);
});
`

Honest framing: "Playwright is not a security scanner. These tests catch regressions in controls we've already designed β€” they don't replace SAST, DAST, or a pen test. I'd position them as a safety net in the pipeline, not as security assurance."


SECTION 8 β€” TypeScript Deep Dive for Playwright (Q79–Q88)


Q79. Why TypeScript over JavaScript for a test framework? Give concrete failure modes it prevents.

Answer.

Go beyond "type safety" β€” give failures:

Bug class JS outcome TS outcome
Typo in a POM method: loginPag.singIn() Runtime TypeError after browser launch, 8s in Compile error, 0s
Forgetting await: page.click(...) Race condition, intermittent flake @typescript-eslint/no-floating-promises catches it
Wrong fixture name: async ({ pge }) undefined at runtime Compile error
API response shape changed Cannot read property 'x' of undefined deep in a test Compile error at the type boundary
Wrong enum value: status: 'CANCELED' (US spelling) Silent false pass Compile error on a union type
Refactoring a POM method signature Manual grep, missed call sites Compiler finds all 40 call sites

The strongest single argument for a QA audience:

"Test code is code that nobody debugs until it fails at 2 a.m. in CI. Every error TypeScript moves from runtime to compile time is an error I find in my editor instead of in a red pipeline 20 minutes later. On a 60-person engineering org, that difference compounds enormously."

Secondary benefits: IDE autocomplete over the Playwright API (huge onboarding win for manual testers moving into automation), safe large-scale refactors, and self-documenting Page Objects.

Fair counterpoint to acknowledge: TS adds a build/type-check step and a learning curve. Since Playwright transpiles TS out of the box with zero config, the cost is close to nil β€” which is why the industry default has moved to TS.


Q80. Explain the TypeScript generics in base.extend<TestFixtures, WorkerFixtures>().

Answer.

`ts
export const test = base.extend<TestFixtures, WorkerFixtures>({ /* ... */ });
// ^^^^^^^^^^^^ ^^^^^^^^^^^^^^
// test-scoped worker-scoped
`

The generic parameters merge your fixture types into the test callback's parameter type, giving full autocomplete and compile-time checking:

`ts
type TestFixtures = { loginPage: LoginPage; testUser: User };
type WorkerFixtures = { apiClient: ApiClient };

test('x', async ({ page, loginPage, testUser, apiClient }) => {
// ^built-in ^^^^^^^^^ ^^^^^^^^ ^^^^^^^^^ all typed
loginPage.login(testUser.email, testUser.password); // autocompleted
loginPage.lgin(...); // ❌ compile error
});
`

Composing multiple fixture sets:
`ts
import { mergeTests, mergeExpects } from '@playwright/test';
import { test as authTest } from './fixtures/auth';
import { test as apiTest } from './fixtures/api';
import { test as dbTest } from './fixtures/db';

export const test = mergeTests(authTest, apiTest, dbTest);
export const expect = mergeExpects(baseExpect, currencyExpect);

mergeTestsunions the fixture types β€” this is how you keep fixture files small and composable rather than one 600-linefixtures.ts`.

Extracting the type of your own test object:
`ts
type MyFixtures = Parameters<Parameters<typeof test>[1]>[0];
// or, cleaner:
import type { PlaywrightTestArgs } from '@playwright/test';
`


Q81. What TypeScript features do you use most in a Playwright framework? Show them.

Answer.

`ts
/* 1. Union types + const assertions β€” invalid values are compile errors */
const ORDER_STATUSES = ['PENDING', 'PAID', 'SHIPPED', 'CANCELLED'] as const;
type OrderStatus = typeof ORDER_STATUSES[number]; // 'PENDING'|'PAID'|'SHIPPED'|'CANCELLED'

/* 2. Generics for reusable helpers /
async function readTable>(table: Locator): Promise { /
... */ }
const rows = await readTable<{ Invoice: string; Amount: string }>(grid);

/* 3. Utility types */
type User = { id: string; email: string; name: string; role: Role; createdAt: string };
type NewUser = Omit;
type UserPatch = Partial>;
type UserMap = Record;
type ReadonlyUser = Readonly;

/* 4. Discriminated unions for result modelling */
type ApiResult =
| { ok: true; data: T }
| { ok: false; status: number; error: string };

function handle(r: ApiResult) {
if (r.ok) return r.data; // TS narrows: data exists here
throw new Error(${r.status}: ${r.error}); // and error exists here
}

/* 5. Type guards */
function isOrder(x: unknown): x is Order {
return typeof x === 'object' && x !== null && 'id' in x && 'status' in x;
}

/* 6. satisfies β€” validate shape WITHOUT widening the literal type */
const config = {
timeouts: { short: 5_000, long: 30_000 },
roles: ['admin', 'viewer'],
} satisfies { timeouts: Record; roles: string[] };
config.timeouts.short; // still typed as 5000, not just number

/* 7. Template literal types */
type TestTag = @${'smoke' | 'regression' | 'mobile' | 'visual'};
const tag: TestTag = '@smoke'; // '@smok' β†’ compile error

/* 8. Mapped types for a page-object registry */
type Pages = { login: LoginPage; dashboard: DashboardPage; orders: OrdersPage };
type PageFactories = { [K in keyof Pages]: (page: Page) => Pages[K] };

/* 9. Non-null assertion β€” used SPARINGLY and only where truly guaranteed */
const box = (await locator.boundingBox())!;

/* 10. Optional chaining + nullish coalescing */
const total = order?.summary?.total ?? 0;
`


Q82. What tsconfig.json settings do you use for a Playwright project and why?

Answer.

`jsonc
{
"compilerOptions": {
"target": "ES2022",
"module": "CommonJS", // Playwright's default loader; use ESNext + "type":"module" for ESM
"moduleResolution": "node",
"lib": ["ES2022", "DOM", "DOM.Iterable"], // DOM needed for page.evaluate() callbacks

/* Strictness β€” the whole point of using TS */
"strict": true,                     // enables the 7 flags below
"noImplicitAny": true,
"strictNullChecks": true,           // ← the single most valuable flag
"strictFunctionTypes": true,
"strictBindCallApply": true,
"strictPropertyInitialization": true,
"noImplicitThis": true,
"alwaysStrict": true,

/* Extra safety */
"noUnusedLocals": true,
"noUnusedParameters": true,
"noImplicitReturns": true,
"noFallthroughCasesInSwitch": true,
"noUncheckedIndexedAccess": true,   // arr[0] is T | undefined β€” catches real bugs
"exactOptionalPropertyTypes": true,
"forceConsistentCasingInFileNames": true,

/* Ergonomics */
"esModuleInterop": true,
"resolveJsonModule": true,          // import data from JSON fixtures
"skipLibCheck": true,               // faster builds
"sourceMap": true,                  // readable stack traces
"types": ["node"],

/* Path aliases β€” kill ../../../ imports */
"baseUrl": ".",
"paths": {
  "@pages/*":    ["src/pages/*"],
  "@fixtures/*": ["src/fixtures/*"],
  "@utils/*":    ["src/utils/*"],
  "@data/*":     ["src/data/*"]
}
Enter fullscreen mode Exit fullscreen mode

},
"include": ["src", "tests", "playwright.config.ts"],
"exclude": ["node_modules", "test-results", "playwright-report"]
}
`

Important gotcha: Playwright transpiles TS with esbuild, which does not type-check. So npx playwright test will happily run type-broken code.

Fix β€” a separate type-check gate:
`json
{ "scripts": {
"typecheck": "tsc --noEmit",
"lint": "eslint . --max-warnings 0",
"test": "playwright test",
"ci": "npm run typecheck && npm run lint && npm run test"
}}
`

Run tsc --noEmit in CI before the tests. Mentioning this unprompted is a strong signal β€” many candidates believe Playwright type-checks for them.

Path aliases also need runtime resolution β€” Playwright respects tsconfig paths natively since v1.35; otherwise add tsconfig-paths.


Q83. How do you type Page Objects properly? Show a well-typed base class.

Answer.

`ts
// pages/BasePage.ts
import { Page, Locator, expect } from '@playwright/test';

export abstract class BasePage {
protected constructor(protected readonly page: Page) {}

/** Each page declares its own path β€” enforced by the compiler. */
protected abstract readonly path: string;

async goto(params: Record = {}): Promise {
const url = Object.entries(params).reduce(
(acc, [k, v]) => acc.replace(:${k}, String(v)),
this.path
);
await this.page.goto(url);
await this.waitUntilLoaded();
}

/** Every page defines what "loaded" means for itself. */
abstract waitUntilLoaded(): Promise;

protected get toast(): Locator { return this.page.getByRole('status'); }

async expectToast(message: string | RegExp): Promise {
await expect(this.toast).toBeVisible();
await expect(this.toast).toContainText(message);
}
}

ts
// pages/OrderDetailPage.ts
export class OrderDetailPage extends BasePage {
protected readonly path = '/orders/:id';

readonly status: Locator;
readonly cancelBtn: Locator;
readonly confirmBtn: Locator;

constructor(page: Page) {
super(page);
this.status = page.getByTestId('order-status');
this.cancelBtn = page.getByRole('button', { name: 'Cancel order' });
this.confirmBtn = page.getByRole('dialog').getByRole('button', { name: 'Yes, cancel' });
}

async waitUntilLoaded(): Promise {
await expect(this.status).toBeVisible();
}

/** Actions return this for fluent chaining, or the NEXT page object on navigation. */
async cancel(): Promise {
await this.cancelBtn.click();
await this.confirmBtn.click();
return this;
}

async expectStatus(status: OrderStatus): Promise {
await expect(this.status).toHaveText(status);
return this;
}

/** Navigation returns the destination page object β€” typed handoff. */
async backToList(): Promise {
await this.page.getByRole('link', { name: 'All orders' }).click();
const list = new OrdersListPage(this.page);
await list.waitUntilLoaded();
return list;
}
}

ts
// Usage β€” fully typed, self-documenting
const detail = new OrderDetailPage(page);
await detail.goto({ id: '1042' });
await (await detail.cancel()).expectStatus('CANCELLED');
const list = await detail.backToList();
`

Design rules I'd state:

  1. Locators are readonly Locator fields, not methods returning strings β€” they're lazy anyway.
  2. protected page β€” tests should not reach into pageObject.page.click(...).
  3. No assertions in POM navigation methods, but domain-level assertion helpers (expectStatus) are fine and reduce duplication.
  4. Return the next Page Object on navigation so the compiler enforces valid flows.
  5. abstract waitUntilLoaded() forces every page to define its own readiness contract.

Q84. What is as const and where does it matter in test code?

Answer.

as const makes a value deeply readonly and narrows literals to their exact type instead of widening them.

`ts
// Without as const
const roles = ['admin', 'viewer']; // type: string[]
type Role = typeof roles[number]; // string ← useless

// With as const
const roles = ['admin', 'viewer'] as const; // type: readonly ['admin', 'viewer']
type Role = typeof roles[number]; // 'admin' | 'viewer' ← useful βœ…
`

Where it matters in real Playwright code:

`ts
/* 1. Data-driven test tables β€” the loop variables stay narrow */
const cases = [
{ role: 'admin', canDelete: true },
{ role: 'viewer', canDelete: false },
] as const;

for (const c of cases) {
test(${c.role}, async ({ page }) => {
await loginAs(page, c.role); // c.role is 'admin'|'viewer', not string βœ…
});
}

/* 2. Config objects that shouldn't be mutated */
export const TIMEOUTS = { short: 5_000, medium: 15_000, long: 60_000 } as const;
TIMEOUTS.short = 1; // ❌ Cannot assign to 'short' because it is read-only

/* 3. Selector maps */
export const TEST_IDS = {
submitButton: 'checkout-submit',
totalPrice: 'checkout-total',
} as const;
type TestId = typeof TEST_IDS[keyof typeof TEST_IDS]; // 'checkout-submit'|'checkout-total'

/* 4. Making a function accept only known keys */
function byTestId(page: Page, key: keyof typeof TEST_IDS) {
return page.getByTestId(TEST_IDS[key]);
}
byTestId(page, 'submitButton'); // βœ…
byTestId(page, 'submitBtton'); // ❌ compile error β€” typo caught
`

satisfies vs as const β€” a common follow-up: as const narrows but doesn't validate shape; satisfies validates against a type without widening. Combine them for the best of both.


Q85. How do you handle async/await correctly, and what is the "floating promise" problem?

Answer.

A floating promise is an async call whose returned promise is never awaited. In Playwright this is the #1 source of mysterious flakiness.

`ts
// ❌ FLOATING β€” the click may not have happened when the assertion runs
page.getByRole('button', { name: 'Save' }).click();
await expect(page.getByText('Saved')).toBeVisible();

// ❌ FLOATING in a loop β€” all 5 fire concurrently, order undefined
[1,2,3,4,5].forEach(i => page.getByRole('row').nth(i).click());

// ❌ FLOATING assertion β€” ALWAYS passes, tests nothing
expect(page.getByText('Error')).toBeVisible();
`

The catastrophic case is the third one. A missing await on a web-first assertion means the assertion never runs, the test goes green, and you have zero coverage β€” silently, for years.

Enforce it with lint (non-negotiable in my repos):
`js
// eslint.config.js
import tseslint from 'typescript-eslint';
import playwright from 'eslint-plugin-playwright';

export default tseslint.config(
...tseslint.configs.recommendedTypeChecked,
playwright.configs['flat/recommended'],
{
languageOptions: { parserOptions: { project: './tsconfig.json' } },
rules: {
'@typescript-eslint/no-floating-promises': 'error', // ← the critical one
'@typescript-eslint/await-thenable': 'error',
'@typescript-eslint/no-misused-promises': 'error',
'playwright/missing-playwright-await': 'error', // catches un-awaited expect()
'playwright/no-wait-for-timeout': 'error',
'playwright/no-force-option': 'warn',
'playwright/no-conditional-in-test': 'warn',
'playwright/no-skipped-test': 'warn',
'playwright/expect-expect': 'error', // a test with no assertion
'playwright/no-focused-test': 'error',
},
}
);
`

Correct concurrency when you actually want it:
`ts
// Parallel β€” independent operations
const [ordersRes, profileRes] = await Promise.all([
request.get('/api/orders'),
request.get('/api/profile'),
]);

// Sequential β€” dependent operations
for (const row of rows) {
await row.getByRole('checkbox').check(); // βœ… ordered
}

// ❌ NEVER do this β€” Playwright actions on ONE page are not safe to parallelise
await Promise.all([page.click('#a'), page.click('#b')]);

Actions on the same
Pageare serialised through one protocol connection; firing them concurrently produces undefined ordering.Promise.allis only for the wait-then-act pattern (waitForResponse+click`) and for independent API calls.


Q86. How do you type API responses end-to-end?

Answer.

`ts
// types/api.ts
export interface Order {
id: string;
reference: string;
status: OrderStatus;
total: number;
currency: 'INR' | 'USD' | 'EUR';
items: OrderItem[];
createdAt: string;
}
export interface OrderItem { sku: string; qty: number; unitPrice: number; }
export type OrderStatus = 'PENDING' | 'PAID' | 'SHIPPED' | 'CANCELLED';
export interface Paginated<T> { data: T[]; page: number; pages: number; total: number; }
`

`ts
// api/ApiClient.ts
import { APIRequestContext, expect } from '@playwright/test';
import { z } from 'zod';

const OrderSchema = z.object({
id: z.string(),
reference: z.string().regex(/^INV-\d{4}$/),
status: z.enum(['PENDING', 'PAID', 'SHIPPED', 'CANCELLED']),
total: z.number().positive(),
currency: z.enum(['INR', 'USD', 'EUR']),
items: z.array(z.object({ sku: z.string(), qty: z.number().int().positive(), unitPrice: z.number() })),
createdAt: z.string().datetime(),
});

export class ApiClient {
constructor(private readonly ctx: APIRequestContext) {}

/** Generic, validated request helper. */
private async json(
promise: Promise,
schema: z.ZodType
): Promise {
const res = await promise;
await expect(res, ${res.url()} returned ${res.status()}).toBeOK();
return schema.parse(await res.json());
}

getOrder(id: string): Promise {
return this.json(this.ctx.get(/api/orders/${id}), OrderSchema);
}

createOrder(payload: Omit): Promise {
return this.json(this.ctx.post('/api/orders', { data: payload }), OrderSchema);
}

listOrders(page = 1): Promise> {
return this.json(
this.ctx.get('/api/orders', { params: { page } }),
z.object({ data: z.array(OrderSchema), page: z.number(), pages: z.number(), total: z.number() })
);
}
}
`

Why Zod on top of the interface? The interface is a compile-time promise; Zod verifies it at runtime against the actual server. Types alone give false confidence β€” await res.json() as Order is a lie the compiler happily accepts. Say this explicitly; it's a distinguishing answer.

Bonus β€” derive the type from the schema so they can never diverge:
`ts
export type Order = z.infer<typeof OrderSchema>; // single source of truth
`


Q87. What are decorators and how would you use them in a Playwright framework?

Answer.

TypeScript 5 stage-3 decorators let you add cross-cutting behaviour to Page Object methods declaratively.

`ts
// support/decorators.ts
import { test } from '@playwright/test';

/** Wrap a method in a reported test.step automatically. */
export function step(label?: string) {
return function Promise>(
target: T,
context: ClassMethodDecoratorContext
) {
return function (this: any, ...args: any[]) {
const name = label ?? ${this.constructor.name}.${String(context.name)};
return test.step(name, async () => target.call(this, ...args), { box: true });
} as T;
};
}

/** Retry a flaky third-party interaction a bounded number of times. */
export function retry(times = 3, delayMs = 500) {
return function Promise>(target: T, _ctx: ClassMethodDecoratorContext) {
return async function (this: any, ...args: any[]) {
let lastErr: unknown;
for (let i = 0; i < times; i++) {
try { return await target.call(this, ...args); }
catch (e) { lastErr = e; await new Promise(r => setTimeout(r, delayMs * (i + 1))); }
}
throw lastErr;
} as T;
};
}

ts
export class CheckoutPage extends BasePage {
protected readonly path = '/checkout';

@step('Apply coupon code')
async applyCoupon(code: string): Promise {
await this.page.getByLabel('Coupon code').fill(code);
await this.page.getByRole('button', { name: 'Apply' }).click();
await expect(this.page.getByTestId('discount')).toBeVisible();
}

@step()
@retry(3, 800)
async payWithGateway(card: Card): Promise {
const frame = this.page.frameLocator('#payment-iframe');
await frame.getByLabel('Card number').fill(card.number);
await frame.getByRole('button', { name: 'Pay' }).click();
}
}
`

Enable in tsconfig.json:
`json
{ "compilerOptions": { "experimentalDecorators": false, "target": "ES2022" } }
`

(Stage-3 decorators need TS β‰₯5.0 and experimentalDecorators: false β€” the legacy flag is for the old proposal.)

Value: every POM method appears as a named step in the trace and HTML report with zero boilerplate. On a 300-test suite that transforms triage.

Caution to voice: decorators add indirection; overusing @retry hides real bugs. I'd allow @step broadly and @retry only on documented third-party boundaries.


Q88. How do you organise a large TypeScript Playwright repository?

Answer.

`plaintext
e2e/
β”œβ”€β”€ playwright.config.ts
β”œβ”€β”€ tsconfig.json
β”œβ”€β”€ eslint.config.js
β”œβ”€β”€ .env.example
β”œβ”€β”€ package.json
β”‚
β”œβ”€β”€ src/
β”‚ β”œβ”€β”€ pages/ # Page Objects
β”‚ β”‚ β”œβ”€β”€ BasePage.ts
β”‚ β”‚ β”œβ”€β”€ LoginPage.ts
β”‚ β”‚ β”œβ”€β”€ components/ # Reusable widgets (Header, DataGrid, DatePicker, Modal)
β”‚ β”‚ β”‚ β”œβ”€β”€ DataGrid.ts
β”‚ β”‚ β”‚ └── NavBar.ts
β”‚ β”‚ └── index.ts # barrel export
β”‚ β”‚
β”‚ β”œβ”€β”€ fixtures/
β”‚ β”‚ β”œβ”€β”€ auth.fixture.ts
β”‚ β”‚ β”œβ”€β”€ api.fixture.ts
β”‚ β”‚ β”œβ”€β”€ data.fixture.ts
β”‚ β”‚ └── index.ts # mergeTests(...) β€” single import for specs
β”‚ β”‚
β”‚ β”œβ”€β”€ api/
β”‚ β”‚ β”œβ”€β”€ ApiClient.ts
β”‚ β”‚ β”œβ”€β”€ schemas.ts # Zod
β”‚ β”‚ └── endpoints.ts
β”‚ β”‚
β”‚ β”œβ”€β”€ data/
β”‚ β”‚ β”œβ”€β”€ builders/ # Test-data builder pattern
β”‚ β”‚ β”‚ └── OrderBuilder.ts
β”‚ β”‚ β”œβ”€β”€ factories.ts # faker-based
β”‚ β”‚ └── constants.ts
β”‚ β”‚
β”‚ β”œβ”€β”€ utils/
β”‚ β”‚ β”œβ”€β”€ date.ts
β”‚ β”‚ β”œβ”€β”€ file.ts
β”‚ β”‚ └── retry.ts
β”‚ β”‚
β”‚ └── types/
β”‚ β”œβ”€β”€ api.d.ts # generated from OpenAPI
β”‚ └── global.d.ts # custom matcher declarations
β”‚
β”œβ”€β”€ tests/
β”‚ β”œβ”€β”€ auth/
β”‚ β”œβ”€β”€ orders/
β”‚ β”œβ”€β”€ checkout/
β”‚ β”œβ”€β”€ api/
β”‚ └── visual/
β”‚
β”œβ”€β”€ setup/
β”‚ β”œβ”€β”€ global.setup.ts
β”‚ └── global.teardown.ts
β”‚
β”œβ”€β”€ reporters/
β”‚ └── slack-reporter.ts
β”‚
└── playwright/.auth/ # gitignored
`

Test Data Builder pattern (worth showing β€” it's a senior-level touch):
`ts
export class OrderBuilder {
private order: Partial = {
status: 'PENDING', currency: 'INR', items: [{ sku: 'SKU-991', qty: 1, unitPrice: 100 }],
};

withStatus(status: OrderStatus): this { this.order.status = status; return this; }
withItems(...items: OrderItem[]): this { this.order.items = items; return this; }
overdue(): this { this.order.createdAt = new Date(Date.now() - 90 * 864e5).toISOString(); return this; }

build(): Omit {
return { reference: INV-${Math.floor(Math.random() * 9000) + 1000},
total: this.order.items!.reduce((s, i) => s + i.qty * i.unitPrice, 0),
...this.order } as Omit;
}
}

// Test reads like a sentence
const order = new OrderBuilder().withStatus('PAID').overdue().build();
`

Conventions I enforce:

  • *.spec.ts for tests, *.setup.ts for setup projects, *.fixture.ts for fixtures
  • Barrel exports (index.ts) so specs import from @fixtures not ../../../src/fixtures/auth.fixture
  • One describe per user-facing feature; test titles read as behaviour ("shows an error when the coupon is expired") not implementation ("test coupon 3")
  • CODEOWNERS per feature folder so the right team reviews test changes

SECTION 9 β€” Framework Design, POM & Scalability (Q89–Q94)


Q89. Is the Page Object Model still relevant with Playwright? Argue both sides.

Answer. (Interviewers ask this to see whether you think or recite.)

The case against POM in Playwright:

  • Playwright's locators are already lazy, self-waiting, and readable. page.getByRole('button', {name:'Sign in'}) needs no wrapper to be legible.
  • Classic POM produces LoginPage.enterUsername(), LoginPage.enterPassword(), LoginPage.clickSubmit() β€” three methods that wrap one line each, adding indirection with no abstraction.
  • Playwright's docs promote fixtures as the primary reuse mechanism, not page classes.
  • Deep POM hierarchies become a second application you must maintain and debug.

The case for POM:

  • Change locality. When the login form is redesigned, one file changes instead of 200 specs.
  • Domain vocabulary. await checkout.completePurchase(card) communicates intent; 15 lines of clicks do not.
  • Onboarding. New engineers write tests in business language without learning every selector.
  • Enforces locator discipline β€” reviewers police one file instead of every spec.

My actual position (this is what to say):

"I use a hybrid: component objects + fixtures, not classic POM. I don't wrap single interactions β€” I encapsulate domain workflows and complex components. A DataGrid component object with rowByRef() and sortBy() earns its place. A LoginPage.clickSubmit() does not; that's a one-line indirection.

The heuristic I apply: abstract a workflow when it appears in three or more tests, or when it takes more than five lines. Below that threshold, the raw locator is clearer than the wrapper. And I expose page objects through fixtures so tests get them injected and typed, rather than instantiating them in every beforeEach."

Anti-patterns to name:

  • Assertions buried in every POM method (the test no longer says what it verifies)
  • POM methods returning Promise<void> when they navigate β€” return the next page object
  • A BasePage god-class with 40 methods
  • Storing state (this.currentOrderId) in a page object β€” makes it non-reentrant across parallel tests

Q90. Design a test framework for a 40-engineer team from scratch. What are your first five decisions?

Answer.

1. Define the test pyramid and where E2E stops.

"E2E covers user journeys that cross system boundaries. Field validation, calculations, and error copy go to unit/component tests. I'd target roughly 70% unit, 20% API/integration, 10% E2E. Without this, the E2E suite grows to 3,000 tests and 90 minutes, and nobody trusts it."

2. Pick the execution contract: what runs when.

Gate Scope Budget Blocking
Pre-commit lint + typecheck + changed unit tests 30s Yes
PR @smoke E2E on Chromium + all API tests 5 min Yes
Merge to main Full regression, Chromium 20 min Yes
Nightly Cross-browser + mobile + visual + a11y 45 min No (alerts)
Pre-release Everything + prod-like data 60 min Yes

3. Establish the data strategy on day one.
API-based seeding, unique data per test, teardown in fixtures, a RUN_ID tag on every entity, nightly sweeper. Retro-fitting this into a mature suite is the most painful migration there is.

4. Establish the locator contract with developers.
getByRole first; data-testid mandated for icon-only and canvas elements, added by devs in the feature PR, stripped in prod builds. This is a cross-team agreement, negotiated in writing, not a QA preference.

5. Build for observability before you build for coverage.
HTML + blob reports merged across shards, trace on first retry, a flaky-rate dashboard, Slack alerts with a direct link to the failing trace, and an ownership map so failures route to the right team automatically.

Then, in order: fixtures β†’ component objects β†’ the first 30 smoke tests β†’ CI wiring β†’ documentation β†’ onboarding pairing sessions.

The organisational answer that lands hardest:

"The framework isn't the hard part β€” Playwright gives you 80% of it. The hard part is making 40 engineers want to use it. So I optimise for: a green suite people trust, sub-5-minute PR feedback, failures that are self-diagnosing from the report alone, and tests devs can write without asking QA. If the suite is slow or flaky, engineers route around it, and the best-designed framework in the world delivers zero value."


Q91. How do you handle a component library / design system with Playwright?

Answer.

Playwright Component Testing (experimental but usable) mounts components in a real browser without a full app:

`ts
// Button.spec.tsx
import { test, expect } from '@playwright/experimental-ct-react';
import { Button } from './Button';

test('renders a loading state and blocks clicks', async ({ mount }) => {
let clicks = 0;
const component = await mount(
clicks++}>Save
);

await expect(component).toContainText('Save');
await expect(component.getByRole('progressbar')).toBeVisible();
await component.click();
expect(clicks).toBe(0);
});

test('variants match the design baseline', async ({ mount }) => {
for (const variant of ['primary', 'secondary', 'danger'] as const) {
const c = await mount(Action);
await expect(c).toHaveScreenshot(button-${variant}.png);
await c.unmount();
}
});
`

Component objects for the full-app suite β€” reuse across pages:
`ts
// pages/components/DateRangePicker.ts
export class DateRangePicker {
constructor(private readonly root: Locator) {}

static within(scope: Locator | Page): DateRangePicker {
return new DateRangePicker(
'getByTestId' in scope ? scope.getByTestId('date-range') : scope
);
}

async selectPreset(preset: 'Last 7 days' | 'Last 30 days' | 'This quarter'): Promise {
await this.root.click();
await this.root.page().getByRole('option', { name: preset }).click();
}

async selectRange(from: Date, to: Date): Promise { /* ... */ }

async expectSelected(label: string): Promise {
await expect(this.root).toHaveText(label);
}
}

// Used identically on every page that embeds it
await DateRangePicker.within(page.getByRole('main')).selectPreset('Last 30 days');
`

Honest caveat: Playwright CT is still experimental, has no SSR support, and its API has changed between versions. For a mature design system I'd use Storybook + Playwright (test the published stories) or Chromatic for visual review, and use Playwright CT only where the team is comfortable with experimental APIs.


Q92. How do you migrate a large Selenium/Cypress suite to Playwright?

Answer.

Never big-bang. Strangler-fig pattern.

Phase 0 β€” Justify it with numbers, not preference. Baseline the current suite: runtime, flake rate, maintenance hours/sprint, MTTR on failures. Without this, the migration is a taste argument and it will be deprioritised.

Phase 1 β€” Parallel run (weeks 1–4). Stand up Playwright alongside the existing suite. Port the 20 highest-value smoke tests. Both suites run; only the legacy one blocks. Prove: faster, less flaky, better failure reports.

Phase 2 β€” Freeze the old suite (week 5). New tests are written only in Playwright. This alone stops the bleeding.

Phase 3 β€” Port by value, not by file order. Rank existing tests by (business criticality Γ— flake rate) / port effort. Port the top of that list. Many tests shouldn't be ported at all β€” they should be deleted or pushed down the pyramid.

Phase 4 β€” Flip the gate. Once Playwright covers the critical paths, make it the blocking suite and demote the legacy one to nightly.

Phase 5 β€” Decommission with a hard date.

Mapping cheat-sheet for Selenium migrants:

Selenium (Java/TS) Playwright
driver.findElement(By.id("x")) page.locator('#x')
WebDriverWait.until(visibilityOf(e)) (automatic) / await expect(loc).toBeVisible()
Thread.sleep(2000) Delete it
driver.switchTo().frame(...) page.frameLocator(...)
Actions.moveToElement().perform() locator.hover()
JavascriptExecutor.executeScript page.evaluate()
driver.getWindowHandles() context.waitForEvent('page')
new ChromeOptions() use: { ...devices['Desktop Chrome'] }
TestNG @BeforeMethod test.beforeEach / fixture
TestNG @DataProvider for loop over a typed array
BrowserMob proxy page.route()

From Cypress:

Cypress Playwright
cy.get() (auto-retries, no await) page.locator() + await
cy.intercept() page.route()
cy.fixture() import data from './data.json'
Custom commands Fixtures / POM methods
cy.session() storageState
No real multi-tab/multi-origin Native contexts and pages

Cultural point: the hardest part of a Cypress→Playwright migration is await. Cypress chains are not promises; Playwright's are. Budget a week of pairing on that single concept.


Q93. How do you decide what to automate and what NOT to automate?

Answer.

Automate when:

  • The flow is business-critical (login, checkout, payment, data integrity)
  • It's repetitive and stable β€” run every release, low churn
  • The failure cost is high (revenue, compliance, data loss)
  • It's a regression β€” every production bug gets a test, no exceptions
  • The manual test is tedious or error-prone (cross-browser, 40-field forms, permission matrices)

Don't automate when:

  • The feature is still in flux β€” you'll rewrite the test three times before the design settles
  • It's a one-off (a data migration verification)
  • It requires human judgement β€” visual aesthetics, UX quality, content tone
  • Exploratory testing β€” automation confirms known expectations; it never discovers unknowns
  • The maintenance cost exceeds the manual cost: a test run 4Γ—/year that takes 3 days to build and breaks monthly is a net loss
  • It's inherently unstable by design β€” third-party captcha, live ad rendering, real payment gateways

The formula I use for ROI:
`plaintext
ROI = (manual_execution_time Γ— runs_per_year Γ— years)
βˆ’ (build_time + maintenance_hours_per_year Γ— years)
`

A test executed 200 times a year that takes 4 minutes manually saves ~13 hours/year. If it takes 4 hours to build and 2 hours/year to maintain, it pays back in the first quarter. A test run twice a year does not.

The nuance senior candidates add:

"The question isn't only 'automate or not' β€” it's at what level. Most requests to 'automate this' are best answered with a unit or API test, not a UI test. And I'd rather have 200 fast, trusted E2E tests than 2,000 that people ignore. Coverage percentage is a vanity metric; defect escape rate and MTTR are the ones I report to leadership."


Q94. How do you convince a sceptical engineering manager to invest in test automation?

Answer. (A leadership question for 10+ year candidates.)

Don't lead with tools. Lead with their pain.

1. Quantify the current cost:

  • Hours of manual regression per release Γ— engineer cost
  • Production incidents in the last 6 months that a test would have caught, with their cost (downtime, support tickets, churn)
  • Time from "bug reported" to "fix verified"
  • Release frequency capped by the length of the manual regression cycle

2. Reframe the goal. Not "improve quality" β€” that's unmeasurable and everyone claims it. Instead: "reduce release cycle time from 3 weeks to 3 days" or "cut production escapes by 60%". Managers fund velocity and risk reduction; they rarely fund abstract quality.

3. Start with a bounded, visible pilot. One critical flow, four weeks, defined success criteria. Show the before/after. Don't ask for a 6-month framework rewrite up front.

4. Track leading and lagging indicators and report them monthly:

Metric Why it matters
Defect escape rate (prod bugs / total bugs) The real quality signal
Regression cycle time Direct release-velocity impact
Flaky rate Trust in the suite
MTTR on a red pipeline Developer productivity
% of PRs blocked by a genuine test failure Proves it's catching things
Manual test hours saved Direct cost

5. Be honest about what automation doesn't do. It doesn't find new bugs, it doesn't replace exploratory testing, and it has real ongoing maintenance cost. Overselling is how automation programmes lose credibility in month four.

The closing line:

"The strongest argument I've used is a single production incident replayed as a test. I take the last severe escape, write the Playwright test that would have caught it, run it against the buggy commit, and show it going red. That's a two-minute demo that converts sceptics faster than any slide deck about ROI."


SECTION 10 β€” CI/CD, Reporting, Debugging & Leadership Scenarios (Q95–Q100)


Q95. Write a complete production CI pipeline for Playwright.

Answer.

`yaml
name: E2E Tests

on:
pull_request:
push: { branches: [main] }
schedule: [{ cron: '0 2 * * *' }] # nightly full run
workflow_dispatch:
inputs:
suite: { description: 'smoke | regression | full', default: 'smoke' }

concurrency:
group: e2e-${{ github.ref }}
cancel-in-progress: true

env:
NODE_VERSION: '20'

jobs:
static:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with: { node-version: '20', cache: npm }
- run: npm ci
- run: npx tsc --noEmit # ← Playwright does NOT type-check
- run: npx eslint . --max-warnings 0

test:
needs: static
timeout-minutes: 30
runs-on: ubuntu-latest
strategy:
fail-fast: false
matrix:
shard: [1, 2, 3, 4]
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with: { node-version: '20', cache: npm }
- run: npm ci

  - name: Cache browsers
    uses: actions/cache@v4
    id: pw-cache
    with:
      path: ~/.cache/ms-playwright
      key: pw-${{ runner.os }}-${{ hashFiles('package-lock.json') }}

  - run: npx playwright install --with-deps chromium
    if: steps.pw-cache.outputs.cache-hit != 'true'
  - run: npx playwright install-deps chromium
    if: steps.pw-cache.outputs.cache-hit == 'true'

  - name: Run tests
    run: npx playwright test --shard=${{ matrix.shard }}/4 --reporter=blob
    env:
      CI: true
      BASE_URL: ${{ vars.BASE_URL }}
      USER_EMAIL: ${{ secrets.E2E_USER_EMAIL }}
      USER_PASSWORD: ${{ secrets.E2E_USER_PASSWORD }}
      RUN_ID: ${{ github.run_id }}
      PW_GREP: ${{ github.event_name == 'pull_request' && '@smoke' || '' }}

  - uses: actions/upload-artifact@v4
    if: always()
    with:
      name: blob-report-${{ matrix.shard }}
      path: blob-report/
      retention-days: 3
Enter fullscreen mode Exit fullscreen mode

report:
needs: test
if: always()
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with: { node-version: '20', cache: npm }
- run: npm ci
- uses: actions/download-artifact@v4
with: { path: all-blob-reports, pattern: blob-report-*, merge-multiple: true }
- run: npx playwright merge-reports --reporter=html,github ./all-blob-reports

  - uses: actions/upload-artifact@v4
    with: { name: html-report, path: playwright-report/, retention-days: 14 }

  - name: Publish to GitHub Pages
    if: github.ref == 'refs/heads/main'
    uses: peaceiris/actions-gh-pages@v4
    with:
      github_token: ${{ secrets.GITHUB_TOKEN }}
      publish_dir: ./playwright-report

  - name: Notify Slack on failure
    if: failure()
    uses: slackapi/slack-github-action@v1
    with:
      payload: |
        { "text": "πŸ”΄ E2E failed on ${{ github.ref_name }} β€” <${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}|View run>" }
    env:
      SLACK_WEBHOOK_URL: ${{ secrets.SLACK_WEBHOOK }}
Enter fullscreen mode Exit fullscreen mode

`

Details worth calling out in the interview:

  • concurrency + cancel-in-progress stops wasting runners on superseded pushes
  • Browser caching saves 60–90s per job
  • --reporter=blob + merge-reports is the only correct way to report across shards
  • Type-check is a separate job because Playwright's esbuild transpile skips it
  • Secrets via GitHub Secrets, config via repo Variables
  • Artifact retention is short for traces (they contain sensitive payloads)

Q96. What reporters does Playwright support, and how do you write a custom one?

Answer.

Built-in: list (default local), dot (default CI), line, html, json, junit (for Jenkins/Azure/GitLab), github (inline PR annotations), blob (shard merging), null.

`ts
reporter: [
['list'],
['html', { open: 'never', outputFolder: 'playwright-report' }],
['junit', { outputFile: 'results/junit.xml', embedAnnotationsAsProperties: true }],
['json', { outputFile: 'results/results.json' }],
['./reporters/metrics-reporter.ts', { threshold: 0.02 }],
]
`

Custom reporter β€” push flaky-rate metrics and alert:
`ts
// reporters/metrics-reporter.ts
import type { Reporter, TestCase, TestResult, FullResult, Suite } from '@playwright/test/reporter';

interface Options { threshold?: number }

export default class MetricsReporter implements Reporter {
private stats = { passed: 0, failed: 0, flaky: 0, skipped: 0, totalMs: 0 };
private slowest: { title: string; ms: number }[] = [];

constructor(private options: Options = {}) {}

onBegin(config, suite: Suite) {
console.log(β–Ά Running ${suite.allTests().length} tests across ${config.workers} workers);
}

onTestEnd(test: TestCase, result: TestResult) {
this.stats.totalMs += result.duration;
this.slowest.push({ title: test.titlePath().join(' β€Ί '), ms: result.duration });

const outcome = test.outcome();          // 'expected'|'unexpected'|'flaky'|'skipped'
if (outcome === 'flaky') this.stats.flaky++;
else if (outcome === 'unexpected') this.stats.failed++;
else if (outcome === 'skipped') this.stats.skipped++;
else this.stats.passed++;

if (outcome === 'unexpected') {
  console.error(`❌ ${test.title}\n   ${result.errors[0]?.message?.split('\n')[0]}`);
}
Enter fullscreen mode Exit fullscreen mode

}

async onEnd(result: FullResult) {
const total = this.stats.passed + this.stats.failed + this.stats.flaky;
const flakyRate = total ? this.stats.flaky / total : 0;

console.log(`\nπŸ“Š ${this.stats.passed} passed Β· ${this.stats.failed} failed Β· ${this.stats.flaky} flaky`);
console.log(`⏱  Total ${(this.stats.totalMs / 1000).toFixed(1)}s`);
console.log('🐌 Slowest:');
this.slowest.sort((a, b) => b.ms - a.ms).slice(0, 5)
  .forEach(t => console.log(`   ${(t.ms / 1000).toFixed(1)}s  ${t.title}`));

await pushToDatadog({ flakyRate, ...this.stats, status: result.status });

if (flakyRate > (this.options.threshold ?? 0.02)) {
  console.error(`⚠️  Flaky rate ${(flakyRate * 100).toFixed(1)}% exceeds threshold`);
}
Enter fullscreen mode Exit fullscreen mode

}

printsToStdio() { return true; }
}
`

Other hooks: onTestBegin, onStepBegin, onStepEnd, onError, onStdOut, onStdErr, onExit.

Third-party integrations worth naming: Allure (allure-playwright), Currents / Testomat.io / Qase for cloud reporting and flake tracking, ReportPortal for AI-assisted failure clustering.


Q97. How do you debug a test that fails only in CI?

Answer. (Structure this as a repeatable procedure β€” that's what they're assessing.)

Step 1 β€” Get the trace. trace: 'on-first-retry', download the artifact, open in Trace Viewer. This resolves the majority of cases without reproducing anything.

Step 2 β€” Reproduce the CI environment locally. Run in the exact Docker image:
`bash
docker run --rm -it --ipc=host -v $(pwd):/app -w /app \
-e CI=true mcr.microsoft.com/playwright:v1.55.0-jammy \
npx playwright test tests/checkout.spec.ts
`

Step 3 β€” Work through the differential checklist:

Difference Symptom Fix
Timing (CI slower) Timeouts at ~30s Web-first assertions; raise expect.timeout, not waitForTimeout
Parallelism (local workers:1) Passes alone, fails in suite Data collision β†’ unique per-test data
Screen size Layout/hidden elements Pin viewport in config
Timezone / locale Date/currency assertions Pin timezoneId, locale
Fonts missing in container Visual diffs Install fonts in the image; generate baselines there
Env vars missing undefined in URLs Zod-validate env at startup
Test order Order-dependent state --shuffle, then fix the dependency
Network restrictions Third-party fetch hangs Block third parties by route
/dev/shm too small "Target closed" crashes --shm-size=2gb / --ipc=host
Stale cache Old browser binary Include lockfile hash in the cache key

Step 4 β€” Reproduce the concurrency locally:
`bash
npx playwright test --workers=4 --repeat-each=5 --grep @suspect
npx playwright test --shuffle # exposes hidden order dependencies
`

Step 5 β€” If still unreproducible, add CI-only instrumentation:
`ts
test.afterEach(async ({ page }, testInfo) => {
if (testInfo.status !== 'passed' && process.env.CI) {
await testInfo.attach('dom', { body: await page.content(), contentType: 'text/html' });
await testInfo.attach('storage', {
body: JSON.stringify(await page.context().storageState(), null, 2),
contentType: 'application/json',
});
await testInfo.attach('console', { body: consoleBuffer.join('\n'), contentType: 'text/plain' });
}
});
`

Step 6 β€” SSH into the runner (mxschmitt/action-tmate on GitHub Actions) as a last resort for genuinely environment-specific failures.


Q98. How do you measure whether your automation is actually working? What metrics do you report?

Answer.

Metrics that matter (report monthly to engineering leadership):

Metric Definition Target
Defect escape rate Prod bugs Γ· total bugs found ↓ trending
Automation pass rate Passed Γ· executed > 98%
Flaky rate Flaky Γ· executed < 2%
Suite runtime P95 wall-clock PR < 5 min, full < 20 min
MTTR on red main Break β†’ green < 60 min
Coverage of critical paths Automated Γ· total P0/P1 journeys 100%
Regression cycle time Manual regression hours per release ↓ trending
Test maintenance cost Engineer-hours/sprint on test upkeep < 10% of QA capacity
Bug-to-test conversion % of prod escapes that got a regression test 100%

Vanity metrics to explicitly reject (saying this scores well):

  • Number of tests. 3,000 tests is not better than 500 if 2,500 are duplicates.
  • Line/code coverage of the app from E2E. E2E is the wrong tool for coverage measurement.
  • % of test cases automated. Encourages automating things that shouldn't be.

The framing to close with:

"The single question I want to answer for leadership is: 'if this suite is green, can we ship?' Every metric I track feeds that. Defect escape rate tells me if the suite is catching things. Flaky rate tells me if people trust it. Runtime tells me if people will wait for it. If all three are healthy, the suite is doing its job β€” and the raw test count is irrelevant."


Q99. Tell me about the hardest automation problem you've solved.

Answer. (Use STAR. Here's a template with a realistic, technically rich example β€” adapt it to your own experience.)

Situation. A B2B SaaS platform with a 1,400-test Playwright suite. Runtime had grown to 78 minutes; flaky rate was 14%. Engineers had started merging with a red pipeline because "it's probably just flake." Two genuine production regressions shipped in one quarter because real failures were dismissed as noise.

Task. Restore trust in the suite. My targets: runtime under 20 minutes, flake rate under 2%, within one quarter, without dropping coverage of critical paths.

Action.

  1. Measured before changing anything. Built a reporter that pushed per-test duration and pass/fail/flaky outcomes to a dashboard, and ran the suite 20 times over a weekend. That gave me a ranked list of the actual worst offenders rather than the ones people complained about loudest.
  2. Root-caused the flake, didn't retry it away. 61% of flakes traced to three causes: non-retrying assertions (expect(await loc.textContent())), shared test accounts colliding across parallel workers, and a third-party chat widget intercepting clicks. I fixed each structurally β€” a lint rule banning the assertion pattern, per-worker account fixtures, and route-blocking for third parties.
  3. Attacked runtime at the source. UI login was replaced with storageState from a setup project (βˆ’22 min). Test data moved from UI creation to API seeding (βˆ’19 min). CI sharded 4-way (βˆ’60% wall clock).
  4. Audited for redundancy. With the product team, I mapped tests to user journeys. 380 tests were asserting validation rules and pricing calculations that belonged in unit tests. I worked with the dev leads to move those assertions down the pyramid, and deleted the E2E versions.
  5. Made failures self-diagnosing. trace: 'on-first-retry', Slack alerts linking straight to the failing trace, and a CODEOWNERS-based routing map so failures pinged the owning team, not a shared QA channel.

Result. Runtime 78 β†’ 11 minutes. Flaky rate 14% β†’ 1.3%. Test count dropped from 1,400 to 620, while critical-path coverage went up because the audit surfaced four uncovered journeys. Defect escape rate fell by roughly half over the following two quarters.

Reflection (say this β€” interviewers weight it heavily).

"The biggest lesson was that I initially treated it as a technical problem and reached for retries. That made the dashboard green and made the actual situation worse, because it hid real race conditions. The real fix was cultural: making flakiness visible and owned rather than tolerated. I'd also start the redundancy audit earlier β€” deleting 780 tests turned out to be higher-leverage than optimising them, and it took me two months to be willing to propose deleting tests."


Q100. Where do you see test automation heading, and how do you keep your skills current?

Answer.

Technical trends I'd name:

  1. AI-assisted authoring and self-healing. Playwright's page.pause() + codegen already generate locators; LLM-based tools now propose locator repairs when the DOM changes. My position: useful for the first draft and for triage suggestions, dangerous as an auto-merge. A self-healing locator that silently rebinds to a different element converts a caught bug into a missed one. I'd accept AI suggestions behind human review, never behind an automatic commit.

  2. Shift-left and shift-right converging. Contract tests and component tests are absorbing what used to be E2E coverage; synthetic monitoring and feature-flagged canary releases are absorbing the rest. The E2E layer is getting thinner and sharper, which I think is correct.

  3. Accessibility and performance as default gates. @axe-core/playwright and Lighthouse assertions in the same pipeline as functional tests. Increasingly a legal requirement (EAA in the EU from 2025), not a nice-to-have.

  4. Playwright as a general browser-automation runtime, not just a test tool β€” scraping, agent frameworks, and RPA are all standardising on it, which means the API will keep getting better and the talent pool will keep growing.

  5. Visual and semantic diffing getting good enough to replace large classes of assertion-heavy tests.

How I stay current (be specific β€” vague answers here are a red flag):

  • Read the Playwright release notes for every minor version β€” the API moves fast and features like page.clock, routeWebSocket, and mergeTests all shipped quietly and changed how I write tests.
  • Follow the repo's issues and discussions; the maintainers' reasoning in issue threads is better documentation than the docs.
  • Build something non-trivial outside work each quarter β€” I learn an API properly only by hitting its edges.
  • Teach it. Writing a walkthrough or mentoring a junior forces me to find the parts I only think I understand.
  • Read post-mortems from other teams' testing failures β€” more instructive than success stories.

The closing note I'd leave them with:

"The tools will keep changing β€” I've migrated from QTP to Selenium to Cypress to Playwright. What transfers is the thinking: knowing what's worth testing, where in the stack to test it, how to make a suite people trust, and how to argue for quality in terms the business cares about. I optimise for that, and I treat the specific tool as an implementation detail with a five-year half-life."


πŸŽ‰ You've reached the end β€” all 100 questions


βœ… Final 20-Point Revision Checklist

Before your interview, make sure you can explain each of these in under 60 seconds:

  • [ ] Playwright architecture β€” single WebSocket vs Selenium's HTTP
  • [ ] Browser β†’ BrowserContext β†’ Page β†’ Frame β†’ Locator hierarchy
  • [ ] Locator vs ElementHandle (lazy vs eager)
  • [ ] The five actionability checks, especially receives events
  • [ ] Web-first assertions β€” await expect(loc) vs expect(await loc...)
  • [ ] Strict mode and how to resolve a violation properly
  • [ ] Locator priority: role β†’ label β†’ placeholder β†’ text β†’ testid β†’ CSS β†’ XPath
  • [ ] filter({ has, hasText, hasNot, hasNotText }), and(), or()
  • [ ] Fixtures vs hooks; test-scope vs worker-scope; the dependency DAG
  • [ ] storageState β€” what it captures and what it misses
  • [ ] Setup projects + dependencies vs globalSetup
  • [ ] page.route() β€” fulfill, continue, abort, fallback, route.fetch()
  • [ ] Workers vs shards; merge-reports with blob
  • [ ] fullyParallel and describe.configure({ mode })
  • [ ] Trace Viewer β€” what's in a trace and your triage decision tree
  • [ ] Why waitForTimeout and networkidle are anti-patterns
  • [ ] Your flakiness diagnostic process (four steps, memorised)
  • [ ] TypeScript: base.extend<Test, Worker>(), as const, floating promises
  • [ ] Your position on POM (hybrid: component objects + fixtures)
  • [ ] Your metrics: defect escape rate, flaky rate, runtime, MTTR

πŸ—ΊοΈ Suggested Preparation Plan

Days Focus Questions
1–2 Architecture + locators β€” the L1 filter Q1–Q26
3–4 Waiting, flakiness, fixtures β€” the L2 core Q27–Q48
5–6 Config, parallelism, network, API Q49–Q70
7 Auth, security, TypeScript depth Q71–Q88
8 Framework design + leadership scenarios Q89–Q100
9 Build something. Write a 10-test suite from scratch with fixtures, POM, and CI β€”
10 Mock interview β€” answer Q99 and Q90 out loud, timed β€”

The single highest-leverage prep activity: build a small real framework end-to-end. Interviewers can tell within two questions whether you've used fixtures or only read about them.


🎁 MEGA SALE β€” Flat 90% OFF on ALL 100+ eBooks & Bundles

Coupon code: JUPITER90

πŸ“š Explore the complete HimanshuAI Digital Playbook Store

πŸ‘‰ https://himanshuai.gumroad.com/


If you're planning to master Playwright, TypeScript, AI Testing, Salesforce, API Testing, Java, Python, Cypress, Selenium, or System Design, this is the best opportunity to build your complete technical library at a fraction of the regular price.

Track What you'll master
🎭 Playwright + TypeScript Framework design, fixtures, POM, CI/CD, flakiness elimination
πŸ”· TypeScript Generics, utility types, decorators, strict-mode discipline
πŸ€– AI Testing LLM-assisted authoring, self-healing locators, AI test strategy
☁️ Salesforce Lightning testing, Apex, integration patterns
πŸ”Œ API Testing REST, GraphQL, contract testing, schema validation
β˜• Java Core, collections, concurrency, Selenium + TestNG
🐍 Python Pytest, automation scripting, data handling
🌲 Cypress Commands, intercepts, component testing
πŸ•ΈοΈ Selenium Grid, waits, migration paths
πŸ—οΈ System Design Scalability, architecture rounds, trade-off reasoning

⏳ Use coupon JUPITER90 at checkout β€” flat 90% OFF on everything.

πŸ‘‰ https://himanshuai.gumroad.com/


All the best for your interview. Go get that offer. πŸš€

Found this useful? Share it with someone who's preparing.

Top comments (0)