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
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
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
Why this matters:
- 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.
-
True isolation. No cookie bleed, no leftover
localStorage, no "test 7 fails only when test 3 runs first." - 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();
});
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
| 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
);
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();
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) ...
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();
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');
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
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();
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
},
});
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
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
});
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:
-
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)];
-
process.env.TEST_PARALLEL_INDEXβ the parallel slot; stays stable even when a worker restarts. - 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.
-
workers: 1forces 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
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' });
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();
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);
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();
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');
Critical distinction (L2 favourite):
-
frameLocator()β lazy, re-resolves, no stale frame errors β use this -
page.frame()/page.frames()β returns aFrameobject 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
);
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;
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;
});
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,-0survive (Playwright uses a superset of JSON) - Functions,
Symbol, DOM nodes,Windowβ must useevaluateHandle - Closures do not transfer: variables from your test file are not in scope inside
evaluateunless 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);
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();
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');
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();
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();
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
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();
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' });
name matches the ACCESSIBLE NAME, computed by the browser via the W3C accname algorithm, in this priority:
-
aria-labelledby(points to another element's text) aria-label- Native label:
<label for>,<caption>,alt,title,placeholder - Text content of the element
<button aria-label="Close dialog">β</button>
await page.getByRole('button', { name: 'Close dialog' }).click(); // β
works β text content is just "β"
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
Crucial behaviour β text matching finds the SMALLEST enclosing element:
<div>
<span>Hello</span> world
</div>
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();
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();
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;
}));
}
}
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);
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();
Change it globally:
// playwright.config.ts
export default defineConfig({
use: {
testIdAttribute: 'data-qa', // or 'data-cy', 'data-test', 'data-automation-id'
},
});
Or at runtime:
import { selectors } from '@playwright/test';
selectors.setTestIdAttribute('data-pw');
Test-id policy that impresses interviewers:
"I treat
data-testidas a public API contract between QA and dev. Rules I enforce:
- Test-ids are added by developers in the same PR as the feature, reviewed by QA.
- Naming convention:
{page}-{component}-{element}βcheckout-payment-submit.- They are stripped from production builds via a Babel/SWC plugin so they cost zero bytes to end users.
- 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
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('βΉ');
}
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)
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();
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();
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 });
When is force: true acceptable? Almost never in product tests. Two defensible cases:
- An element intentionally covered by a decorative overlay with
pointer-events: nonemisconfigured β and you've filed the bug. - 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();
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 },
});
Global config:
export default defineConfig({
expect: {
toHaveScreenshot: {
maxDiffPixelRatio: 0.01,
animations: 'disabled',
scale: 'css',
},
},
snapshotPathTemplate: '{testDir}/__screenshots__/{projectName}/{testFilePath}/{arg}{ext}',
});
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();
});
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
Custom failure messages (huge for triage speed):
await expect(page.getByTestId('total'), 'Order total must include GST')
.toHaveText('βΉ12,400.00');
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}"`,
};
},
});
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 {};
Usage:
import { expect } from '../support/custom-matchers';
await expect(page.getByTestId('total')).toHaveCurrencyValue(12400);
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');
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] });
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();
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',
},
});
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:
-
Eliminate every non-retrying assertion. Lint rule: ban
expect(awaitin test files. -
Ban
waitForTimeout(). ESLintno-restricted-syntaxrule fails the build. -
Unique test data per test.
const email = \user-${crypto.randomUUID()}@test.io``. Never a shared "automation@test.com". -
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. - 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:
- 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.
- Timing per step. The report shows duration per step β you find the slow business operation, not just the slow test.
-
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; }); ` -
box: truecollapses internal noise so failures point at your call site, not inside the POM:`ts await test.step('login', async () => { ... }, { box: true }); ` -
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/afterAllre-run because the worker is new -
test.info().retrygives 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: 2in CI to keep the pipeline green, but I pipe theflakycount 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:
- Ask the team for a test-mode bypass key (reCAPTCHA and hCaptcha both ship official test keys that always pass).
- Env-flag the captcha off in non-prod.
- Bypass the UI entirely β authenticate via API and inject
storageState. - 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();
}, { 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);
}, { 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);
}, { 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:
- A worker-scoped fixture can NOT depend on a test-scoped fixture. (Lifetime violation β the worker outlives the test.) This throws.
- A test-scoped fixture can depend on a worker-scoped one. β
-
beforeAllruns after worker fixtures;beforeEachruns after test fixtures. So fixtures are always available in hooks. - 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:
-
storageStatecaptures cookies +localStorageβ notsessionStorage. If your app stores the token insessionStorage, inject it viaaddInitScript. - Add
playwright/.auth/to.gitignoreβ these files contain live tokens. - Handle token expiry: regenerate per run, or check the JWT
expclaim 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:
- Every test owns its data. No shared "automation@test.com".
- Seed via API, never UI. 50Γ faster and doesn't fail because of an unrelated UI bug.
- Cleanup in fixture teardown, so it runs even on failure/timeout.
- Unique identifiers, so parallel workers can't collide.
- 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);
}
},
});
`
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/,
},
],
/* ---- 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, noconcurrently, no sleep-and-pray in your npm scripts. -
reuseExistingServer: truelocally means you don't restart your dev server on every run (huge DX win);falsein CI guarantees a clean process. - Multiple entries let you boot a whole stack (API + web + mock server).
-
urlvsport:urllets 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:
- Secrets never in the repo β CI secret store (GitHub Secrets, Vault, AWS Secrets Manager) only.
- Add
.env*(except.env.example) to.gitignore; commit a.env.examplewith keys and dummy values. - Run gitleaks / trufflehog as a pre-commit hook and a CI gate.
- Mask secrets in logs β
core.setSecret()in Actions. - Distinct low-privilege test accounts per environment; never reuse a real user's credentials.
-
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=oldhad real differences. - Remaining differences: no GPU acceleration by default, different default window chrome, some
window.outerHeightvalues. - 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:
- Determinism β no dependence on backend uptime or data drift.
- Coverage of unreachable states β 500s, timeouts, empty sets, boundary values, rate limits.
- Speed β no network latency, no DB writes.
- 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}$/);
});
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/);
});
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.jsoncommitted 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);
mergeTests
unions 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/*"]
}
},
"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:
- Locators are
readonlyLocator fields, not methods returning strings β they're lazy anyway. -
protected pageβ tests should not reach intopageObject.page.click(...). -
No assertions in POM navigation methods, but domain-level assertion helpers (
expectStatus) are fine and reduce duplication. - Return the next Page Object on navigation so the compiler enforces valid flows.
-
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')]);
Page
Actions on the sameare 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.tsfor tests,*.setup.tsfor setup projects,*.fixture.tsfor fixtures - Barrel exports (
index.ts) so specs import from@fixturesnot../../../src/fixtures/auth.fixture - One
describeper 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
DataGridcomponent object withrowByRef()andsortBy()earns its place. ALoginPage.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
BasePagegod-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
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 }}
`
Details worth calling out in the interview:
-
concurrency+cancel-in-progressstops wasting runners on superseded pushes - Browser caching saves 60β90s per job
-
--reporter=blob+merge-reportsis 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]}`);
}
}
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`);
}
}
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.
- 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.
-
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. -
Attacked runtime at the source. UI login was replaced with
storageStatefrom a setup project (β22 min). Test data moved from UI creation to API seeding (β19 min). CI sharded 4-way (β60% wall clock). - 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.
-
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:
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.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.
Accessibility and performance as default gates.
@axe-core/playwrightand Lighthouse assertions in the same pipeline as functional tests. Increasingly a legal requirement (EAA in the EU from 2025), not a nice-to-have.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.
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, andmergeTestsall 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βLocatorhierarchy - [ ]
LocatorvsElementHandle(lazy vs eager) - [ ] The five actionability checks, especially receives events
- [ ] Web-first assertions β
await expect(loc)vsexpect(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 +
dependenciesvsglobalSetup - [ ]
page.route()βfulfill,continue,abort,fallback,route.fetch() - [ ] Workers vs shards;
merge-reportswithblob - [ ]
fullyParallelanddescribe.configure({ mode }) - [ ] Trace Viewer β what's in a trace and your triage decision tree
- [ ] Why
waitForTimeoutandnetworkidleare 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)