Scenario Interview Masterclass

68Scenario-Based Interview Masterclass

Modern SDET interviews have shifted from "What is X?" to "Why does X exist when Y already exists?" and "Explain how Z works at the architecture level." This module is a deep-dive Q&A bank covering exactly these questions — with answers that demonstrate senior-level understanding.

Architecture & Internals Q&A

When we have a browser, why do we need BrowserContext? Can't we just use the browser directly?

Short answer: Because browser tabs share state. BrowserContext doesn't.

A Browser object represents one process. Multiple Pages (tabs) inside the same browser share cookies, localStorage, sessionStorage, IndexedDB, auth tokens, and service workers — exactly like real browser tabs in the same window.

If test A logs in as "admin" and test B logs in as "user" and they share the same browser with no context isolation, test B's login can overwrite test A's session. The tests corrupt each other.

BrowserContext creates an isolated profile — separate cookies, storage, and auth state — that costs only ~5ms to create (vs ~1-2s for a new browser process). Playwright's default fixture creates a new BrowserContext per test, which is why tests never bleed state between them even within the same worker.

When would you reuse a context across tests? When you have a logged-in state that is expensive to establish and you want all tests in a suite to share it. You use storageState to save the auth state to a file and restore it per context — getting isolation AND speed.

Node.js is single-threaded. So how does Playwright run tests in parallel?

Short answer: Playwright's runner spawns multiple worker processes — each is a separate Node.js process, not a thread. True parallelism at the OS level.

Node.js runs JavaScript on a single event loop thread. You cannot run two JS callbacks simultaneously on one Node process. But Playwright's test runner uses child_process to spawn N worker processes. Each worker is a completely independent Node.js process with its own V8 heap, its own event loop, and its own browser instance. The OS scheduler runs them on different CPU cores simultaneously.

Within each worker, Playwright uses async/await on the event loop to handle all browser I/O (which is non-blocking network/pipe communication). So the single thread in each worker is efficiently utilized via the event loop, and parallelism across workers is achieved via OS-level process isolation.

Default: workers = ceil(cpuCount / 2). Change with --workers=4 or in playwright.config.ts. On CI: set explicitly since cloud runners vary in CPU count.

Why does Playwright use Chromium and not Chrome? End users use Chrome — aren't we testing the wrong browser?

Short answer: Chromium is open-source and freely distributable; Chrome is not. The rendering engine (Blink + V8) is identical — behavior parity is >99.9% for web apps.

Google Chrome cannot be legally redistributed. It includes proprietary codecs (H.264, AAC), Widevine DRM, and Google-specific integrations. Playwright must ship its own browser binaries — so it ships Chromium (the open-source project Chrome is built from).

For web application testing, the Blink rendering engine and V8 JavaScript engine — the parts that matter — are identical in Chromium and Chrome. The differences only surface for DRM-protected video, proprietary audio codecs, or very specific Chrome sandbox behaviors. These are edge cases that almost never affect SDET work.

More importantly, Playwright pins each release to a specific Chromium revision. This means your CI environment, your dev machine, and your team lead's laptop all run the exact same browser. Chrome auto-updates silently — a Chrome update could break a test on Tuesday that passed on Monday with zero code change. Chromium pinning eliminates this class of flakiness.

What is CDP (Chrome DevTools Protocol) and why does Playwright use it for ALL browsers, not browser-native protocols?

Short answer: Playwright doesn't use CDP for all browsers — it uses CDP for Chromium, and CDP-compatible patched protocols for Firefox and WebKit. And it does this because CDP is mature, bidirectional, and event-driven, which WebDriver (the alternative) is not.

CDP is a JSON-over-WebSocket protocol that Chrome's DevTools use to inspect and control the browser. Playwright reuses this for automation. Key advantages over WebDriver:

  • Bidirectional: Browser pushes events to Playwright (network interception, console messages, DOM mutations) without polling
  • Rich API: Network interception, JS coverage, performance metrics, frame isolation, service worker control — all built in
  • Low latency: WebSocket vs HTTP round-trips

For Firefox and WebKit, Playwright ships its own patched forks. Firefox gets a "Juggler" patch — a CDP-like protocol layered on top of Firefox internals. WebKit gets an inspector protocol exposed via WebSocket. Neither the official Firefox nor Safari supports CDP natively — that's why you cannot use a system-installed Firefox/Safari with Playwright.

The future is WebDriver BiDi (W3C standard) — a bidirectional protocol all browsers will support. Playwright is already working on BiDi support to eventually remove the need for patched browser forks.

What is Shadow DOM and how do you test elements inside it?

