A QA engineer I talked to last year described her first Playwright interview loop like this: the interviewer pulled up a flaky Selenium test suite from the company's own repo, thirty seconds of retries and Thread.sleep calls scattered through it, and just asked "how would you fix this." Not "what is Playwright." Not "list the browsers it supports." The whole thirty minutes was one broken test and a shared screen. That's the shape most Playwright interviews actually take now, less trivia, more "here's something ugly, make it not flaky."
Part of why that question works is that Playwright was built specifically to remove the class of bugs that made tools like Selenium fragile. Microsoft's own framework performs actionability checks, visible, stable, receives events, enabled, editable, before every action, and blocks until an element actually satisfies them or the timeout runs out (Playwright docs, actionability). A candidate who can explain why that removes most sleep() calls, and where it still can't save you, usually has real hands-on time with the tool rather than a afternoon reading the quickstart.
This page covers Playwright interview questions across locators and selector strategy, auto-waiting and web-first assertions, test structure with fixtures and isolation, network interception and API testing, authentication and parallel execution, and debugging with the trace viewer, plus a section of harder edge cases: iframes, shadow DOM, multiple contexts, and visual regression. Code examples use TypeScript, Playwright's primary language, throughout.
Fundamentals: what Playwright is and how it talks to the browser
Every loop opens here, even for candidates who've shipped a full suite. The point isn't to test memorization, it's to see whether you can explain the mechanism, not just the marketing.
Easy questions
15Playwright is an end-to-end testing and browser automation framework from Microsoft that drives Chromium, Firefox, and WebKit through a single API. It was built by engineers who had previously worked on Puppeteer, and the design goal was cross-browser automation that doesn't need a separate driver binary per browser the way Selenium does with its WebDriver protocol.
The bigger problem it targets is flakiness. Most broken browser tests fail because the test clicks an element before the page has finished rendering it, or before an animation settles, not because the test logic is wrong. Playwright bakes waiting and retry behavior into the API itself instead of leaving it to the test author to sprinkle sleeps and manual waits everywhere.
A Browser is one launched browser process. A BrowserContext is an isolated, incognito-like session inside that browser, its own cookies, local storage, and cache, completely separate from any other context in the same process. A Page is a single tab inside a context. One context can hold multiple pages, useful for testing flows that open a new tab or a popup.
const browser = await chromium.launch();
const context = await browser.newContext(); // fresh, isolated session
const page = await context.newPage();
const secondContext = await browser.newContext(); // completely separate cookies/storageSpinning up a new context is cheap compared to launching a new browser, which is exactly why the test runner creates a fresh context per test by default instead of a fresh browser.
A Locator is a description of how to find an element, not a reference to a specific DOM node captured at one moment. Every time you call an action on a locator, Playwright re-queries the DOM and locates the current matching element (Playwright docs, locators). An ElementHandle, by contrast, is a live reference to one specific node, and it goes stale the moment that node gets detached and re-rendered, which happens constantly in a React or Vue app.
const button = page.locator('button.submit');
// button doesn't point at anything yet, it's a query
await button.click(); // resolves the current matching element, then clicks it
// if the DOM re-rendered that button in between, this still worksElementHandle still exists for narrow low-level cases, but the official guidance is to reach for locators for essentially everything now.
getByText is the direct tool for this, and it works on exact text or a substring, with an option for exact matching. For text nested inside other markup, it still matches on the element's accumulated text content, not just a direct text node.
await page.getByText('Order confirmed').click();
await page.getByText('Order', { exact: false }); // substring match, default behavior
await page.getByText('Order confirmed', { exact: true }); // must match exactlyIt's a reasonable fallback for read-only content like confirmation banners or toast messages where there's no interactive role to hang getByRole off of.
A web-first assertion, anything using expect(locator), retries automatically until the condition is true or a timeout elapses. A plain check that reads a value once and compares it immediately has no retry built in, so it fails the instant it's evaluated even if the element would have become visible half a second later.
await expect(page.getByText('Saved')).toBeVisible(); // retries up to the timeout
const isVisible = await page.getByText('Saved').isVisible();
expect(isVisible).toBe(true); // checked once, right now, no retryThe second version is a classic source of flaky tests, it passes locally where the machine is fast and fails intermittently in CI where a render is a beat slower.
Because reusing a context means reusing its cookies, local storage, and cached auth state, and any test that logs in, logs out, or mutates client-side state contaminates whatever runs after it. A fresh context per test guarantees test order doesn't matter and tests can run in parallel across workers without one interfering with another's session.
The tradeoff is cost. Spinning up a context per test is fast, but not free, which is exactly why login is usually done once via storageState reuse rather than repeating a full UI login flow inside every single test.
UI Mode is an interactive test runner with a visual timeline, time-travel through each step, a live locator picker, and a watch mode that reruns tests as you edit them, meant for active day-to-day authoring and debugging. The Inspector, triggered by setting PWDEBUG=1 or calling page.pause(), pauses execution at a specific point and opens a lighter debugging panel with step controls, more useful for pinpointing exactly where inside one specific test a failure happens.
In practice most people live in UI Mode while writing new tests and reach for page.pause() when they need to freeze execution at one exact line inside an existing test that's already mostly working.
Playwright drives Chromium, Firefox, and WebKit. Chromium is the open-source engine Google Chrome and Edge are built on, close enough that most Chrome-specific behavior applies, but it isn't Google's exact shipped Chrome binary with all of Google's proprietary bits. Firefox and WebKit are patched builds that Playwright's own team maintains specifically to add the automation hooks the framework needs, not the stock browser installs you'd download yourself.
Because Playwright bundles and tests against these specific builds, a given Playwright version is guaranteed to work with the exact browser version it shipped with, which is why npx playwright install downloads its own browser copies instead of using whatever's already on your machine.
TypeScript and JavaScript are the primary, most fully-featured bindings, and that's what almost all official docs and examples use. Playwright also ships official bindings for Python, Java, and .NET, with the same underlying API shape and actionability model across all four, just adapted to each language's conventions.
codegen opens a browser, records your clicks and inputs as you interact with the page, and generates the corresponding Playwright code live, including reasonably sensible locators picked from the page's roles and text.
npx playwright codegen https://example.comIt's genuinely useful for scaffolding a first draft or for quickly finding a good locator for a tricky element, but the generated code usually needs cleanup afterward, assertions it never adds on its own, and locators that sometimes need tightening. Treating its raw output as a finished test is a common beginner mistake.
setInputFiles sets the files on a file input directly, without needing to drive the OS's native file picker dialog, which automation tools generally can't interact with anyway since it's outside the browser process.
await page.getByLabel('Upload resume').setInputFiles('fixtures/resume.pdf');
await page.getByLabel('Upload resume').setInputFiles([]); // clears selected filesRegister a listener on the page's 'dialog' event before the action that triggers it, since native dialogs block the page and Playwright auto-dismisses any dialog it doesn't have a listener for by default, which can otherwise silently hang a test.
page.on('dialog', async (dialog) => {
console.log(dialog.message());
await dialog.accept(); // or dialog.dismiss()
});
await page.getByRole('button', { name: 'Delete account' }).click();retries tells the runner to re-run a failed test up to that many additional times before marking it as truly failed, and it's commonly set higher in CI than locally since CI environments are more prone to transient slowness.
export default defineConfig({
retries: process.env.CI ? 2 : 0,
});It's a mitigation, not a fix. A test that fails one run in five because of a real race condition in the app or the test will often pass on retry and hide that bug from view instead of surfacing it, so a suite that leans on retries to stay green without anyone investigating why tests fail the first time is accumulating debt, not stability.
Pass the file path directly to the CLI, or use -g with a pattern matching the test's title, or add .only to a specific test or describe block in the source itself.
npx playwright test checkout.spec.ts
npx playwright test -g "user can complete checkout"
test.only('this is the one test that runs', async ({ page }) => { /* ... */ });test.only is convenient while actively debugging one test, but it's also a common cause of "why did CI skip half my suite" when someone forgets to remove it before committing, which is why some teams add a lint rule that fails CI if test.only shows up anywhere.
Headless mode runs the browser with no visible window, which is faster and lighter on resources, exactly what you want on a CI runner executing hundreds of tests where nobody's watching a screen. Headed mode renders an actual visible browser window, useful locally when you want to watch a test execute step by step to understand why it's failing.
Rendering differences between headless and headed used to be a real source of flaky tests in older Chrome versions, headless Chrome now uses the same rendering engine as headed, so that gap has mostly closed, but it's still worth knowing as a possible explanation if a test behaves differently between the two modes.
Medium questions
25Playwright communicates with the browser over each browser's own DevTools-style protocol (CDP for Chromium, and equivalent internal protocols patched into custom Firefox and WebKit builds it ships and controls), rather than going through the W3C WebDriver wire protocol that Selenium uses. That's a meaningfully different architecture: Playwright bundles browser binaries it has tested against, so a given Playwright version is paired to specific browser builds instead of depending on whatever driver version happens to be installed on the machine.
The practical upside is fewer version-mismatch headaches between your automation code and the browser, and lower-level access to network requests, console messages, and page lifecycle events, since the protocol wasn't designed around a generic cross-vendor spec.
The isolation comes from the browser context, not the browser or the test file. The @playwright/test runner hands every test a brand new context and page fixture, so cookies, local storage, session storage, and cache from one test never leak into the next. That's what lets you run tests in any order, or in parallel across workers, without one test's login session bleeding into an unrelated test.
It's not free of gotchas though. If two tests both write to the same backend database record, isolation at the browser level doesn't protect you from that collision, you still need to think about test data the same way you would with any suite hitting a shared API.
The documented priority is getByRole, then getByText, getByLabel, getByPlaceholder, getByAltText, getByTitle, with getByTestId as a fallback, and raw CSS or XPath discouraged as a last resort (Playwright docs, locators). getByRole comes first because it queries the accessibility tree the same way a screen reader would, which means the test is checking something a real user actually perceives, a button that says "Submit", not an implementation detail like a class name.
await page.getByRole('button', { name: 'Submit' }).click();
await page.getByLabel('Email address').fill('a@b.com');
await page.getByTestId('cart-total').textContent();The side benefit is durability. A designer renaming a CSS class or a developer swapping a div for a button breaks a CSS selector but not a role-based query, since the accessible role and name usually survive markup changes that visual styling doesn't.
Strict mode is the default behavior where an action-triggering call on a locator throws an error if the locator resolves to more than one element, instead of silently acting on the first match. It exists to catch a specific bug class: a selector that's technically valid but ambiguous, matching three rows in a table when the test author only meant to interact with one.
await page.locator('li.item').click();
// throws: strict mode violation, resolved to 5 elements
await page.locator('li.item').first().click(); // opts out explicitly
await page.locator('li.item').nth(2).click(); // also opts out explicitlyReaching for first() or nth() to silence a strict-mode error, rather than tightening the locator to be unambiguous, is a smell interviewers watch for. It usually means the test will pass today and break the moment the list order changes.
Locators support chaining with .locator() and filtering with .filter(), both of which narrow the search to descendants of the parent locator instead of the whole page. This is how you disambiguate two elements with identical text or role that only differ by which container they sit in.
const row = page.locator('tr', { hasText: 'Invoice #4821' });
await row.getByRole('button', { name: 'Delete' }).click();
const product = page.locator('.product-card').filter({ hasText: 'In Stock' });
await product.getByRole('button', { name: 'Add to cart' }).click();This matters most on pages built from repeated components, product grids, table rows, kanban cards, where the same button text shows up dozens of times and only container scoping tells them apart.
Before most actions, Playwright checks that the element is visible, stable (same bounding box across two consecutive animation frames), receives pointer events (nothing else is on top of it), and enabled. Fill also checks editable. If a check keeps failing, the action retries until it passes or the timeout runs out, then throws a TimeoutError with a clear reason instead of a generic failure (Playwright docs, actionability).
Not every action needs every check. fill() doesn't need the stable or receives-events checks the way click() does, since typing into a field doesn't depend on it sitting still relative to an overlay. Knowing that different actions apply a different subset is the detail that separates someone who's read the source from someone who's just used the API casually.
Usually because of something auto-waiting genuinely can't see: an animation that finishes with no DOM change, a debounced input that only fires an API call 300ms after the last keystroke, or a third-party widget that renders on a delay outside the page's own event lifecycle. Auto-waiting tracks specific, observable conditions, visibility, stability, network idle in some helpers, and can't wait for something with no detectable signal.
The honest fix in most of those cases isn't a hardcoded sleep either, it's waiting on the actual signal: waitForResponse for the debounced call, or a data attribute the app sets when the animation completes. A fixed sleep is a workaround people reach for under deadline pressure, and it's worth saying so plainly in an interview rather than pretending it's never in your code.
Use a regular expression or a partial matcher instead of an exact string, or assert on a stable substring rather than the whole text node.
await expect(page.getByTestId('order-count')).toHaveText(/d+ orders/);
await expect(page.getByTestId('timestamp')).toContainText('Updated');For values that are genuinely unpredictable, count, id, timestamp, asserting the shape of the text rather than the exact value keeps the test meaningful without becoming brittle to data that changes between runs.
A fixture is a piece of test setup and teardown wrapped into a reusable, dependency-injected unit that a test declares by naming it as a parameter, rather than a hook that runs unconditionally for every test in a file (Playwright docs, fixtures). Built-in fixtures like page, context, and browser are provided automatically. Custom ones are defined with test.extend().
export const test = base.extend<{ loggedInPage: Page }>({
loggedInPage: async ({ page }, use) => {
await page.goto('/login');
await page.getByLabel('Email').fill('user@test.com');
await page.getByRole('button', { name: 'Log in' }).click();
await use(page); // hand control to the test
// teardown code, if any, runs here after the test finishes
},
});The key advantage over a shared beforeEach is that fixtures are lazy and composable, a test only pays the setup cost for the fixtures it actually names, and fixtures can depend on each other in a clear chain instead of everything running through one big shared hook that every test in the file inherits whether it needs it or not.
Test-scoped fixtures, the default, get set up fresh and torn down for every single test, which is what gives you full isolation. Worker-scoped fixtures are created once per worker process and reused across every test that worker runs, which matters for anything expensive to set up that doesn't need to be unique per test, seeding a test database, starting a local server, or authenticating a shared service account.
export const test = base.extend<{}, { apiToken: string }>({
apiToken: [async ({}, use) => {
const token = await authenticateServiceAccount(); // runs once per worker
await use(token);
}, { scope: 'worker' }],
});Getting this wrong in the expensive direction, worker scope for something that should be test scope, is how state leaks between tests that are supposed to be independent.
Define the shared setup as a fixture in one file and export a customized test object built on test.extend(), then have every test file import that test object instead of the base one from @playwright/test. Any file that imports the extended test gets the fixture automatically, no repeated hook code, and it composes cleanly if you need to extend it further for a subset of tests.
// fixtures.ts
export const test = base.extend<{ adminPage: Page }>({
adminPage: async ({ browser }, use) => {
const context = await browser.newContext({ storageState: 'admin.json' });
const page = await context.newPage();
await use(page);
await context.close();
},
});
// some-admin.spec.ts
import { test } from './fixtures';
test('admin can delete a user', async ({ adminPage }) => { /* ... */ });page.route registers a handler for requests matching a URL pattern, glob or regex, and gives you full control before the request reaches the network: you can let it continue unmodified, abort it, fulfill it with a fabricated response, or continue it with modified headers or a modified body (Playwright docs, network).
await page.route('**/api/user-profile', async (route) => {
await route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify({ name: 'Test User', plan: 'pro' }),
});
});
await page.goto('/dashboard'); // renders using the mocked response, no real API hitMock it when you need to test a state that's hard or slow to produce for real, an error response, an empty result set, a specific edge-case payload, or when the real backend is flaky, rate-limited, or simply not something the test owner controls. Mocking also decouples a frontend test suite from backend deploy timing, so a UI regression test doesn't fail just because a staging API happened to be down.
The tradeoff is real: a heavily mocked suite can pass while the actual integration is broken, since it's testing the frontend's handling of a shape you defined by hand, not what the backend actually returns today. Most mature suites keep a smaller set of true end-to-end tests against a real staging environment alongside the larger, faster, mocked suite.
waitForResponse takes a URL pattern, regex, or predicate function and resolves once a matching response comes back, so you can pair it with the action that triggers the request and get a promise that settles exactly when the network call finishes, not an arbitrary number of milliseconds later.
const responsePromise = page.waitForResponse((res) =>
res.url().includes('/api/checkout') && res.status() === 200
);
await page.getByRole('button', { name: 'Place order' }).click();
const response = await responsePromise;Starting the wait before triggering the action matters, if you await the click first and only then set up waitForResponse, the response can arrive and resolve before your listener even exists.
APIRequestContext lets you send real HTTP requests directly, no browser rendering involved, which makes it useful both for pure API testing and for fast test setup, creating a user account or seeding data through an API call instead of clicking through a signup form every time a test needs a logged-in user.
const response = await request.post('/api/users', {
data: { email: 'test@example.com', password: 'secret123' },
});
expect(response.status()).toBe(201);Using the API to set up state and the UI only to test the actual behavior under test is one of the bigger speedups available in a Playwright suite, since API calls run in milliseconds compared to a full form submission through a rendered page.
Log in once in a dedicated setup step, save the resulting cookies and storage with storageState, and load that saved state into every test's context afterward. In the Playwright test runner this is usually wired up as a setup project that other projects declare a dependency on, so it always runs first and authenticates before the real tests start (Playwright docs, auth).
// auth.setup.ts
setup('authenticate', async ({ page }) => {
await page.goto('/login');
await page.getByLabel('Email').fill('user@test.com');
await page.getByRole('button', { name: 'Log in' }).click();
await page.context().storageState({ path: 'playwright/.auth/user.json' });
});Tests then just reference that storage state file in config instead of repeating the login flow, which turns a five-second UI login into essentially zero added time per test.
By default the test runner spins up multiple worker processes and distributes test files across them, each worker running its own tests sequentially inside its own isolated set of browser contexts. That's parallel execution within one machine. Sharding is a separate mechanism, splitting the whole test suite across multiple machines or CI jobs using flags like --shard=1/3, so a suite that takes twenty minutes on one runner can finish in under seven across three parallel CI jobs.
The two stack: each shard still runs its own set of workers locally within that machine. Getting the worker count and shard count both right for your CI runner's actual CPU and memory limits, not just cranking both numbers up, is usually where the real speed gains are.
A trace captures a full DOM snapshot before, during, and after every action, all network requests with headers and bodies, console logs from both the page and the test, and a timeline with a filmstrip of what the page looked like at each step (Playwright docs, trace viewer). It's usually left off for the happy path locally and turned on with trace: 'on-first-retry' in CI, so you only pay the recording cost for tests that actually failed once and are being retried, not for every passing test.
// playwright.config.ts
export default defineConfig({
use: { trace: 'on-first-retry' },
});Get a FrameLocator for the iframe first, then locate elements inside it the same way you would on the main page. Playwright treats the frame as its own scoped document, and you can't accidentally reach into it with a page-level locator, the iframe boundary is enforced.
const frame = page.frameLocator('iframe[name="payment"]');
await frame.getByLabel('Card number').fill('4242 4242 4242 4242');
await frame.getByRole('button', { name: 'Pay' }).click();Nested iframes just chain another frameLocator call. Cross-origin iframes, common with embedded payment widgets like Stripe, work the same way from Playwright's side, since the automation protocol operates below the browser's own same-origin restrictions.
Set up a listener for the context's 'page' event before triggering the click, since the new tab is a new Page object belonging to the same BrowserContext, not something the original page reference knows about on its own.
const [newPage] = await Promise.all([
context.waitForEvent('page'),
page.getByRole('link', { name: 'Open in new tab' }).click(),
]);
await newPage.waitForLoadState();
await expect(newPage).toHaveURL(/dashboard/);The Promise.all pattern matters here for the same reason it matters with waitForResponse, you need the listener armed before the action fires, or there's a race where the new tab opens before anything is listening for it.
Playwright ships a library of predefined device descriptors, viewport, user agent, touch support, device scale factor, that you spread into a browser context's launch options, and geolocation is a separate context option that also requires granting the geolocation permission explicitly.
import { devices } from '@playwright/test';
const context = await browser.newContext({
...devices['iPhone 13'],
geolocation: { latitude: 51.5074, longitude: -0.1278 },
permissions: ['geolocation'],
});This is context-level configuration, not page-level, which matters if a test needs two different simulated locations open side by side, that's two separate contexts, not two pages in one.
You can pre-grant or deny permissions at the context level so the app behaves as if the user already responded, and you can assert on the app's resulting behavior, but Playwright doesn't give you a hook to intercept and assert on the native permission prompt UI itself, since that prompt is rendered by the browser chrome, outside the page Playwright automates.
const context = await browser.newContext({ permissions: ['camera', 'microphone'] });The practical takeaway for an interview: you test the app's reaction to a granted or denied permission, not the browser's own permission UI, that distinction trips people up who haven't hit it before.
context.setOffline(true) simulates a fully offline network, useful for testing offline banners or retry logic. For throttling rather than a full outage, route handlers can add an artificial delay before fulfilling or continuing a request, which is a more common way to test loading spinners than trying to configure real network throttling.
await context.setOffline(true);
await page.reload();
await expect(page.getByText('You are offline')).toBeVisible();
await page.route('**/api/**', async (route) => {
await new Promise((r) => setTimeout(r, 2000)); // simulate slow network
await route.continue();
});A soft assertion, expect.soft, records a failure but lets the test keep executing instead of throwing immediately, so you can collect every assertion failure in a single test run into one report instead of stopping at the first mismatch and having to fix and rerun repeatedly to find the next one.
await expect.soft(page.getByTestId('title')).toHaveText('Dashboard');
await expect.soft(page.getByTestId('subtitle')).toHaveText('Welcome back');
// test continues even if the first assertion above failedIt's most useful for a single test that's checking many independent pieces of one page, a full form's worth of validation messages, say, where you want the complete picture of what's broken in one run rather than fixing failures one at a time.
Define multiple entries in the projects array, one per browser, each pointing at the same test files but with a different browserName or use option. The test runner then runs every spec file once per project, so a single test file effectively runs three times, once per configured browser, without any duplicated test code.
export default defineConfig({
projects: [
{ name: 'chromium', use: { ...devices['Desktop Chrome'] } },
{ name: 'firefox', use: { ...devices['Desktop Firefox'] } },
{ name: 'webkit', use: { ...devices['Desktop Safari'] } },
],
});This is also how most teams structure device emulation projects, a "mobile-chrome" project alongside "desktop-chrome", both pulling from the same test files, just with different device presets applied.
Hard questions
10Because every test sharing that one storageState is effectively acting as the same logged-in user against a shared backend, so two tests running in parallel can race each other, one test deletes a record the other test just created and is about to assert against. The fix is per-worker authentication instead of one global shared login: each worker authenticates its own account, via a worker-scoped fixture, so parallel workers never fight over the same server-side state even though they're running simultaneously.
This is one of the places where "just add more parallel workers" quietly breaks a suite that looked stable at low concurrency and starts failing intermittently once it's scaled up in CI.
First pull the trace file from the CI artifact and open it in the trace viewer, it usually shows exactly what the DOM looked like at the moment of failure, which is often the fastest way to see a race condition a log line would never surface. Common root causes worth checking specifically: CI machines are slower and can expose a real timing bug that a fast local machine masks, CI often runs headless while local runs headed and rendering differs subtly between the two, and CI frequently runs at higher parallelism, exposing shared-state races that never show up running one test at a time locally.
If the trace doesn't make it obvious, reproducing the exact CI conditions locally, headless mode, the same worker count, the same viewport, is usually more productive than guessing and adding console.log calls one at a time.
Yes. Playwright's built-in locator engine automatically pierces open shadow roots, so a CSS selector or a getByRole call matches elements inside a shadow root the same way it would in the light DOM, without any special shadow-DOM-aware syntax. This is one of the concrete advantages over Selenium's default CSS engine, which stops at a shadow boundary and needs explicit traversal to get inside.
// works even if <my-widget> uses open shadow DOM internally
await page.locator('my-widget').getByRole('button', { name: 'Confirm' }).click();The caveat is closed shadow roots, which no automation tool can see into from outside, since the browser itself doesn't expose them. If a component author deliberately closed the shadow root, there's no locator workaround, only a conversation with whoever owns that component about testability.
toHaveScreenshot captures a screenshot and diffs it pixel-by-pixel against a stored baseline image, failing if the difference exceeds a configurable threshold. It's genuinely good at catching a whole class of bugs that DOM assertions miss entirely, a broken CSS grid, an overlapping z-index, a font that failed to load.
await expect(page).toHaveScreenshot('checkout-page.png', { maxDiffPixels: 100 });The honest downside is baseline maintenance. Screenshots are sensitive to font rendering differences between operating systems, anti-aliasing, and even minor browser version updates, so teams running visual tests across different OS environments than where the baseline was captured often chase false-positive diffs that have nothing to do with an actual bug. Most teams that use it seriously pin a specific Docker image for generating and comparing screenshots, precisely to remove that variable.
Component testing mounts a single UI component in isolation, React, Vue, or Svelte, inside a real browser, and lets you interact with it and assert on it using the same locator and assertion API as a full page test, without needing a running application or backend behind it.
import { test, expect } from '@playwright/experimental-ct-react';
import { Counter } from './Counter';
test('increments on click', async ({ mount }) => {
const component = await mount(<Counter initial={0} />);
await component.getByRole('button', { name: '+' }).click();
await expect(component.getByText('1')).toBeVisible();
});It sits between a unit test and a full end-to-end test, faster and more isolated than driving the whole app through a browser, but still running in a real browser engine rather than a simulated DOM like jsdom, which catches real rendering and CSS behavior that a jsdom-based unit test can't see.
For drag-and-drop, use dragTo or the lower-level hover, mouse.down, mouse.move, mouse.up sequence when a component's drag handling doesn't respond to the simplified dragTo helper, since some custom drag implementations listen for specific intermediate mousemove events that a single jump doesn't trigger.
await page.locator('#task-1').dragTo(page.locator('#done-column'));
// lower-level fallback for finicky custom drag implementations
const source = page.locator('#task-1');
await source.hover();
await page.mouse.down();
await page.mouse.move(400, 300, { steps: 10 });
await page.mouse.up();For a custom date picker, the reliable approach is usually to interact with it exactly the way a user would, click to open it, click through to the right month, click the day, rather than trying to set the underlying input's value directly, since most date picker components ignore a value set outside their own click handlers and just overwrite it on the next render.
Both are context-level options set at newContext time, since they affect how the browser's own Intl and Date APIs behave for everything rendered inside that context, not something you can toggle mid-test on an existing page.
const context = await browser.newContext({
locale: 'de-DE',
timezoneId: 'Europe/Berlin',
});
const page = await context.newPage();
await page.goto('/checkout');
// currency and date formatting now reflect de-DE / Europe/BerlinThis matters more than it sounds for any product with real internationalization, a bug where a price renders with a comma instead of a period as a decimal separator only shows up under the right locale, and a suite that never varies locale will never catch it.
That symptom almost always means test pollution, shared state one test leaves behind that a later test unknowingly depends on or gets broken by. Start by running just that test alongside the one immediately before it in file order, then narrow from there, since Playwright workers usually run files in a fairly stable order and the culprit is often adjacent.
Common concrete causes: a shared backend record two tests both mutate, a worker-scoped fixture that was supposed to be test-scoped, or global app state, a feature flag, a seeded database row, that one test flips and never resets. The fix is almost never "add a wait", it's finding and removing the shared dependency, usually by making the affected fixture test-scoped or by having each test create its own uniquely-named test data instead of reusing a fixed record.
Because parallelism exposes races that never had a chance to happen at low concurrency. Two workers hitting the same shared backend resource, the same rate-limited third-party API, the same seeded test account, at the same time is a scenario that simply didn't exist when tests ran one at a time. The suite wasn't actually free of the bug before, it just never had two tests colliding on the same second.
The fix is almost always isolating whatever's shared, unique test accounts per worker, unique data per test rather than a fixed seed record, rate-limit-aware retry logic if a third-party API is genuinely shared and can't be worker-scoped. Turning parallelism back down is a valid short-term mitigation, but it doesn't fix the underlying design issue, it just makes the race less likely to fire.
It means the specific DOM node a reference pointed at got removed or replaced, usually by a framework re-render, between when the reference was captured and when the test tried to act on it. ElementHandle captures one static reference at query time, so any re-render in between invalidates it. A Locator re-queries the DOM fresh on every action, so it naturally survives a re-render as long as a matching element still exists somewhere on the page afterward.
Seeing this error in a codebase still using page.$ or ElementHandle directly is usually a sign it should be migrated to Locator-based code, since the error is largely a symptom of using the older, more brittle API on a page that re-renders, which most modern single-page apps do constantly.
How to prepare for a Playwright interview
Skip re-reading the getting-started guide. Take a UI you already know has some rough edges, a table with pagination, a form with a debounced autosave, a modal that animates in, and write tests against it using only role-based locators and web-first assertions, no CSS selectors, no waitForTimeout. If you get stuck, that's the exact gap an interviewer is likely to probe. Then break something on purpose, delete a data-testid, rename a button's visible label, and watch which of your tests survive it and which don't.
Across the mock interview sessions LastRoundAI runs for QA and SDET roles, the question that trips up more candidates than any other isn't about locators or assertions, it's the one about shared authentication state breaking under parallel execution. Most people who've written Playwright tests solo have never hit that race condition, because it only shows up once a suite runs with real concurrency against a shared backend. It's worth thinking through before you're asked, not during.
One more thing worth saying plainly: a lot of interview prep treats Playwright as interchangeable with Selenium or Cypress and tests the same generic automation trivia either way. The questions that actually distinguish a strong Playwright candidate are about the mechanisms specific to it, actionability checks, context-level isolation, and locator re-resolution, not "what is a test case" in the abstract.
Get the reps in before the real thing
Explaining actionability checks on paper is not the same as walking an interviewer through a live flaky test and fixing it out loud while they watch. LastRoundAI's mock interview mode runs live technical rounds with real-time follow-up questions in your browser, and the free plan includes 15 credits a month that reset monthly rather than piling up unused. Starter is $19/mo if a handful of sessions isn't enough runway.
Once your answers hold up under a follow-up question, the slower part of a QA or SDET job search is usually just getting in front of enough roles that actually test browser automation instead of treating it as a checkbox on a job description. Auto-Apply queues tailored applications for your review, 10 a month on the free plan, up to 400 a month on the Ultimate plan, and nothing goes out until you approve it.
Questions about either product go to contact@lastroundai.com. That's the only inbox we check.

