Playwright — Complete Interview Guide (5+ Yrs Automation Engineer)¶
Comprehensive Playwright reference grounded in your actual code at
/Users/rohan/questt/Morrie_automation/playwright-automation/(modern Morrie framework) and/Users/rohan/playwright/Playwright-questtAutomation/playwright-automation-project/(HOAD-BI chatbot Playwright suite). Every concept includes spoken-style interview answers, real examples from your repos, and follow-up questions.
How this file is organised¶
| Section | Topic |
|---|---|
| 1 | Why Playwright + the real comparison |
| 2 | Architecture & internals |
| 3 | Browser → Context → Page (the BPC model) |
| 4 | Locators — basics, filtering, chaining, strict mode |
| 5 | Actions (click, fill, hover, drag, files, keyboard) |
| 6 | Web-first assertions (full list, soft, poll, custom) |
| 7 | Auto-waiting + explicit wait patterns |
| 8 | Custom fixtures (the testFixture.ts pattern) |
| 9 | Test hooks + describe / serial / parallel |
| 10 | Parallel execution, workers, sharding |
| 11 | Network — mocking, interception, HAR, waitForResponse |
| 12 | API testing — request context + Morrie's Axios pattern |
| 13 | Tabs, frames, windows, downloads |
| 14 | Storage state + modern auth.setup.ts pattern |
| 15 | Reporters, trace viewer, codegen, screenshots, video |
| 16 | Debugging deep dive |
| 17 | Visual regression + accessibility + mobile |
| 18 | CI/CD — Jenkinsfile + GitHub Actions + sharding |
| 19 | TypeScript for Playwright |
| 20 | Selenium → Playwright migration narrative |
| 21 | Tech debt I see in my own projects (seniority signal) |
| 22 | 35+ interview Q&A with full spoken answers |
1. WHY PLAYWRIGHT¶
The honest comparison¶
Playwright = browser automation tool by Microsoft, released open-source in 2020. The big wins over Selenium that actually matter in production:
| Win | What it means in real code |
|---|---|
| WebSocket protocol instead of HTTP | One persistent connection vs new HTTP request per command → ~3x faster |
| Auto-wait before every action | Element must be attached + visible + stable + receives events + enabled before click runs. Eliminates ~80% of "implicit wait + sleep" code |
| Web-first assertions that auto-retry | expect(loc).toBeVisible() polls up to 5s by default. No more WebDriverWait boilerplate |
| Single API for all browsers | Same code drives Chromium, Firefox, WebKit (Safari) without separate drivers |
| Built-in parallel execution | workers: 4 in config; no Selenium Grid needed for parallelism |
| Trace Viewer | Visual time-machine of every action with DOM snapshots, network, console — best debugging tool in the test space |
Network mocking via page.route |
First-class request interception; mock APIs, simulate failures, block analytics |
| Bundled browsers | npx playwright install chromium gets you a tested browser; no version mismatch with driver |
| Multi-tab/context out of the box | New tab = context.newPage(); new isolated session = browser.newContext() |
Memory hook¶
"WAIT-SAFE" = WebSocket, Auto-wait, Intercept (mocking), Trace Viewer; Safari/WebKit, Assertion-retry, Fixtures, Easy parallel.
2. ARCHITECTURE & INTERNALS¶
How Playwright talks to the browser¶
your test (Node.js)
↓
Playwright client library
↓ (single WebSocket connection)
playwright-driver (browser process)
↓ (Chrome DevTools Protocol / Firefox Remote / WebKit Inspector)
the browser
Why WebSocket beats HTTP¶
- One TCP connection, kept open for the test duration → no per-command handshake cost
- Two-way streaming — browser can push events (download started, dialog opened, network request) without polling
- Binary frames — protocol overhead is lower than HTTP+JSON-Wire
What "bundled browsers" means¶
Each Playwright version ships with a tested patched build of Chromium, Firefox, and WebKit. npx playwright install downloads them. This is why you never hit "ChromeDriver version mismatch" — Playwright owns both ends of the protocol.
3. THE BPC MODEL: BROWSER → CONTEXT → PAGE¶
This is the single most important concept to understand for interviews.
const browser = await chromium.launch(); // OS process; expensive
const context = await browser.newContext(); // isolated session; cheap
const page = await context.newPage(); // a tab inside the context
| Object | Cost to create | What it isolates |
|---|---|---|
| Browser | High (~500ms) — OS process | Engine choice only |
| Context | Low (~5ms) — JS object | Cookies, localStorage, cache, permissions, geolocation, viewport, auth |
| Page | Low — a tab in a context | DOM; shares cookies with sibling pages in same context |
Why this matters¶
- One browser per worker = cheap parallelism
- One context per test = full state isolation, no test pollution
- Multiple pages in one context = test OAuth popups, multi-tab flows easily
Spoken interview answer¶
"Playwright's three-level model is the reason it's faster and cleaner than Selenium. Browser is the OS process — heavyweight. Context is an isolated session — cheap, like incognito. Page is just a tab inside a context. In Selenium, isolating two tests means launching two browsers, which is expensive. In Playwright, I launch one browser per worker and create a fresh context per test — same isolation, fraction of the time."
4. LOCATORS — DEEP¶
4.1 The user-facing priority order¶
| Priority | Locator | Use when |
|---|---|---|
| 1 | getByRole(role, { name }) |
Best — matches accessibility tree |
| 2 | getByLabel(text) |
Form inputs with visible label |
| 3 | getByPlaceholder(text) |
Inputs with placeholder |
| 4 | getByText(text, { exact }) |
Visible text content |
| 5 | getByAltText(text) |
Images |
| 6 | getByTitle(text) |
Elements with title attribute |
| 7 | getByTestId('id') |
When semantic locator doesn't fit |
| 8 | page.locator('css or xpath') |
Last resort |
Real example from your Morrie AgentPage.ts¶
this.agentNameInput = page.getByRole('textbox', { name: 'Name' });
this.agentDescriptionInput = page.getByRole('textbox', { name: 'System Prompt' });
this.createAgentBtn = page.getByRole('button', { name: 'Create' });
this.confirmDeleteBtn = page.getByRole('button', { name: 'Delete' });
this.updateAgentBtn = page.getByRole('button', { name: 'Update' });
getByRole + name is the first thing a screen-reader user sees. It survives CSS class renames, ID changes, even DOM structure changes — as long as the button still says "Create" and is a <button>, the locator works.
Where you fell back to CSS — and why¶
The honest reason:data-slot="button" is a Shadcn UI / Radix component attribute. Multiple buttons render with the same accessible name, so getByRole('button', { name: 'Create Agent' }) would hit strict-mode error. Falling back to CSS with the attribute disambiguates without hacks.
Where pure CSS was best (HOAD-BI chat suite)¶
Why: The chatbot uses Tailwind Typography classes —getByRole would have to walk up to region or article, much less specific. The CSS class is stable infrastructure (Tailwind), so it's reliable.
4.2 Locator filtering (the #1 advanced topic)¶
.filter({ hasText }) — keep elements containing text¶
// AgentPage.ts — find the row that has the agent name
this.agentRows = page
.locator('nav')
.locator('div')
.filter({ has: page.locator('span.text-sm') });
private getAgentRow(agentName: string): Locator {
return this.agentRows.filter({ hasText: agentName }).last();
}
agentRows = "all divs inside nav that contain a span.text-sm" (structural filter using has:)
- getAgentRow(name) = "of those, keep ones whose visible text includes the agent name, take the last"
.filter({ has }) — keep elements containing another locator¶
page.getByRole('row').filter({ has: page.getByRole('button', { name: 'Edit' }) });
// "rows that contain an Edit button"
.filter({ hasNot, hasNotText }) — the inverse¶
page.locator('.task').filter({ hasNot: page.locator('.completed') });
// "tasks that don't have a .completed child"
.first(), .last(), .nth(i) — positional disambiguation¶
Used when multiple matches are expected and you want a specific one.
Strict mode (the gotcha!)¶
By default, Playwright actions on a locator throw if it matches more than one element.
Fix: disambiguate with filter / nth / first.Interview question — "What is strict mode and why does Playwright have it?"¶
"Strict mode is Playwright's safety net against silently clicking the wrong element. When a locator action — click, fill, etc. — runs against a locator that matches more than one element, Playwright throws a strict-mode violation instead of guessing. This is the opposite of Selenium, where
findElementsilently returns the first match. The Playwright design forces you to be intentional: if you want a specific match, you use.filter,.nth, or.firstand say so explicitly. In real code, I use it in my Morrie AgentPage whereagentRows.filter({ hasText: name }).last()picks the most recent matching row — explicit, predictable."
4.2.5 Locator.or() — fallback chain (PageBuilderPage pattern)¶
When the same logical element may render under two different selectors — say the dev team is mid-migration from Material UI to Shadcn — .or() lets you express "try this, else this" in one locator.
Real example from your Morrie PageBuilderPage.ts¶
this.pageNameInput = page
.getByLabel(/page\s+name/i)
.or(page.locator('input[placeholder*="page name" i]'));
this.routingUrlInput = page
.getByLabel(/routing\s+url|route|path/i)
.or(page.locator('input[placeholder*="routing" i]'));
this.createFormBtn = page
.getByRole('button', { name: /create|save/i })
.first();
How .or() evaluates:
1. Resolves both locators
2. Returns the first one that has at least one match
3. If both have matches, you'll hit strict mode unless you .first() afterwards
When this is the right tool¶
- Mid-migration apps where you can't rely on one selector
- A/B-tested UIs where the same component renders in two layouts
- Forms that swap label-vs-placeholder depending on viewport
Honest framing — when it's a smell¶
"
.or()is a fallback for instability. In my PageBuilderPage I lean on it heavily because the page is mid-migration to a new design system — labels become placeholders and back depending on which version of the component is mounted. The clean alternative is adata-testidfrom the dev team. Using.or()everywhere is a code smell — it tells you the page lacks stable selectors. I use it as a bridge, not a destination."
4.2.6 Regex locators (case-insensitive, partial matching)¶
All the getBy* locators accept regex as the name option — case-insensitive and partial matching come free.
Real examples from your Morrie code¶
// PageBuilderPage — matches "Page Name", "page-name", "Page Name", etc.
page.getByLabel(/page\s+name/i)
// Matches "Create", "Save", "Create New", "Save Draft", etc.
page.getByRole('button', { name: /create|save/i })
// Matches the cancel button regardless of exact casing
page.getByRole('button', { name: /cancel/i })
// AgentPage — wait for URL fragment
await this.page.waitForURL(/agent/i);
Memory rule¶
| Need | Use |
|---|---|
| Exact match | getByRole('button', { name: 'Submit' }) |
| Case-insensitive exact | getByRole('button', { name: /^Submit$/i }) |
| Substring (case-insensitive) | getByRole('button', { name: /submit/i }) |
| Any of several names | getByRole('button', { name: /submit\|save\|continue/i }) |
| Excluding noise | getByRole('button', { name: /^submit$/i }).first() |
Spoken answer — "When would you use regex in a locator?"¶
"Three real scenarios. First, the button label varies across screens —
Createon one form,Saveon another, but logically the same action. A regex/create\|save/imatches both, so my Page Object stays simple. Second, whitespace variability — when a label is rendered with non-breaking spaces or multiple spaces,/page\\s+name/iis more robust than exact. Third, design-system migrations — labels migrate from sentence-case to lowercase or back; regex insulates the test from that churn. The trade-off is the locator is slightly less explicit, so I add a comment when the intent isn't obvious."
4.3 Chaining and scoping¶
// Real example: find the delete button INSIDE a specific agent row
private getDeleteBtn(row: Locator): Locator {
return row.locator('button[data-variant="ghost"]').last();
}
row's subtree only
- Re-querying — every call to getDeleteBtn(row) re-resolves both row and the inner button against the current DOM (no stale references)
Locator vs ElementHandle (important interview point)¶
| Locator | ElementHandle | |
|---|---|---|
| Behaviour | Lazy — re-queries DOM on each action | Eager — snapshot of element at one point in time |
| Staleness | Never (re-resolves) | Easily becomes stale |
| Recommended? | ✅ Always | ❌ Avoid; use only for special cases (file handles, etc.) |
| Created by | page.locator(...), page.getBy* |
page.$(selector), locator.elementHandle() |
5. ACTIONS¶
5.1 Click variants¶
await page.click('#submit'); // basic
await page.click('#submit', { force: true }); // skip actionability checks
await page.click('#submit', { trial: true }); // dry-run — just check actionability
await page.click('#submit', { position: { x: 10, y: 10 } }); // pixel offset
await page.click('#submit', { button: 'right' }); // right click
await page.click('#submit', { clickCount: 2 }); // double click
await page.click('#submit', { modifiers: ['Control'] }); // Ctrl+click
5.2 Fill vs Type vs pressSequentially¶
await page.fill('#email', 'a@x.com');
// Sets the value via DOM input event. FAST. No keystroke events.
await page.type('#search', 'hello', { delay: 100 });
// (deprecated; use pressSequentially) types char by char.
await emailInput.pressSequentially('a@x.com', { delay: 100 });
// One real keydown/keyup/input event per char. Slow but triggers React onKeyDown.
The real gotcha (from your Morrie global-setup.ts)¶
await emailInput.pressSequentially(TEST_EMAIL, { delay: 100 });
await emailInput.blur();
await expect(continueBtn).toBeEnabled({ timeout: 50_000 });
await continueBtn.click();
pressSequentially instead of fill:
"The Morrie sign-in is React-based and validates the email field on every
onKeyDown.fillsets the value viaHTMLInputElement.valueand dispatches a singleinputevent — React's keystroke validation doesn't fire, so the Continue button stays disabled.pressSequentiallysimulates real typing, firing keydown/keyup/input per character. Theblur()after forces final validation. Without these two changes the test stalls on a disabled Continue button."
5.3 Form helpers¶
await page.check('#agree'); // checkbox / radio
await page.uncheck('#newsletter');
await page.selectOption('#country', { label: 'India' }); // by label
await page.selectOption('#country', 'IN'); // by value
await page.selectOption('#country', { index: 2 }); // by index
await page.selectOption('#multi', ['a', 'b']); // multi-select
await page.setInputFiles('#upload', 'tests/data/file.pdf'); // file upload
await page.setInputFiles('#upload', []); // clear selection
5.4 Hover, drag, keyboard¶
await page.hover('#menu');
await page.dragAndDrop('#source', '#target');
await page.keyboard.press('Enter');
await page.keyboard.press('Control+A');
await page.keyboard.down('Shift');
await page.keyboard.up('Shift');
await page.keyboard.type('hello world', { delay: 50 });
Real hover-then-click pattern (your AgentPage deleteAgent)¶
async deleteAgent(agentName: string) {
const agentRow = this.getAgentRow(agentName);
await expect(agentRow).toBeVisible();
await agentRow.hover(); // reveal action buttons
const deleteBtn = this.getDeleteBtn(agentRow);
await deleteBtn.click();
await expect(this.confirmDeleteBtn).toBeVisible();
await this.confirmDeleteBtn.click();
}
:hover reveal). Without .hover() first, the click would either fail (element invisible) or click the wrong element.
6. WEB-FIRST ASSERTIONS¶
6.1 The full list (memorize the most-used ones)¶
Visibility / state¶
await expect(loc).toBeVisible();
await expect(loc).toBeHidden();
await expect(loc).toBeAttached(); // in DOM (not necessarily visible)
await expect(loc).toBeEnabled();
await expect(loc).toBeDisabled();
await expect(loc).toBeChecked();
await expect(loc).toBeEditable();
await expect(loc).toBeFocused();
await expect(loc).toBeEmpty();
await expect(loc).toBeInViewport();
Text / value / attribute¶
await expect(loc).toHaveText('exact');
await expect(loc).toHaveText(/regex/);
await expect(loc).toContainText('substring');
await expect(loc).toHaveValue('input value');
await expect(loc).toHaveAttribute('href', '/dashboard');
await expect(loc).toHaveClass(/btn-primary/);
await expect(loc).toHaveCSS('color', 'rgb(255, 0, 0)');
await expect(loc).toHaveId('user-card');
await expect(loc).toHaveJSProperty('disabled', true);
Counts / collections¶
await expect(rows).toHaveCount(3);
await expect(items).toHaveText(['Apple', 'Banana']); // text per element
Page-level¶
await expect(page).toHaveTitle(/Dashboard/);
await expect(page).toHaveURL(/.*\/agent/);
await expect(page).toHaveScreenshot(); // visual regression
Response-level (for API tests)¶
6.2 Auto-retry behaviour¶
All expect(locator).to* assertions auto-retry up to expect.timeout (default 5000ms) until they pass. No manual waits needed.
Real example from e2e-lifecycle.spec.ts:
await expect(page.getByText(updatedName).first()).toBeVisible();
// Auto-retries up to 5s for the updated name to appear
6.3 expect.soft — collect failures, don't stop¶
await expect.soft(page.getByText('Welcome')).toBeVisible();
await expect.soft(page.getByText('Logout')).toBeVisible();
await expect.soft(page.getByText('Settings')).toBeVisible();
// Even if Welcome fails, all three are still checked. Test fails at end if any soft failed.
6.4 expect.poll — custom condition polling¶
// Poll until cart count is >= 3
await expect.poll(async () => {
const text = await page.locator('#cart-count').innerText();
return parseInt(text, 10);
}, {
message: 'cart count never reached 3',
timeout: 15_000,
intervals: [500, 1000, 2000], // back off
}).toBeGreaterThanOrEqual(3);
6.5 expect.toPass — retry an arbitrary block¶
await expect(async () => {
const res = await request.get('/api/job/123');
expect(res.status()).toBe(200);
expect((await res.json()).status).toBe('completed');
}).toPass({ timeout: 30_000 });
6.6 Custom matchers — extend expect¶
expect.extend({
async toHaveTransactionId(received: Locator) {
const text = await received.textContent();
const pass = /^TXN-\d{10}$/.test(text || '');
return {
pass,
message: () => pass
? `expected ${text} not to match transaction ID format`
: `expected ${text} to match TXN-XXXXXXXXXX`,
};
},
});
// In test
await expect(page.locator('#txn-id')).toHaveTransactionId();
6.7 Shape assertions for API responses (Morrie pattern)¶
When you want to assert "the response has these fields with these types" without pinning every value — common for API tests where IDs and timestamps are server-generated — use expect.objectContaining plus expect.any().
Real example from your Morrie users.api.spec.ts¶
test('GET user detail', async ({ userApi }) => {
const res = await userApi.userDetail();
expect(res.status).toBe(200);
expect(res.data).toEqual(
expect.objectContaining({
email: expect.any(String),
name: expect.any(String),
role: expect.any(String),
_id: expect.any(String),
})
);
});
What this asserts:
- Response has email, name, role, _id — all strings
- Extra fields are OK (the contract is "at least these fields")
- Specific values are not checked — useful for dynamic data
When to use each¶
| Matcher | Use when |
|---|---|
expect.any(String) |
Field exists, is a string, value doesn't matter |
expect.any(Number) |
Same for numbers |
expect.any(Array) |
Field is an array (any contents) |
expect.any(Object) |
Field is a non-null object |
expect.objectContaining({...}) |
Object has at least these fields (extras allowed) |
expect.arrayContaining([...]) |
Array has at least these elements |
expect.stringMatching(/regex/) |
String matches regex |
expect.stringContaining('substring') |
String includes substring |
Status-array assertion — accept either of two valid codes¶
Use when: the API contract allows either status — e.g., DELETE may return 200 with body or 204 no-content depending on the backend's implementation. The test should accept both rather than break when the backend optimizes.Real example from your users.api.spec.ts PATCH¶
test('PATCH users updates username', async ({ userApi }) => {
const newUsername = `user_${Date.now()}`;
const res = await userApi.updateUserName(newUsername);
expect([200, 204]).toContain(res.status);
// If API returns body
if (res.data) {
expect(res.data).toHaveProperty('name', newUsername);
}
});
user_${Date.now()} — unique value per run for parallel test independence
- Conditional body assertion — only check body if the response includes one (defensive code that works regardless of whether the API picks 200 or 204)
Spoken answer — "How do you assert API response shape when values are server-generated?"¶
"Use
expect.objectContainingplusexpect.any(). The pattern says 'the response has at least these fields with these types' without pinning the actual values. For example, in my Morrie users API test, I assert_id,name,roleare all strings — I don't care about the specific values, just that the contract holds. This is the right level for most positive tests because it's stable across data changes and catches contract drift. I combine it with status-array assertions likeexpect([200, 204]).toContain(res.status)for endpoints where multiple statuses are valid — keeps the test from breaking when the backend optimizes a DELETE from 200-with-body to 204."
7. AUTO-WAITING + EXPLICIT WAITS¶
7.1 What auto-wait checks before every action¶
| Check | Meaning |
|---|---|
| Attached | Element is in the DOM |
| Visible | Has non-empty bounding box and not display: none / visibility: hidden |
| Stable | Hasn't moved in the last two animation frames |
| Receives events | Hit-testing at element center returns this element, not an overlay |
| Enabled | Not disabled (for buttons / inputs) |
Default action timeout = 0 (relies on these checks). Default navigation timeout = 30s. Default expect timeout = 5s.
7.2 When you DO need explicit waits¶
Navigation¶
await page.waitForURL('**/dashboard');
await page.waitForURL(/.*\/order\/\d+/);
await page.waitForLoadState('networkidle'); // no network for 500ms
await page.waitForLoadState('domcontentloaded');
await page.waitForLoadState('load');
Network¶
// Wait for specific API response
const resp = await page.waitForResponse(r =>
r.url().includes('/api/users') && r.status() === 200
);
const data = await resp.json();
Wait for selector with state¶
await page.waitForSelector('.spinner', { state: 'hidden' });
await page.waitForSelector('.results', { state: 'visible', timeout: 10_000 });
Real example from HOAD-BI hoadChatPage.ts¶
// Wait for the textarea to disable (chatbot is processing)
await this.page.waitForSelector('textarea[disabled]', { timeout: 480000 });
// Then wait for it to enable again (response loaded)
await this.page.waitForSelector('textarea:not([disabled])', { timeout: 880000 });
textarea[disabled] then textarea:not([disabled]) captures the start and end of the response generation — far more reliable than waitForTimeout(15000) would be.
7.3 waitForFunction — custom JS condition (HOAD-BI pattern)¶
// From hoadChatPage.ts — wait for response text to stabilise
await this.page.waitForFunction(
(selector) => {
const elements = document.querySelectorAll(selector);
if (elements.length === 0) return false;
const lastElement = elements[elements.length - 1];
const text = lastElement.textContent || '';
return new Promise(resolve => {
setTimeout(() => {
const newText = lastElement.textContent || '';
resolve(text === newText && text.trim().length > 0);
}, 2000);
});
},
'.prose.prose-lg',
);
true only when the text is the same after 2 seconds and non-empty
- This is how you detect a "streaming LLM response has finished streaming" — there's no DOM event for it
When to use waitForFunction¶
- Stability checks (LLM streaming, animations)
- Conditions involving
windowproperties not exposed as events - Anything you'd write a custom poll for
7.4 Anti-pattern alert¶
Your AgentPage uses this in multiple places —await this.page.waitForTimeout(2000) after create/update/delete. This is the #1 tech-debt callout for that file. Right approach: wait for a DOM signal that the action completed (toast, list refresh, button state). The honest interview line:
"Yes, I have a few
waitForTimeout(2000)calls inAgentPage— they're tech debt from when I was working around timing issues during initial framework setup. The right pattern is to wait for the UI signal:await expect(toast).toBeVisible()orawait expect(this.agentRows).toHaveCount(prevCount + 1). I'd refactor those out as I add more tests around the same flow."
8. CUSTOM FIXTURES — THE testFixture.ts PATTERN¶
8.1 What fixtures replace¶
Pytest's @fixture or JUnit's @BeforeEach — but better:
- Lazy: only run if test actually requests them
- Composable: fixtures can depend on other fixtures
- Scoped: test or worker
- Typed: generic type parameter ensures compile-time safety
8.2 Your Morrie testFixture.ts (real code)¶
import { test as base, expect } from '@playwright/test';
import { AuthApi } from '../api/AuthApi';
import { AgentsApi } from '../api/AgentsApi';
import { AgentPage } from '../pages/agentPage';
import { LoginPage } from '../pages/LoginPage';
type TestFixtures = {
authApi: AuthApi;
agentsApi: AgentsApi;
agentPage: AgentPage;
loginPage: LoginPage;
};
export const test = base.extend<TestFixtures>({
authApi: async ({}, use) => {
const api = new AuthApi(process.env.API_BASE!);
await use(api);
},
agentsApi: async ({}, use) => {
const api = new AgentsApi(process.env.API_BASE!);
await use(api);
},
agentPage: async ({ page }, use) => {
await use(new AgentPage(page));
},
loginPage: async ({ page }, use) => {
await use(new LoginPage(page));
},
});
export { expect };
What every line does¶
| Line | Purpose |
|---|---|
test as base |
Aliases Playwright's stock test so we can extend it |
type TestFixtures = {...} |
TypeScript generic — gives us autocomplete + compile-check on every fixture name |
base.extend<TestFixtures>({...}) |
Returns a new test object that knows about our fixtures |
async ({}, use) => {...} |
Fixture function. Empty destructure means "doesn't depend on built-in fixtures." use(api) hands the value to the test. Anything after use(...) is teardown. |
agentPage: async ({ page }, use) => {...} |
Depends on built-in page fixture; constructs AgentPage(page) and hands it to the test. |
Why this pattern wins over beforeEach¶
// Tests now look like
import { test, expect } from '../../src/fixtures/testFixture';
test('create agent', async ({ agentPage }) => {
await agentPage.goto();
await agentPage.createAgent('Q1', 'be helpful');
// ...
});
agentsApi, it doesn't run
- Refactor safety — if you rename agentPage in the fixture, TypeScript catches every test that uses it
8.3 Fixture scopes — test vs worker¶
type Worker = { dbPool: Pool };
export const test = base.extend<{}, Worker>({
dbPool: [async ({}, use) => {
const pool = await createPool();
await use(pool);
await pool.end();
}, { scope: 'worker' }],
});
test (default) | Each test | Browser context, fresh data, page-specific helpers |
| worker | Each parallel worker process | DB connection pool, expensive once-per-worker setup |
8.4 auto: true — run without being requested¶
metricLogger: [async ({}, use, testInfo) => {
const start = Date.now();
await use();
testInfo.attach('duration_ms', { body: String(Date.now() - start) });
}, { auto: true }],
8.5 Overriding built-in fixtures¶
export const test = base.extend({
page: async ({ page }, use) => {
page.on('console', msg => console.log('[browser]', msg.text()));
page.on('pageerror', err => console.error('[browser-error]', err));
await use(page);
},
});
8.6 Composing fixtures¶
export const test = base.extend<{
authToken: string;
authedRequest: APIRequestContext;
}>({
authToken: async ({ request }, use) => {
const res = await request.post('/auth/login', { data: creds });
const { token } = await res.json();
await use(token);
},
authedRequest: async ({ playwright, authToken }, use) => {
const ctx = await playwright.request.newContext({
extraHTTPHeaders: { Authorization: `Bearer ${authToken}` },
});
await use(ctx);
await ctx.dispose();
},
});
authedRequest depends on authToken. Playwright resolves the dependency chain automatically.
9. TEST HOOKS¶
test.beforeAll(async ({ browser }) => { /* once per worker per file */ });
test.beforeEach(async ({ page }) => { /* before every test */ });
test.afterEach(async ({ page }, testInfo) => {
if (testInfo.status !== testInfo.expectedStatus) {
await page.screenshot({ path: `failures/${testInfo.title}.png` });
}
});
test.afterAll(async () => { /* cleanup */ });
test.describe + serial (real Morrie pattern)¶
// From agent.spec.ts
test.describe.serial('Morrie agent', () => {
test('agent loads for logged-in user', async ({ agentPage }) => {...});
test('clicking Create Agent navigates...', async ({ agentPage }) => {...});
test('updating an agent', async ({ agentPage }) => {...});
test('delete agent', async ({ agentPage }) => {...});
});
.serial: these tests share state (create → update → delete the same agent). They must run in order in the same worker, even if fullyParallel: true globally.
test.describe.parallel — opposite¶
Forces parallel even within a single file (default is serial within a file unless fullyParallel is set).
10. PARALLEL EXECUTION, WORKERS, SHARDING¶
10.1 Workers¶
A worker = a separate Node.js process with its own browser instance. Configure in playwright.config.ts:
// Your Morrie config
workers: isCI ? 2 : undefined, // 2 in CI, auto-detect locally
fullyParallel: true, // files in parallel, tests within file in parallel
retries: isCI ? 1 : 0,
How worker count affects parallelism¶
- Local dev:
undefined→~50% of cores(so 4 cores = 2 workers) - CI: 2 (your config) — keeps it bounded for shared CI runners
- Total parallelism = workers ×
fullyParallel
10.2 forbidOnly — safety in CI¶
Fails the entire CI run if any test has .only. Prevents accidental "I left it in" disasters.
10.3 Sharding — splitting the suite across machines¶
npx playwright test --shard=1/4 # this machine runs 25% of tests
npx playwright test --shard=2/4
npx playwright test --shard=3/4
npx playwright test --shard=4/4
Sharding in GitHub Actions¶
strategy:
fail-fast: false
matrix:
shard: [1/4, 2/4, 3/4, 4/4]
steps:
- run: npx playwright test --shard=${{ matrix.shard }}
- uses: actions/upload-artifact@v4
with:
name: report-${{ strategy.job-index }}
path: blob-report/
Spoken answer¶
"Workers give parallelism within one machine — separate Node processes, each with its own browser. Sharding gives parallelism across machines. We typically combine them: 4 shards in CI, each shard runs with 2 workers. That's effectively 8 parallel tests on a 4-machine matrix. Each shard produces a blob report; we merge them into one HTML report at the end so the developer sees one unified view."
10.4 Test independence is mandatory¶
With parallel + sharding, tests can run in any order on any machine. So:
- No shared mutable state (no let userId at module scope unless inside .serial)
- Unique data per test — UUIDs, timestamps
- Self-cleaning — each test deletes what it creates
11. NETWORK MOCKING + INTERCEPTION¶
11.1 page.route — intercept and respond¶
await page.route('**/api/users', route => {
route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify([{ id: 1, name: 'Mocked' }]),
});
});
11.2 Modify and forward¶
await page.route('**/api/orders', async route => {
const original = await route.fetch();
let body = await original.json();
body.discount = 99; // tamper with response
await route.fulfill({ response: original, json: body });
});
11.3 Block unwanted requests (speed boost)¶
await page.route('**/*.{png,jpg,gif,woff2}', route => route.abort());
await page.route('**/analytics.js', route => route.abort());
11.4 Continue with modifications¶
await page.route('**/api/login', route => {
route.continue({
postData: JSON.stringify({ ...original, hijacked: true }),
headers: { ...route.request().headers(), 'X-Test': 'true' },
});
});
11.5 waitForResponse — listen, don't intercept¶
const responsePromise = page.waitForResponse(r =>
r.url().includes('/api/agents') && r.status() === 201
);
await page.click('#create'); // triggers the request
const response = await responsePromise;
const data = await response.json();
expect(data._id).toBeDefined();
Real example from your AgentPage.deleteAllAgents — listening for 429¶
const responsePromise = this.page.waitForResponse(response =>
response.status() === 429 ||
response.status() === 200 ||
response.status() === 204,
{ timeout: 5000 }
).catch(() => null);
await this.confirmDeleteBtn.click();
const response = await responsePromise;
if (response && response.status() === 429) {
const backoff = Math.pow(2, attempt + 1) * 1000;
await this.page.waitForTimeout(backoff); // exponential backoff
}
11.6 HAR recording + replay¶
// Record once
await context.routeFromHAR('checkout.har', { update: true });
// ... drive the app
// Replay deterministically
const context2 = await browser.newContext();
await context2.routeFromHAR('checkout.har');
// All matching requests served from HAR
12. API TESTING IN PLAYWRIGHT¶
12.1 The built-in request fixture¶
test('API smoke', async ({ request }) => {
const res = await request.post('/api/login', {
data: { email: 'u@x.com', password: 'p' }
});
expect(res.ok()).toBeTruthy();
const body = await res.json();
expect(body.token).toBeDefined();
});
request is an APIRequestContext. It can share cookies with browser contexts (or stay isolated) — useful for authenticated UI tests that need to verify backend state.
12.2 Your Morrie pattern — Axios via custom fixture (not Playwright's request)¶
Your team chose Axios for API tests instead of Playwright's built-in request. The reasoning:
- Axios has battle-tested interceptors for cross-cutting concerns (auth, retry, logging, rate-limit)
- The same AgentsApi / AuthApi classes are usable outside Playwright (in pure Node scripts, in load tests, in CLIs)
Your BaseApi.ts (real code, annotated)¶
import axios, { AxiosInstance, AxiosError } from 'axios';
export class BaseApi {
protected client: AxiosInstance;
private static lastRequestTime = 0;
private static REQUEST_DELAY = 700; // ms — safe spacing for OTP endpoints
constructor(baseUrl: string) {
if (!baseUrl) throw new Error('BaseApi: baseUrl is missing');
this.client = axios.create({
baseURL: baseUrl,
headers: {
...(process.env.ACCESS_TOKEN
? { Authorization: `Bearer ${process.env.ACCESS_TOKEN}` }
: {}),
},
});
// REQUEST INTERCEPTOR — spaces out requests to avoid OTP rate limits
this.client.interceptors.request.use(async (config) => {
const now = Date.now();
const wait = Math.max(0, BaseApi.REQUEST_DELAY - (now - BaseApi.lastRequestTime));
if (wait > 0) {
await new Promise(r => setTimeout(r, wait));
}
BaseApi.lastRequestTime = Date.now();
return config;
});
// RESPONSE INTERCEPTOR — retries on 429 with `Retry-After`
this.client.interceptors.response.use(
res => res,
async (error: AxiosError) => {
const config: any = error.config;
if (!config) throw error;
if (error.response?.status === 429) {
config.__retryCount = config.__retryCount || 0;
if (config.__retryCount >= 3) throw error;
config.__retryCount++;
const retryAfter = Number(error.response.headers['retry-after']) || 3;
await new Promise(r => setTimeout(r, retryAfter * 1000));
return this.client(config);
}
throw error;
}
);
}
}
Spoken answer — "Walk me through BaseApi"¶
"BaseApi is the Axios wrapper that every API class — AuthApi, AgentsApi, UserApi, ChatsApi — extends. Three responsibilities. First, it auto-injects the Authorization header from the ACCESS_TOKEN env var set by global-setup. Second, it has a request interceptor with a static
lastRequestTimeand a 700ms delay — every outgoing request waits if the last one was too recent. This stops us tripping the OTP endpoint's rate limit when global-setup retries. Third, a response interceptor handles 429 — it retries up to 3 times, honouring theRetry-Afterheader, otherwise defaulting to 3 seconds. I madelastRequestTimestatic deliberately so it's shared across every API class instance — true global pacing, not per-class."
Honest tech-debt callout¶
"One thing I'd refactor:
lastRequestTimebeing static means all API clients share one global lock. That works for our scale, but if I wanted to run high-concurrency load-style API tests in parallel, I'd switch to a per-host token-bucket implementation. Right now they all queue behind one another even when hitting different hosts."
12.2.5 Two rate-limit strategies in Morrie — when each applies¶
The Morrie framework has two distinct rate-limit implementations for different purposes:
Strategy A — BaseApi's static interceptor (per-process global throttle)¶
- Static field — shared across every API class instance in the process
- Enforces a hard 700ms minimum between any two API calls
- Used for the OTP endpoint where the backend rate-limits aggressively
- Implicit — every request goes through the interceptor automatically
Strategy B — RateLimiter queue with concurrency + interval¶
// src/utils/rateLimiter.ts
export class RateLimiter {
private queue: (() => void)[] = [];
private active = 0;
constructor(
private readonly maxConcurrent = 1,
private readonly intervalMs = 500
) { }
async schedule<T>(fn: () => Promise<T>): Promise<T> {
return new Promise<T>((resolve, reject) => {
const run = async () => {
try {
this.active++;
const result = await fn();
resolve(result);
} catch (e) {
reject(e);
} finally {
this.active--;
setTimeout(() => this.next(), this.intervalMs);
}
};
this.queue.push(run);
this.next();
});
}
private next() {
if (this.active >= this.maxConcurrent) return;
const task = this.queue.shift();
if (task) task();
}
}
// src/utils/apiLimiter.ts
export const apiLimiter = new RateLimiter(1, 600); // 1 concurrent, 600ms gap
How this differs from BaseApi's static throttle:
| Aspect | BaseApi static throttle | RateLimiter queue |
|---|---|---|
| Where it applies | Every API call (automatic) | Wherever you wrap with apiLimiter.schedule(...) |
| Concurrency control | None (just timing) | Yes — maxConcurrent slots |
| Queueing | None — interceptor blocks the request thread | Real queue — promises wait their turn |
| Cleanup interval | Time-since-last-request | Time-since-task-completed |
| Use case | OTP-style fixed rate limits | Bulk operations like deleteAllAgents where I want serialized execution |
When to use which¶
- BaseApi static throttle — when every API call to a host needs spacing (OTP, auth-heavy endpoints)
- RateLimiter queue — when you need true concurrency control (e.g., "only 1 delete at a time, with 600ms recovery") inside a loop
Honest framing — what I'd refactor¶
"The two strategies are both useful but they don't compose well. The BaseApi static throttle is a single global lock — every API call queues behind every other API call regardless of host. The RateLimiter queue is opt-in via
schedule(fn), so callers have to remember to wrap. The clean refactor: extract aThrottlestrategy in BaseApi that's per-host and based on the same queue primitive — so the same configurable token-bucket runs in both places. We haven't needed it because our scale doesn't demand it, but it's the right shape for a multi-host load-style suite."
12.3 The combined API + UI test (your e2e-lifecycle.spec.ts)¶
test('Full lifecycle: Create (API) -> Verify (UI) -> Update (UI) -> Verify (API) -> Delete (UI)',
async ({ agentsApi, agentPage, page }) => {
// 1. Create via API (fast)
const createRes = await agentsApi.createAgent(agentName, agentPrompt);
expect(createRes.status).toBe(201);
agentId = createRes.data._id;
// 2. Verify in UI (real user experience)
await agentPage.goto();
await page.reload();
await expect(page.getByText(agentName).first()).toBeVisible();
// 3. Update via UI
await agentPage.editAgent(agentName, updatedName, agentPrompt);
await expect(page.getByText(updatedName).first()).toBeVisible();
// 4. Verify update via API
const getRes = await agentsApi.getAgentById(agentId);
expect(getRes.data.name).toBe(updatedName);
// 5. Delete via UI
await agentPage.deleteAgent(updatedName);
// 6. Verify deletion via API
try {
await agentsApi.getAgentById(agentId);
} catch (error: any) {
expect(error.response?.status).toBe(404);
}
});
12.4 Multi-step API CRUD chain with shared describe-scope state¶
When a logical flow spans multiple API calls — create an agent, then a thread, then add messages — share state via let variables at describe scope, with test.describe.serial to guarantee order.
Real example from your Morrie chats.api.spec.ts¶
test.describe.serial('ChatsApi e2e tests', () => {
let agentId: number | string;
let threadId: number | string;
test('Create agent for chat thread tests', async ({ agentsApi }) => {
const res = await agentsApi.createAgent(
CHATS_TEST_DATA.create_agent.name,
CHATS_TEST_DATA.create_agent.prompt
);
expect(res.status).toBe(201);
expect(res.data).toHaveProperty('_id');
agentId = res.data._id;
});
test('Create chat thread', async ({ chatsApi }) => {
const res = await chatsApi.createThread(agentId, CHATS_TEST_DATA.create_thread.title);
expect(res.status).toBe(201);
threadId = res.data._id;
});
test('Add message to chat thread', async ({ chatsApi }) => {
const res = await chatsApi.addMessageToThread(threadId, CHATS_TEST_DATA.add_message.content);
expect(res.status).toBe(201);
// The /messages endpoint returns BOTH the user message and the AI response
expect(res.data).toHaveProperty('userMessage');
expect(res.data).toHaveProperty('aiMessage');
expect(res.data.userMessage).toHaveProperty('_id');
expect(res.data.aiMessage).toHaveProperty('_id');
});
test('Get all messages in chat thread', async ({ chatsApi }) => {
const res = await chatsApi.getAllMessagesInThread(threadId);
expect(res.status).toBe(200);
expect(Array.isArray(res.data)).toBe(true);
expect(res.data.length).toBeGreaterThan(0);
});
test('Delete all messages in chat thread', async ({ chatsApi }) => {
const res = await chatsApi.deleteAllMessagesInThread(threadId);
expect([200, 204]).toContain(res.status);
});
});
Three patterns to call out¶
test.describe.serial— guarantees order soagentIdandthreadIdare populated before subsequent tests use them- Shared describe-scope variables —
let agentId; let threadId;capture state across tests; only safe inside.serialblocks - AI-aware assertion — the
add messageendpoint returns two messages — the user's input and the AI's response. The assertion explicitly checks bothuserMessage._idandaiMessage._idexist, which validates that the AI generation actually ran
Why this pattern beats one-mega-test¶
- Granular failure isolation — if
addMessageToThreadfails, the test output names exactly that step - Per-step trace in the HTML report — debugging is easier than one 200-line test
- Stop on first failure —
serialaborts subsequent tests when one fails, saving CI minutes
Honest framing — when serial is wrong¶
"I use
test.describe.serialwhenever the tests form a real sequence — create then read then update then delete. The downside is they can't parallelize, so a 5-step serial block runs in 5x the time of one step. For tests that don't actually depend on each other, I use independent tests with their own setup via fixtures. The rule of thumb: serial when the tests model one user journey, parallel when they're separate scenarios that happen to touch the same API."
13. TABS, FRAMES, WINDOWS, DOWNLOADS¶
13.1 Multiple tabs / windows in one context¶
const [newPage] = await Promise.all([
context.waitForEvent('page'), // listen for new tab
page.click('a[target="_blank"]'), // triggers it
]);
await newPage.waitForLoadState();
Promise.all: we have to register the listener before the click — race condition otherwise.
13.2 Frames (<iframe>)¶
// Get a FrameLocator (lazy, like a Locator)
const frame = page.frameLocator('#payment-iframe');
await frame.getByLabel('Card number').fill('4242 4242 4242 4242');
await frame.getByRole('button', { name: 'Pay' }).click();
// Nested iframes
const inner = page.frameLocator('#outer').frameLocator('#inner');
13.3 Dialogs (alert, confirm, prompt)¶
page.on('dialog', async dialog => {
expect(dialog.type()).toBe('confirm');
expect(dialog.message()).toContain('Are you sure?');
await dialog.accept();
});
await page.click('#delete');
13.4 Downloads¶
const [download] = await Promise.all([
page.waitForEvent('download'),
page.click('#export-csv'),
]);
await download.saveAs('./out/report.csv');
expect(download.suggestedFilename()).toMatch(/\.csv$/);
14. STORAGE STATE + MODERN AUTH SETUP¶
14.1 The classic storageState pattern (what your Morrie uses)¶
Save once in global-setup¶
Reuse via config¶
projects: [
{
name: 'chromium',
use: {
...devices['Desktop Chrome'],
storageState: 'auth.storage.json',
},
testMatch: ['**/web/*.spec.ts', '**/e2e/*.spec.ts'],
},
{
name: 'api',
use: { headless: true }, // no storageState — API uses env token
testDir: 'tests/api',
},
]
Per-test override¶
test.use({ storageState: 'auth-admin.json' }); // entire file uses admin auth
test('admin can delete users', async ({ page }) => {...});
14.1.5 Overriding storage state at file level (the login test pattern)¶
When you want to test the login flow itself, you need to disable the storage state — otherwise the test starts already logged in and never hits the sign-in page.
Real example from your Morrie tests/web/login.spec.ts¶
// Disable storage state for this test — we test UI login, not authenticated state
test.use({ storageState: { cookies: [], origins: [] } });
test.describe.serial('Order Flow - Login Tests', () => {
test('UI Login + OTP verification -> success flow', async ({ loginPage, page }) => {
await page.goto(WEB_ENDPOINTS.SIGNIN);
await loginPage.enterEmail(TEST_EMAIL);
await loginPage.clickContinue();
// ...
});
});
What test.use({ storageState: { cookies: [], origins: [] } }) does:
- Overrides the project-level storageState (which is loaded from auth.storage.json)
- Replaces it with an empty storage state — no cookies, no localStorage
- Applies to every test in this file
When to use this pattern¶
- Login tests — you actually need to be logged out to test login
- Logout tests — start logged in, verify logout completes, but then a subsequent test in the file should start fresh
- Multi-role tests —
test.use({ storageState: 'auth.admin.json' })for admin-only tests in one file,test.use({ storageState: 'auth.viewer.json' })in another
Spoken answer — "How do you test the login flow when storageState is on?"¶
"Override at the file level —
test.use({ storageState: { cookies: [], origins: [] } }). This replaces the project's storageState with an empty one for every test in the file, so the browser starts unauthenticated and the sign-in page is reachable. It's exactly the pattern I use inlogin.spec.ts— the rest of the suite reuses the saved auth state fromglobalSetup, but login tests need to start from a blank slate to actually drive through the sign-in flow."
14.2 The modern auth.setup.ts project pattern (worth knowing)¶
Instead of globalSetup, define a setup project that runs first as a dependency:
// playwright.config.ts
projects: [
{
name: 'setup',
testMatch: /global\.setup\.ts/,
},
{
name: 'chromium',
use: { storageState: 'auth.storage.json' },
dependencies: ['setup'], // <-- runs setup project first
testMatch: ['**/web/*.spec.ts'],
},
]
// tests/global.setup.ts — a regular test file!
import { test as setup, expect } from '@playwright/test';
setup('authenticate', async ({ page, request }) => {
// do OTP login
await context.storageState({ path: 'auth.storage.json' });
});
Why this is better than globalSetup¶
- The setup runs in a real Playwright test slot — you get trace, screenshot, video for auth failures
dependencieschains projects — auth setup project, then per-role test projects- It's a regular
test— easier to debug thanglobalSetup(which has no UI/trace access)
Honest framing¶
"Our Morrie framework uses the older
globalSetuppattern. It works, but the modern recommendation is a setup project withdependencies. The big win is debuggability — if auth fails, I get a trace and screenshot of exactly what happened on the sign-in page. WithglobalSetup, you get a console error and a guess. On the next refactor, I'd migrate."
14.3 Multiple auth states for multiple roles¶
// global.setup.ts — produces two storage states
setup('auth as admin', async ({ page }) => {
await loginAs('admin@x.com');
await page.context().storageState({ path: 'auth/admin.json' });
});
setup('auth as viewer', async ({ page }) => {
await loginAs('viewer@x.com');
await page.context().storageState({ path: 'auth/viewer.json' });
});
15. REPORTERS, TRACE VIEWER, CODEGEN, SCREENSHOTS, VIDEO¶
15.1 Reporter configuration (your Morrie config)¶
reporter: [
['list'], // CLI live output
['html', { outputFolder: 'reports/html-report', open: 'never' }],
['json', { outputFile: 'reports/results.json' }],
['junit', { outputFile: 'reports/junit/results.xml' }], // for CI
],
| Reporter | Use |
|---|---|
list |
Live console output while running |
html |
Rich interactive report with traces, screenshots, video links |
json |
Programmatic post-processing |
junit |
Universal CI format (Jenkins, GitLab, GitHub all parse it) |
blob |
Sharded runs — merge later with merge-reports |
github |
GitHub Actions native annotations |
allure-playwright |
Allure integration (3rd party) |
15.2 Trace Viewer — the killer feature¶
Options:off, on, retain-on-failure, on-first-retry, on-all-retries.
Open a trace¶
You get a time-traveling debugger: - DOM snapshot at every action - Network requests with full headers + bodies - Console output - Screenshots - Source code of the action that took placeSpoken answer — "How do you debug a flaky test in CI?"¶
"First stop is the trace from the failed run. The Trace Viewer is essentially a video playback with DOM time-travel — I can scroll to the failing step, see the exact DOM state when Playwright tried to click, see all network activity around that moment, and see console logs from the page. Nine times out of ten the cause is visible — an unexpected overlay, a slow API response, an element that hadn't finished animating. In Selenium I'd have screenshots and logs and try to guess; with Playwright I see exactly what the browser was seeing."
15.3 Screenshots + video¶
Options for screenshot:off, on, only-on-failure. Same for video.
15.4 Codegen — record-and-replay starter¶
Opens a browser; every click/fill is captured as test code. Great for prototyping POMs.16. DEBUGGING¶
16.1 The 5 most-used debugging tools¶
| Tool | When |
|---|---|
page.pause() |
Drop into Playwright Inspector mid-test |
--debug CLI flag |
Run all tests with Inspector |
PWDEBUG=1 env var |
Same as --debug but as env |
--headed |
Watch the browser run |
DEBUG=pw:api |
Verbose protocol logs |
npx playwright test # headless, normal
npx playwright test --headed # see the browser
npx playwright test --debug # step through interactively
npx playwright test login.spec.ts --debug # debug one file
PWDEBUG=1 npx playwright test # env-var form
DEBUG=pw:api npx playwright test # protocol-level logs
16.2 page.pause() mid-test¶
test('debug me', async ({ page }) => {
await page.goto('/dashboard');
await page.pause(); // browser stops; Inspector opens
await page.click('#button');
});
16.3 test.only for focus during dev¶
Only this test runs. forbidOnly: isCI in config prevents it leaking to CI.
16.4 Annotations for known-broken / slow tests¶
test.skip('not ready', async ({ page }) => {...});
test.skip(browserName === 'webkit', 'webkit-specific bug')
test.fixme('broken — see ticket ABC-123', async ({ page }) => {...});
test.fail('expected to fail — bug reproducer', async ({ page }) => {...});
test.slow(); // 3x default timeout for this test
test.setTimeout(60_000); // explicit per-test timeout
16.5 Tagging + selective runs (the Morrie pattern)¶
Tags live in the test or describe title and are filtered by --grep.
Real example from your create.spec.ts¶
// Multiple tags on a describe — applies to all tests inside
test.describe.serial('@page-builder @create', () => {
test('@smoke Create new page with routing and component upload', async ({ page }) => {
// Arrange
const pageName = `Test Page - ${Date.now()}`;
const routingUrl = `/test-page-${Date.now()}`;
// Act
await pageBuilder.clickCreatePage();
await pageBuilder.fillPageName(pageName);
await pageBuilder.fillRoutingUrl(routingUrl);
await pageBuilder.uploadComponentFile('./tests/fixtures/sample-component.jsx');
await pageBuilder.submitCreateForm();
// Assert
await expect(page.locator(`text=${pageName}`)).toBeVisible();
expect(await pageBuilder.isPageCreatedInList(pageName)).toBe(true);
});
test('@validation Create page with empty name', async ({ page }) => {
await pageBuilder.clickCreatePage();
await pageBuilder.fillRoutingUrl('/test-page');
await pageBuilder.uploadComponentFile('./tests/fixtures/sample-component.jsx');
expect(await pageBuilder.isCreateButtonDisabled()).toBe(true);
const errorMsg = await pageBuilder.getErrorMessage();
expect(errorMsg).toContain('name');
expect(errorMsg).toContain('required');
});
});
// Other API specs
test.describe.serial('@api agents CRUD flow', () => {...});
test.describe('@api users', () => {...});
test.describe.serial('@api auth – email + otp flow', () => {...});
Run subsets¶
npx playwright test --grep @smoke # only smoke
npx playwright test --grep @api # all API tests
npx playwright test --grep '@page-builder.*@validation' # combined
npx playwright test --grep-invert @slow # everything except slow
npx playwright test --grep '@smoke|@validation' # union (regex OR)
Tag naming convention I use¶
| Tag | Meaning |
|---|---|
@smoke |
Run on every PR — < 5 minutes total |
@regression |
Run nightly — full coverage |
@api / @web / @e2e |
Layer (route via CI workflow) |
@validation |
Negative / boundary cases |
@page-builder, @agent, @chat |
Feature scope |
@flaky |
Quarantined — runs but doesn't fail the build |
@slow |
> 30s — skip on PR, run nightly |
Spoken answer — "How do you organize tests for different CI runs?"¶
"Tag-based. Every test has a feature tag like
@page-builder, a type tag like@smokeor@validation, and a layer tag like@apior@web. CI workflows filter via--grep. On PR we run--grep @smoke— that's about 5 minutes. Nightly we run the full suite. A specific bug investigation can be--grep @page-builderto scope to one feature. The Jenkinsfile in Morrie has aTEST_SUITEchoice parameter that maps to grep expressions — devs trigger custom runs without editing pipeline code."
16.6 The AAA pattern with negative tests (your PageBuilder spec)¶
Your create.spec.ts uses explicit Arrange/Act/Assert comments — makes negative tests especially readable.
Pattern¶
test('@validation Create page with empty name', async ({ page }) => {
// Act
await pageBuilder.clickCreatePage();
await pageBuilder.fillRoutingUrl('/test-page');
await pageBuilder.uploadComponentFile('./tests/fixtures/sample-component.jsx');
// Leave name empty deliberately
// Assert — TWO assertions per negative test
expect(await pageBuilder.isCreateButtonDisabled()).toBe(true); // 1. UI state
const errorMsg = await pageBuilder.getErrorMessage();
expect(errorMsg).toContain('name'); // 2. error text content
expect(errorMsg).toContain('required');
});
Why two assertions per negative test¶
- State assertion (
isCreateButtonDisabled) — verifies the UI is in the right "can't proceed" state - Message assertion (
errorMsg contains "name" and "required") — verifies the user gets actionable feedback, not just a blocked button
A test that only asserts state passes even if the error message says "Please contact admin" — terrible UX, but technically the button is disabled.
Spoken answer — "How do you structure negative tests?"¶
"AAA — Arrange, Act, Assert — with explicit comments. The Act phase deliberately omits or breaks one input. The Assert phase has two checks: a state assertion (button disabled, form not submitted) and a message assertion (error text contains the relevant keywords). The state check tells me the system stopped at the right place; the message check tells me the user knows why. My PageBuilder validation tests in Morrie follow this — every test for an empty field asserts both
isCreateButtonDisabledand the error message containing both the field name and the word 'required'. That second assertion catches the surprisingly common bug where validation fires but the message is generic."
17. VISUAL REGRESSION, ACCESSIBILITY, MOBILE¶
17.1 Visual regression — toHaveScreenshot¶
First run creates the baseline. Subsequent runs compare. Update baselines:
17.2 Accessibility — @axe-core/playwright¶
import AxeBuilder from '@axe-core/playwright';
test('home page has no a11y violations', async ({ page }) => {
await page.goto('/');
const results = await new AxeBuilder({ page })
.withTags(['wcag2a', 'wcag2aa'])
.analyze();
expect(results.violations).toEqual([]);
});
17.3 Mobile emulation¶
import { devices } from '@playwright/test';
projects: [
{ name: 'iPhone 13', use: { ...devices['iPhone 13'] } },
{ name: 'Pixel 7', use: { ...devices['Pixel 7'] } },
]
17.4 Permissions, geolocation, timezone¶
const context = await browser.newContext({
geolocation: { latitude: 12.97, longitude: 77.59 }, // Bangalore
permissions: ['geolocation'],
timezoneId: 'Asia/Kolkata',
locale: 'en-IN',
colorScheme: 'dark',
});
18. CI/CD¶
18.1 Your Morrie Jenkinsfile (real code)¶
pipeline {
agent any
options { timestamps(); ansiColor('xterm') }
parameters {
choice(name: 'TEST_SUITE', choices: ['all', 'api', 'ui', 'e2e'])
}
environment {
CI = 'true'
NODE_ENV = 'test'
}
stages {
stage('Checkout') { steps { checkout scm } }
stage('Install Dependencies') {
steps {
sh 'npm ci'
sh 'npx playwright install chromium'
}
}
stage('Run Playwright Tests') {
steps {
script {
if (params.TEST_SUITE == 'api') { sh 'npm run test:api' }
else if (params.TEST_SUITE == 'ui') { sh 'npm run test:ui' }
else if (params.TEST_SUITE == 'e2e') { sh 'npx playwright test tests/e2e --project=chromium' }
else { sh 'npm test' }
}
}
}
}
post {
always {
archiveArtifacts artifacts: 'reports/**, test-results/**, auth.token.json',
allowEmptyArchive: true
junit testResults: 'reports/junit/results.xml, test-results/**/*.xml',
allowEmptyResults: true
publishHTML([
reportDir: 'reports/html-report',
reportFiles: 'index.html',
reportName: 'Playwright HTML Report',
keepAll: true, alwaysLinkToLastBuild: true,
])
}
}
}
Spoken walkthrough¶
"Parameter-driven pipeline. The
TEST_SUITEchoice lets devs trigger a self-service run for just API, UI, e2e, or all. Three stages — Checkout, Install (which doesnpm ciplusnpx playwright install chromium— both mandatory to skip cache mismatches), and Run Tests. Post-actions always — even on failure — archive reports, publish JUnit XML for Jenkins's native results UI, and publish the Playwright HTML report so devs click straight from Jenkins to a full interactive report. Theauth.token.jsonis archived for debugging — sometimes failures are due to a stale token."
18.2 GitHub Actions equivalent with sharding¶
name: Playwright Tests
on: [push, pull_request]
jobs:
test:
timeout-minutes: 60
runs-on: ubuntu-latest
strategy:
fail-fast: false
matrix:
shard: [1/4, 2/4, 3/4, 4/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 }}
env:
BASE_URL: ${{ secrets.STAGE_BASE_URL }}
TEST_EMAIL: ${{ secrets.TEST_EMAIL }}
- uses: actions/upload-artifact@v4
if: always()
with:
name: blob-report-${{ strategy.job-index }}
path: blob-report/
retention-days: 7
merge-reports:
if: always()
needs: test
runs-on: ubuntu-latest
steps:
- uses: actions/setup-node@v4
with: { node-version: 20 }
- run: npm ci
- uses: actions/download-artifact@v4
with:
pattern: blob-report-*
merge-multiple: true
path: all-blob-reports
- run: npx playwright merge-reports --reporter=html ./all-blob-reports
- uses: actions/upload-artifact@v4
with: { name: playwright-report, path: playwright-report }
19. TYPESCRIPT FOR PLAYWRIGHT¶
19.1 Type your fixtures (the typed Morrie pattern)¶
type TestFixtures = {
agentsApi: AgentsApi;
agentPage: AgentPage;
};
type WorkerFixtures = {
dbPool: Pool;
};
export const test = base.extend<TestFixtures, WorkerFixtures>({
/* ... */
});
19.2 Type API responses¶
interface Agent {
_id: string;
name: string;
prompt: string;
createdAt: string;
}
const res = await agentsApi.getAgentById(id);
const agent: Agent = res.data;
expect(agent.name).toBe('Q1');
19.3 Generic test helpers¶
async function getJson<T>(request: APIRequestContext, url: string): Promise<T> {
const res = await request.get(url);
expect(res.ok()).toBeTruthy();
return await res.json() as T;
}
const user = await getJson<User>(request, '/api/users/me'); // fully typed
19.4 as const for endpoint maps¶
export const API_ENDPOINTS = {
AUTH: { LOGIN: '/auth/login', VERIFY_OTP: '/auth/verify' },
AGENTS: { BASE: '/agents', BY_ID: (id: string) => `/agents/${id}` },
} as const;
as const, TypeScript infers string. With it, autocomplete shows you LOGIN, VERIFY_OTP directly.
20. SELENIUM → PLAYWRIGHT MIGRATION¶
This is a top-3 most-asked Bangalore interview question.
20.1 The 90-second spoken answer¶
"I'd frame it as three wins and one risk. The wins: first, auto-wait eliminates roughly 80% of flake from the Selenium suite — no more
WebDriverWait+ customExpectedConditions, no moreThread.sleep. Second, parallel execution is built in via workers and sharding — no Selenium Grid to maintain. Third, multi-browser including WebKit, plus mobile emulation, network mocking, and Trace Viewer for debugging — features that didn't exist or required separate tools in the Selenium world. The risk: it's a rewrite, not a port. The locator API, action API, and async model are different enough that you can't sed-replace your way through. So the migration path is — keep both suites running in parallel during the bridge, port one critical user flow per week, retire the Selenium equivalent once Playwright is green for two consecutive sprints. The team learns by doing instead of in a doc."
20.2 The mapping cheat-sheet¶
| Selenium | Playwright |
|---|---|
driver.findElement(By.id("x")) |
page.locator('#x') or page.getByTestId('x') |
WebDriverWait(driver, 10).until(...) |
await expect(loc).to*() (auto-retry up to expect.timeout) |
driver.findElement(...).sendKeys("x") |
await page.fill('#input', 'x') |
Actions(driver).moveToElement(e).click() |
await page.hover('#x'); await page.click('#y') |
JavascriptExecutor.executeScript(...) |
await page.evaluate(() => {...}) |
driver.switchTo().frame(...) |
page.frameLocator('#iframe') |
driver.switchTo().alert().accept() |
page.on('dialog', d => d.accept()) |
driver.manage().getCookies() |
await page.context().cookies() |
| Selenium Grid + Docker for parallel | workers: 4 + --shard=1/4 |
| ThreadLocal driver | Worker = own browser, automatic |
Page Factory @FindBy |
Locator properties in POM (lazy by default) |
TestNG parallel="classes" |
fullyParallel: true |
| ExtentReports listener | Playwright HTML + optional Allure |
20.3 The migration pattern I'd use¶
- Stand up Playwright alongside Selenium (different repo or different folder)
- Start with API tests — fastest win, no UI complexity
- Migrate smoke suite first — small, high-value, runs on every PR
- Build one POM at a time
- Once a feature is fully covered in Playwright + green for 2 sprints, remove the Selenium equivalent
- Decommission Selenium Grid only after full parity
21. TECH DEBT IN MY OWN PROJECTS — WHAT I'D REFACTOR¶
This is what signals seniority. Be honest about your own code.
21.1 Morrie playwright-automation/¶
| Tech debt | Why it's a problem | What I'd do |
|---|---|---|
await this.page.waitForTimeout(2000) scattered in AgentPage + PageBuilderPage |
Blind sleeps cause flakiness on slow CI and waste time on fast CI | Replace with await expect(toast).toBeVisible() or await expect(rows).toHaveCount(prev + 1) |
LoginPage uses fill, global-setup uses pressSequentially |
Inconsistent; if global-setup's pattern is needed (React keystroke validation), LoginPage also needs it too | Standardise on pressSequentially + blur |
BaseApi.lastRequestTime is static |
One global lock across all clients; can't parallelise hits to different hosts | Per-host token-bucket implementation |
globalSetup instead of setup project |
No trace/screenshot when auth fails — hard to debug | Migrate to auth.setup.ts with dependencies |
Defensive three-strategy fallback in editAgent (visible → attached → raw input) |
Symptom of the UI not having stable selectors | Work with dev team to add data-testid |
PageBuilderPage uses .or() + [class*="..."] regex CSS heavily |
Symptom of mid-migration UI with unstable selectors | Same — push for data-testid from dev team |
DashboardPage hardcodes 'rohan@questt.com' in userEmail locator |
Breaks when test user changes or in CI with a different account | Pull from process.env.TEST_EMAIL or credentials.ts |
auth.api.spec.ts has both tests marked .skip() |
Tests look active in code but never run; misleading | Either delete with reason, or move to test.fixme with ticket reference |
Both BaseApi static throttle and RateLimiter queue exist |
Two parallel rate-limit strategies with no shared primitive | Extract a single per-host token-bucket; use it in both places |
| Single browser project (Chromium only) | Misses WebKit / Firefox render bugs | Add browser projects in config matrix |
Hardcoded TEST_OTP in credentials.ts |
Breaks the moment backend enables real OTP delivery | Backend test-mode endpoint that returns deterministic OTP for test emails |
apiUtils.ts only exports buildHeaders — barely used |
Dead-or-near-dead utility file | Remove or consolidate into BaseApi |
logger.ts is console.log with a timestamp |
No log levels, no structured output, no log routing | Migrate to pino or winston with levels + JSON output |
seed.spec.ts is empty placeholder for codegen |
Tracked in repo but does nothing | Either delete or add a real seed (codegen target) |
21.2 HOAD-BI Playwright-questtAutomation/¶
| Tech debt | Why it's a problem | What I'd do |
|---|---|---|
| No POM hierarchy beyond per-client chat page | Five near-identical chat pages — hoadChatPage, godrejChatPage, etc. |
Extract a BaseChatPage with common waits + saveToCSV; subclass per client only for unique selectors |
Hard-coded test OTP '1' six times |
Same risk as Morrie | Backend test-mode |
waitForTimeout(2000) after every step |
Same as Morrie | Wait for DOM signals |
headless: false in config |
Tests can't run on CI without xvfb | Make conditional via env var |
actionTimeout: 0 |
No upper bound on individual actions — masks performance issues | Set to 30s, raise per-test if needed |
No fixtures, no request API client |
Each test re-implements login | Extract login fixture; tests look like Morrie |
All assertions are weak (waitForSelector) — no expect.toBeVisible |
Misses Playwright's auto-retry; uses raw waits | Replace with web-first assertions |
| Suite is essentially a CSV scraper, not a test suite | No pass/fail per question; everything passes as long as response comes back | This is why we built the Python chat-eval framework — proper evaluation requires golden answers + scoring rubric |
Honest interview line¶
"The HOAD-BI Playwright suite started as a CSV scraper to capture chatbot responses for manual review. It served that purpose well, but it isn't a real evaluation framework — every test passes as long as the response comes back, regardless of whether the response is correct. That gap is exactly what motivated the Python
chat-evalframework, where we built proper evaluators — oracle, BKG, content-quality, safety, latency — with weighted scoring and hard gates. The Playwright suite is still useful for capturing transcripts but the real grading lives in Python."
22. INTERVIEW QUESTIONS — 35+ Q&A WITH FULL ANSWERS¶
Each question has a short answer (one line) plus a spoken-style long answer. Use the long form in actual interviews.
22.1 Architecture & basics¶
Q1. Why did you choose Playwright over Selenium?¶
Short: WebSocket protocol + auto-wait + built-in parallel + multi-browser (incl. WebKit) + Trace Viewer. Long:
"Four practical wins that mattered in production. First, the WebSocket protocol is one persistent connection, so commands are 3x faster than Selenium's per-command HTTP. Second, auto-wait removed about 80% of our flakiness — Playwright waits for element visibility, stability, and actionability before every action; no more
WebDriverWait+ customExpectedConditionsboilerplate. Third, parallel execution is built into the config —workers: 4instead of a Selenium Grid to maintain. Fourth, Trace Viewer makes debugging flaky CI failures take minutes instead of hours — it's a full time-machine of DOM + network + console. The WebKit support also catches Safari-specific bugs without needing a Mac."
Q2. Browser vs Context vs Page?¶
Short: Browser = OS process. Context = isolated session (like incognito). Page = a tab in a context. Long:
"Browser is the heavyweight OS process — chromium, firefox, or webkit. We launch one per worker. Context is the isolated session — own cookies, localStorage, cache. Creating a context is nearly free, like opening an incognito window — that's how Playwright achieves test isolation cheaply. Page is just a tab inside a context; multiple pages in one context share cookies, which is useful for OAuth popups or multi-tab flows. The Selenium equivalent of context isolation is launching a new browser, which is expensive. That cheap context creation is the architectural reason Playwright scales parallel tests so well."
Q3. How does auto-wait work?¶
Short: Before every action, Playwright checks element is attached + visible + stable + receives events + enabled. Polls until passing or actionTimeout. Long:
"Auto-wait is a series of actionability checks Playwright runs before every action. The element must be attached to the DOM, visible, stable — meaning not in the middle of an animation, receiving events — meaning the hit-test at center returns this element, and for inputs/buttons it must be enabled. If any check fails, Playwright polls until they all pass or the default 30-second action timeout expires. This is why I rarely write explicit waits in Playwright. The explicit waits I do use are for navigation, network responses, or custom stability conditions."
22.2 Locators¶
Q4. What's strict mode? Why does Playwright have it?¶
Short: Action on a locator throws if it matches more than one element. Forces explicit disambiguation. Long:
"Strict mode is Playwright's safety net. If a click or fill targets a locator that resolves to more than one element, Playwright throws instead of silently picking the first match like Selenium does. The design forces intent — if you want a specific match, you say
.first(),.nth(2), or.filter(...)explicitly. In my Morrie AgentPage I haveagentRows.filter({ hasText: agentName }).last()to pick the most recent matching row. Without strict mode it would have silently grabbed the first match and a duplicate-named agent would have been deleted incorrectly."
Q5. Walk me through your locator priority.¶
Short: getByRole > getByLabel > getByText > getByTestId > CSS > XPath.
Long:
"I follow Playwright's recommended user-facing hierarchy.
getByRolefirst because it matches the accessibility tree — what a screen reader sees — and survives CSS or class refactors.getByLabelfor form inputs because the visible label is what users associate with the field.getByTextfor visible text.getByTestIdwhen I have adata-testidavailable and semantic locators don't disambiguate. CSS is the next fallback for cases like Shadcn UI'sdata-slotattributes where multiple buttons share the same accessible name. XPath is genuinely last resort — I haven't written one in 18 months."
Q6. How do you find the 'edit' button only in rows that have a 'Pending' status?¶
Long:
"Chain a filter, then a locator.
page.getByRole('row').filter({ hasText: 'Pending' }).getByRole('button', { name: 'Edit' }). The.filter({ hasText })narrows to rows containing the word Pending, then.getByRole('button', { name: 'Edit' })walks into each matching row to find the Edit button. If multiple rows match, strict mode warns me; I add.first()or iterate if that's intentional."
Q7. Locator vs ElementHandle?¶
Short: Locator is lazy — re-queries DOM each use. ElementHandle is a snapshot — can go stale. Long:
"Always prefer Locator. It's a lazy reference — every action on it re-resolves the selector against the live DOM. That's why Playwright doesn't have a StaleElementReferenceException. ElementHandle is a captured reference at one point in time, like Selenium's WebElement — it can go stale if the DOM updates. The only real reason to drop down to ElementHandle is when you need to pass an actual DOM node into
page.evaluate(node => ...), which is rare."
22.3 Fixtures¶
Q8. What's a Playwright fixture and how is it different from beforeEach?¶
Short: A reusable setup that tests opt into by parameter name. Lazy, composable, scoped. Long:
"A fixture is a setup function decorated via
test.extend. Tests opt in by parameter name —async ({ agentPage }) => {...}. Three advantages over beforeEach. First, lazy — only tests that request the fixture pay the cost. Second, composable — fixtures can depend on other fixtures and Playwright resolves the order. Third, scoped —testscope creates a fresh fixture per test,workerscope creates once per worker process. In our Morrie testFixture.ts we haveagentsApi,agentPage,loginPageall as fixtures, so tests look likeasync ({ agentPage, agentsApi }) => {...}— clean, no setup code in the test."
Q9. Test-scope vs worker-scope fixture?¶
Short: Test = fresh per test. Worker = once per worker process. Long:
"Test scope is the default — Playwright tears down and recreates the fixture for every test. Use for browser-context-related state, fresh test data, page-specific helpers. Worker scope creates once per worker process and survives across tests in that worker. Use for expensive setup — DB connection pool, fixture data that doesn't change, an upstream service mock. The trade-off: worker fixtures must be test-safe (no mutations one test would notice in another). For Morrie, all our fixtures are test-scoped since each test gets its own context."
Q10. What's auto: true on a fixture?¶
Short: The fixture runs even if no test requests it explicitly. Long:
"By default, fixtures are pull-based — tests opt in.
auto: truemakes a fixture run for every test automatically. I use this sparingly because it adds hidden side effects, but it's perfect for cross-cutting concerns like metric collection — wrap every test in a timing fixture that attaches duration to the report, without making every test request it."
22.4 Network and API testing¶
Q11. How do you mock a slow API response?¶
Long:
"Use
page.routeand delay the fulfillment. Example:await page.route('**/api/orders', async route => { await new Promise(r => setTimeout(r, 5000)); await route.fulfill({ status: 200, body: '[]' }); }). This lets me test loading states, spinners, and timeout-handling without coordinating with the backend team."
Q12. How do you wait for a specific API response after a click?¶
Long:
"Register the response listener before triggering the click using
Promise.all.const responsePromise = page.waitForResponse(r => r.url().includes('/api/agents') && r.status() === 201); await page.click('#create'); const response = await responsePromise;. I use this pattern in my Morrie AgentPagedeleteAllAgentsto detect 429s and back off exponentially."
Q13. Walk me through your Morrie BaseApi.¶
[See section 12.2 for the full answer]
Q14. How do you do a combined API + UI test?¶
Long:
"Our e2e-lifecycle test is a good example. We create the agent via API (fast — no UI flow), verify it appears in the UI (real user experience), update via UI, verify the update via API (backend ground-truth), delete via UI, then verify deletion via API. The pattern is: use API where speed matters, UI where the user flow matters, and cross-verify between the two. It catches a class of bug where the UI lies about what's actually in the backend."
22.5 Auth and storage¶
Q15. How do you avoid logging in for every test?¶
Long:
"Storage state. Login once in global-setup — for Morrie, the OTP flow that produces the JWT — then save
context.storageStateto a JSON file. Configure the chromium project to load that file. Every test starts already authenticated; the login UI never runs. Saves 5-10s per test, which across 200 tests is real money. For multi-role testing, we save multiple state files —auth.admin.json,auth.viewer.json— and usetest.use({ storageState })per file."
Q16. What's the modern alternative to globalSetup?¶
Long:
"A setup project that other projects depend on via
dependencies: ['setup']. Two big advantages overglobalSetup. First, the setup runs as a real test, so it gets traces, screenshots, and video — debuggability matters when auth breaks at 3 AM. Second, you can chain setup projects — auth setup, then DB seeding, then test projects — without writing brittle initialization code. Our Morrie still uses the older globalSetup; the next refactor migrates."
22.6 Parallelism + sharding¶
Q17. How does parallel execution work?¶
Long:
"Two axes. Workers split parallelism within one machine — each worker is a separate Node process with its own browser. Tests in different files run in parallel automatically. With
fullyParallel: true, tests in the same file also parallelize unless wrapped intest.describe.serial. Sharding splits across machines —npx playwright test --shard=2/4runs 25% of the suite deterministically. We combine both: in CI, 4 shards × 2 workers = 8 parallel tests across 4 machines. Each shard produces a blob report; we merge them into one HTML report at the end."
Q18. How do you ensure test independence with parallel execution?¶
Long:
"Three rules. First, no shared mutable state between tests — every test creates its own data. Second, unique identifiers — UUIDs, timestamps, randomized email prefixes. Third, self-cleaning — each test deletes what it created in an afterAll, or relies on context isolation to wipe state automatically. For tests that genuinely share state — like a CRUD flow that creates → reads → deletes the same entity — I wrap them in
test.describe.serialso they run in order in the same worker."
Q19. Your CRUD tests in agents.api.spec.ts use test.describe.serial and share an agentId variable. Isn't that an anti-pattern?¶
Long:
"It's a controlled exception. Inside
describe.serial, Playwright guarantees order and same-worker execution, so the module-scopeagentIdis safe. The reason I wrote it that way: the alternative is generating a new agent in every test's setup, which triples the test runtime and doesn't actually validate the lifecycle. The serial approach matches the user flow — create, read, update, delete one entity. For tests that don't have a natural sequence, I'd absolutely keep them parallel and independent."
22.7 Debugging¶
Q20. Walk me through how you debug a CI-only flake.¶
Long:
"Trace first. With
trace: 'on-first-retry'in config, every failed retry produces a trace.zip. I download it from the CI artifact,npx playwright show-trace trace.zip, and step through the timeline. Nine times out of ten the cause is visible — an overlay covered the button, an API took longer than expected, an element hadn't finished animating. If trace doesn't tell me, I addDEBUG=pw:apito capture the full WebSocket protocol — that catches really subtle race conditions. As a last resort, I'll addpage.pause()and run with--headedlocally to step through interactively."
Q21. What does --debug do?¶
Long:
"Three things at once. It sets the action timeout to no-limit so steps don't time out while you're inspecting. It opens the Playwright Inspector — a panel attached to the browser with current locator, source view, and step-through controls. And it runs
page.pause()implicitly at the start of every test. You can click 'Resume' to fly through, or step action-by-action. Indispensable when investigating something the trace doesn't fully explain."
22.8 Assertions and waiting¶
Q22. Soft vs hard assertion in Playwright?¶
Long:
"
expect(loc).toBe...is hard — first failure stops the test.expect.soft(loc).toBe...collects failures and continues; the test fails at the end if any soft failure was recorded. Hard is the default and right for most cases. Soft is useful for form validation reporting or dashboard widgets where you want all failures visible in one run — saves the fix-rerun-fix cycle."
Q23. When would you use expect.poll?¶
Long:
"When the condition I'm waiting for isn't a standard locator assertion. For example, waiting for a counter on the page to reach a value after multiple API calls complete —
expect.poll(async () => parseInt(await page.locator('#cart').innerText())).toBeGreaterThanOrEqual(3). Same idea as Selenium'sFluentWaitbut cleaner — built-in retry, custom timeout, backoff intervals."
Q24. Difference between waitForSelector and expect(loc).toBeVisible()?¶
Long:
"Both wait for visibility.
waitForSelectoris older — it returns the element handle if found.expect(loc).toBeVisible()is the newer web-first assertion — it doesn't return anything, just asserts. The newer API is preferred because it integrates with the report, generates clearer failure messages, and supports.soft/.poll/ custom matchers. I useexpect(loc).toBeVisible()everywhere except when I genuinely need the element handle forpage.evaluate."
22.9 Migration + comparison¶
Q25. Selenium dev wants to migrate to Playwright. Convince them in 90 seconds.¶
[See section 20.1 — the migration spoken answer]
Q26. What's something Playwright can't do that Selenium can?¶
Long:
"Few things, mostly niche. Selenium has broader browser support — Internet Explorer, older Edge versions, browsers Playwright never targeted. If your test matrix includes IE11 — which some legacy enterprise apps do — Selenium is the only option. Selenium also has the Grid 4 + Docker community ecosystem with more battle-tested production deployments. For new projects targeting modern browsers, Playwright wins; for legacy enterprise, Selenium often still does."
Q27. Cypress vs Playwright?¶
Long:
"Both modern, both auto-wait, both faster than Selenium. Three differences that matter. First, Cypress runs in-browser — your test code executes inside the same JavaScript runtime as the page; it's a fundamentally different architecture. Playwright runs out-of-process and controls the browser via WebSocket. Second, Playwright supports multiple tabs/contexts natively; Cypress is single-tab by default and adds it via plugins. Third, Playwright is multi-language — TS, Python, Java, .NET; Cypress is JS-only. For us at Questt, multi-tab support and Python compatibility tipped it to Playwright."
22.10 Real-world patterns¶
Q28. How do you handle a chatbot that streams a response?¶
Long:
"The challenge is detecting when the streaming ends. There's no DOM event for 'LLM finished streaming.' In our HOAD-BI suite I solved it with
waitForFunctionand a stability check — poll the response container's text content, compare to the same text two seconds later, return true only when they match and the text is non-empty. That tells me the response has been stable for two seconds, which empirically means streaming completed. The alternative would be listening to the network response — but the chatbot uses Server-Sent Events, and detecting 'last SSE message' is brittle. Stability check is more robust."
Q29. How do you handle a hover-revealed action button?¶
Long:
"Hover the row first, then locate the button within it. From AgentPage:
await agentRow.hover(); const deleteBtn = this.getDeleteBtn(agentRow); await deleteBtn.click();. The hover triggers the CSS:hoverpseudoclass and makes the button render; without it, the click either fails (element not in DOM) or clicks the wrong element. Playwright's auto-wait doesn't help here — the element really isn't visible until hover fires."
Q30. Your AgentPage has many waitForTimeout calls. Defend them.¶
Long:
"Honestly, I can't fully defend them — they're tech debt. They worked around timing issues during initial framework setup. The right pattern is a DOM signal — wait for a toast
expect(toast).toBeVisible(), wait for the list to updateexpect(agentRows).toHaveCount(prev + 1). I'd refactor them out as I add more tests around the same flows, because the timeouts will start causing flakiness on slow CI runners. Calling them out is the seniority signal — I see them and I have a plan."
Q31. Your deleteAllAgents handles 429 with exponential backoff. Walk me through it.¶
Long:
"Yes — this was a real production-style problem. The Morrie backend rate-limits delete operations to prevent abuse. When deleting many agents in a loop, we'd hit 429 mid-loop and the test would fail. I added a retry loop with
waitForResponseto detect either 200/204 (success) or 429 (rate limited). On 429, exponential backoff — 2s, 4s, 8s, 16s, 32s. Max 5 retries per agent. If the modal closes between retries (because the click went through but the response was 429), we break and let the outer loop pick up the row fresh. It's verbose, but it mirrors the backend's actual behaviour rather than fighting it."
Q32. Walk me through your Jenkinsfile.¶
[See section 18.1 for the spoken walkthrough]
Q33. How do you organize tests for multiple environments (dev/stage/prod)?¶
Long:
"Environment files in
src/config/env.dev.tsandenv.stage.ts, plus anENVenv var. The config loads the matching file at startup.BASE_URL,API_BASE, test user credentials, feature flags — all come from there. CI setsENV=stagefor nightly regression,ENV=devfor PR checks. For tests that should only run against certain envs, we usetest.skip(process.env.ENV === 'prod', 'skipped on prod')."
Q34. How do you keep tests fast?¶
Long:
"Five lever points. First, API setup over UI setup whenever possible — creating a user via API is 50x faster than clicking through a form. Second, storage state instead of UI login. Third, block heavy assets — images, fonts, analytics — via
page.route(...).abort()for tests that don't need them. Fourth, parallelize aggressively — workers + sharding. Fifth, fail fast — useforbidOnly: isCIandretries: 1to prevent runaway failures. Across all of these I've seen suites go from 25 minutes to under 8."
Q35. What's the worst Playwright bug you've debugged?¶
Long:
"The Morrie sign-in Continue button stayed disabled even with the correct email.
fillwas setting the value but not firing the keystroke events React's validator depended on. Three days of trial and error before I switched topressSequentiallyplus explicitblur— fixed instantly. The lesson: Playwright'sfillis fast but it's not 'real typing.' If a React form validates on keystroke events specifically, you needpressSequentially. I now use it by default for any form that has reactive validation, and onlyfillfor plain forms where I just want the value set."
Q36. What's Locator.or() and when did you use it?¶
Long:
"
Locator.or()resolves the first locator that has at least one match. I used it heavily in my Morrie PageBuilderPage — for examplepage.getByLabel(/page\\s+name/i).or(page.locator('input[placeholder*=\"page name\" i]')). The reason: that page is mid-migration between two design systems, so the same logical input renders sometimes with a label and sometimes with a placeholder..or()lets one locator definition cover both. Honest framing — it's a smell, not a destination. If a Page Object is full of.or()chains, the right fix isdata-testidfrom the dev team. I use it as a bridge during refactors, not as the long-term pattern."
Q37. How do you assert API response shape without pinning values?¶
Long:
"
expect.objectContainingplusexpect.any(). In my Morrie users API test I assertexpect(res.data).toEqual(expect.objectContaining({ email: expect.any(String), name: expect.any(String), role: expect.any(String), _id: expect.any(String) })). The pattern asserts 'the response has at least these fields with these types' without pinning specific values — so the test survives data changes but catches contract drift. I combine it withexpect([200, 204]).toContain(res.status)for endpoints where multiple statuses are valid, like a DELETE that may return 200-with-body or 204-no-content depending on backend optimization."
Q38. Why do you have two rate-limit strategies in BaseApi vs RateLimiter?¶
Long:
"Different shapes for different needs.
BaseApi's staticlastRequestTimeinterceptor enforces a 700ms minimum gap between any two API calls — it's automatic, applies to every request, and was needed because the OTP endpoint rate-limits aggressively across the whole API surface. TheRateLimiterqueue is opt-in viaapiLimiter.schedule(fn), takesmaxConcurrentandintervalMs, and is used for bulk operations where I need real serialization with concurrency control. They don't compose well — that's the tech debt — but they cover different needs. The clean refactor is a per-host token-bucket primitive used in both places."
Q39. Walk me through your chats API CRUD chain.¶
Long:
"It's
test.describe.serial('ChatsApi e2e tests')withlet agentId; let threadId;at the describe scope. The first test creates an agent and captures_idintoagentId. The second creates a thread for that agent and capturesthreadId. Then we add a message, get all messages, and delete all messages — each test uses the IDs from earlier tests. The serial wrapper guarantees order and same-worker execution, so the sharedletvariables are safe. The interesting assertion is on the add-message endpoint — it returns BOTHuserMessageandaiMessagebecause the AI generates a response synchronously. I assert both objects have_idto confirm the AI generation actually ran, not just the user message being stored."
Q40. How would you build a Playwright framework from scratch tomorrow?¶
Long:
"Same shape as Morrie, with the three things I'd do differently. First — setup project pattern instead of globalSetup, so auth failures are traceable. Second — proper POM hierarchy with a BaseChatPage / BasePage class for shared logic; HOAD-BI's per-client chat pages are an example of what not to do. Third — Allure or a custom reporter early, not as an afterthought; the Playwright HTML report is fine but Allure's history view catches flakiness patterns over time. Plus all the standard pieces — fixtures for everything, environment-based config, sharding in CI, storage state for auth, network mocking for error-state tests. I'd avoid
waitForTimeoutfrom day one and treat any usage as a code-review block."
QUICK CHEAT SHEET¶
Most-used commands¶
npx playwright test # run all
npx playwright test login.spec.ts # one file
npx playwright test -g "create agent" # by name
npx playwright test --headed # see the browser
npx playwright test --debug # interactive debug
npx playwright test --project=chromium # one project
npx playwright test --shard=2/4 # one shard
npx playwright test --grep @smoke # by tag
npx playwright test --update-snapshots # refresh visuals
npx playwright show-report # open last HTML report
npx playwright show-trace trace.zip # open trace
npx playwright codegen example.com # record-and-replay
npx playwright install chromium # browser binary
Most-used config keys¶
defineConfig({
testDir: 'tests',
fullyParallel: true,
workers: isCI ? 2 : undefined,
retries: isCI ? 1 : 0,
forbidOnly: isCI,
timeout: 30_000,
expect: { timeout: 5000 },
reporter: [['list'], ['html'], ['junit']],
use: {
baseURL: process.env.BASE_URL,
trace: 'on-first-retry',
screenshot: 'only-on-failure',
video: 'retain-on-failure',
storageState: 'auth.storage.json',
},
projects: [
{ name: 'chromium', use: { ...devices['Desktop Chrome'] } },
{ name: 'webkit', use: { ...devices['Desktop Safari'] } },
],
});
Five lines that signal seniority in interview¶
- "I'd rather use a DOM signal than
waitForTimeout— blind sleeps are tech debt." - "Strict mode catches the silent-wrong-click bug Selenium lets through."
- "Fixtures over
beforeEachbecause they're lazy, composable, and typed." - "Trace Viewer turns 'why did this flake?' from a guess into a video playback."
- "I always cross-verify UI actions against API state, and vice versa."
Owner: Rohan Dsouza | Grounded in: Morrie playwright-automation/ + HOAD-BI Playwright-questtAutomation/ + B2BProjectTest reference experience | Updated: 2026