Short answer: Shadow DOM is a DOM boundary that web components use to encapsulate their internals. Playwright automatically pierces open shadow roots in CSS selectors.

Shadow DOM lets a custom element like <my-datepicker> have its own internal HTML tree that is hidden from the outer document. A regular document.querySelector('.calendar-day') would return null if .calendar-day lives inside a shadow root.

Two types:

  • Open shadow rootelement.shadowRoot is accessible. Playwright pierces this automatically. You write selectors as if the shadow boundary doesn't exist: page.click('.calendar-day') works.
  • Closed shadow rootelement.shadowRoot returns null. No external selector can reach inside. Cannot test internal elements directly — only test behavior via the component's public interface (events, attributes, slots).

XPath does NOT pierce shadow DOM. For XPath-heavy legacy suites migrating to Playwright, this is a common breaking change when web components are involved.

Use :light(.selector) to explicitly stay in the light DOM (stop at shadow boundary). Use pierce/.selector for explicit piercing (same as default CSS behavior but explicit in intent).

What is Puppeteer and how is it related to Playwright?

Short answer: Puppeteer (2017) is Google's Chrome automation library over CDP. The team that built it moved to Microsoft in 2019 and created Playwright — a full framework with multi-browser support, BrowserContext isolation, fixtures, auto-wait, and a built-in test runner.

Playwright is NOT a fork of Puppeteer. It is a ground-up rewrite with the same engineering team (Andrey Lushnikov, Joel Einbinder, and others) applying all lessons learned: Puppeteer was Chrome-only, had no built-in test runner, no isolation model, no auto-wait, and no fixtures. Playwright addressed all of these.

Today Puppeteer supports multiple browsers and has improved significantly. But Playwright's fixture system, trace viewer, component testing, and MCP integration make it the preferred choice for enterprise SDET work.

What is the difference between headless and headed mode? How does headless take a screenshot without opening a browser window?

Short answer: In headless mode the full browser rendering pipeline runs (parse → layout → paint → composite) but the compositor outputs to an in-memory framebuffer, not a screen. Screenshots read from that buffer via CDP. No visible window is needed because the rendering is decoupled from display output.

Headed mode connects the compositor output to a real display server (X11 on Linux, Quartz on macOS, Win32 on Windows) so you see the browser window.

Headless mode: the compositor writes frames to memory. page.screenshot() sends a CDP Page.captureScreenshot command; the browser encodes the in-memory frame as PNG/JPEG and returns it over the WebSocket. No screen required.

Modern Chromium uses --headless=new (native headless) which doesn't need Xvfb. Older setups used Xvfb — a virtual framebuffer X11 server that tricks the browser into thinking a real display exists. Playwright on modern CI uses native headless.

When to use headed? Local debugging, recording videos for bug reports, visual regression baseline capture on a calibrated display.

Explain how Playwright works at the architecture level — from your test code all the way to the DOM click event. Don't just say "WebSocket."

Here is the complete flow for await page.click('#submit'):

  1. Test code calls page.click('#submit') — a Promise is returned and awaited
  2. Playwright driver (Node.js, playwright-core library) translates this into a structured CDP command: { method: "Input.dispatchMouseEvent", params: { type: "mousePressed", x: 100, y: 200, ... } }
  3. Before sending, the driver runs actionability checks — it sends CDP DOM queries to verify the element exists, is visible, not covered, enabled, and stable. This polling loop is what "auto-wait" is.
  4. Transport layer — the command is serialized to JSON and sent over a WebSocket connection (or a named pipe on the same machine for performance) to the browser server process
  5. Browser process receives the CDP command, routes it to the correct page frame, and executes it: finds the element by selector, scrolls it into view if needed, calculates the center point, dispatches synthetic mouse events (mousedown, mouseup, click) into the browser's event system
  6. Rendering engine (Blink) — the click event propagates through the DOM, event listeners fire, React/Vue/Angular state updates run, the DOM may change, a new frame is rendered
  7. CDP response comes back to the Playwright driver — either success or an error (element not found, element detached, timeout)
  8. Promise resolves in your test code — execution continues to the next line

The WebSocket is just the transport. The intelligence is in: the actionability check loop (driver side), the CDP command mapping (driver side), and the browser's event dispatch (browser side).

If a test passes in headed mode but fails in headless, what would you investigate?

This is a common flakiness pattern. Causes to investigate:

  • Timing: Headless is faster — animations/transitions not yet complete when assertion fires. Add explicit waits or use Playwright's auto-wait correctly (avoid waitForTimeout)
  • Font rendering: Different fonts in headless can shift layout, causing elements to be slightly off-position for click coordinates
  • Viewport: Default headless viewport may differ — set explicitly in config
  • GPU features: WebGL or CSS transforms using GPU acceleration may behave differently without a real GPU compositor
  • Focus behavior: Some browsers behave differently for focus events without a real window
  • Media queries: prefers-color-scheme, prefers-reduced-motion may have different defaults in headless

Debug approach: run with --headed first to confirm it's a headless-specific issue → add slowMo: 100 to slow down actions → capture trace (--trace on) in both modes → compare screenshots.

What happens when page.locator() and page.$()/page.$eval() both exist — which should you use and why?

page.locator() is lazy — it does not query the DOM when called. It returns a Locator object that queries and retries only when an action (.click()) or assertion (expect(locator).toBeVisible()) is invoked. This enables auto-wait and chaining.

page.$() (ElementHandle) queries the DOM immediately and returns a snapshot. If the DOM changes after the query, the handle may be stale. ElementHandles are the Puppeteer-era API — Playwright deprecated them in favor of Locators.

Always use page.locator(). The only reason to use ElementHandle today is for niche frame or shadow-DOM traversal scenarios where the Locator API doesn't yet reach, or when interfacing with legacy Puppeteer-compatible code.

Why can't all testing be done with just the browser automation APIs? What value does a framework like Playwright add over raw CDP?

Raw CDP gives you browser control. A test framework adds everything else:

  • Test runner: Discover, execute, retry, report tests without writing your own orchestration
  • Fixtures: Dependency injection, setup/teardown, scope management (worker/test/file)
  • Auto-wait: CDP gives you "click at coordinate" — Playwright adds "wait until element is ready, then click at the right coordinate"
  • Isolation: BrowserContext-per-test managed automatically by fixtures
  • Parallel workers: Process spawning, test distribution, result aggregation
  • Assertions: expect(locator).toBeVisible() with built-in retry — not a one-shot check
  • Reporting: HTML reports, Allure integration, JUnit XML for CI
  • Developer experience: Trace viewer, codegen, inspector, VS Code extension

Raw CDP requires you to build all of this yourself. Playwright packages 5+ years of real-world testing experience into the framework layer.

Trade-off & Scenario Q&A

When would you NOT use a new BrowserContext per test? What's the trade-off?

You might share a BrowserContext across tests when:

  • Auth setup is extremely expensive (OAuth flow with MFA, complex SSO)
  • You have a large suite (1000+ tests) and the auth state is truly stable across all of them
  • You use test.describe.serial() for an intentional sequential suite that simulates a user session

Trade-off: shared context = shared cookies/storage = test interdependence. A failing test that corrupts state will cascade failures to subsequent tests. For this reason, Playwright's default (new context per test) is almost always the right choice. Use storageState to save logged-in auth state and restore it per-context — you get isolation AND skip the login UI every test.

Your test works locally but is flaky on CI. What is your systematic debugging approach?
  1. Reproduce in CI conditions: Run locally with CI=true, --workers=4, headless mode, same viewport
  2. Enable trace: trace: 'on-first-retry' in config → download trace artifact → open in npx playwright show-trace
  3. Check timing: Is there a waitForTimeout? Replace with event-based waits. Is there a waitForSelector with too-short timeout?
  4. Check shared state: Could another parallel test be writing to the same database record or auth cookie? Ensure test data isolation.
  5. Check CI environment: Different Node version? Different OS? Font differences causing layout shift?
  6. Add retries: retries: 2 in CI config as a safety net — but also fix the root cause
  7. Run 10x locally: npx playwright test --repeat-each=10 to reproduce the race condition
How would you design a Playwright test framework from scratch for a new team of 5 SDETs?

Key decisions in order:

  1. Fixture architecture: Define global fixtures (browser, context, page) + domain fixtures (loginPage, checkoutPage). Share via a central fixtures/index.ts
  2. Page Object Model: One class per page, locators as getters, actions as methods. Keep assertions OUT of page objects — they belong in tests.
  3. Auth strategy: global-setup.ts to log in once per run, save storageState per role. Tests restore state without re-logging in.
  4. Test data management: Unique test data per run (timestamp/UUID in names), API setup/teardown for database state.
  5. Tag strategy: @smoke (5min, gates deploy), @regression (full suite, nightly), @critical (flakiness-zero tolerance)
  6. CI pipeline: Sharded matrix (3 browsers x N shards), parallel jobs, Allure report artifact, GitHub Pages deploy
  7. Flakiness policy: Any test failing 3x in a week → quarantine tag → root cause required before re-enabling