Playwright Interview Questions and Answers
Last updated:
Check out 30 of the most common Playwright interview questions, then take an AI-powered practice interview
Q1What is Playwright and why has it become the industry standard over Cypress?
BasicFundamentals
Answer
Playwright is an open-source end-to-end testing framework released by Microsoft in 2020, built by the team that previously created Puppeteer at Google. It enables automated testing of web applications across Chromium, Firefox, and WebKit (Safari engine) using a single unified API. Tests can be written in TypeScript, JavaScript, Python, .NET, or Java, though the TypeScript runner ships with the most features and is the de-facto default in 2026.
Three reasons it has displaced Cypress as the 2026 default: (1) true cross-browser support, Cypress only added WebKit/Firefox parity recently and it is still limited, while Playwright runs identical tests on Chromium, Firefox, and WebKit from day one with no extra configuration. (2) Multi-tab, multi-origin, and iframe handling, Cypress runs inside the browser so it cannot cross origins or open multiple tabs, while Playwright drives the browser externally via the Chrome DevTools Protocol (and equivalent protocols for Firefox/WebKit) and has no such restriction. This matters enormously for OAuth flows, payment redirects, and multi-user scenarios. (3) Native parallelism, Playwright runs test files in parallel workers by default, while Cypress has historically required paid Cypress Cloud for proper parallelism. Add in auto-waiting that eliminates 90% of `sleep` calls, the trace viewer that has changed how teams debug flaky tests, async/await TypeScript ergonomics, and free open-source parallel execution, and the choice for new projects is fairly clear. Microsoft, Vercel, Postman, Razorpay, Flipkart QA teams, and Swiggy have all adopted Playwright as their primary E2E framework.
Key Points
- Built by ex-Puppeteer team at Microsoft, released 2020
- True cross-browser: Chromium, Firefox, WebKit out of the box
- Multi-tab, multi-origin, iframe handling unlike Cypress
- Native parallel execution and free trace viewer
- Multiple language bindings: TS/JS, Python, .NET, Java
Q2How do you install Playwright and write your first test?
BasicSetup
Answer
Run `npm init playwright@latest` in your project. The installer scaffolds a `playwright.config.ts` file, installs the browser binaries (Chromium, Firefox, WebKit) into a managed cache directory, creates an `e2e` folder with example tests, and adds the necessary npm scripts. It also asks whether you want TypeScript (recommended), a GitHub Actions workflow, and where your tests should live.
The browser binaries are version-pinned, Playwright never uses your system Chrome, which is what makes tests reproducible across developer machines and CI runners. A test file uses `import { test, expect } from '@playwright/test'` and runs with `npx playwright test`. The default config runs tests in parallel across all installed browsers, you can scope to one with `--project=chromium`.
Run `npx playwright test --ui` to launch the interactive UI mode, which shows a list of tests, a live time-travel debugger that scrubs through every action, and lets you pick locators visually by clicking the page. This is the single best onboarding tool for new team members and the fastest way to write a new test, write the skeleton, then watch and tweak in UI mode until it passes.
import { test, expect } from '@playwright/test';
test('homepage has expected title', async ({ page }) => {
await page.goto('https://goodspace.ai');
await expect(page).toHaveTitle(/GoodSpace/);
});
test('user can sign in', async ({ page }) => {
await page.goto('/login');
await page.getByLabel('Email').fill('test@example.com');
await page.getByLabel('Password').fill('secret');
await page.getByRole('button', { name: 'Sign in' }).click();
await expect(page).toHaveURL('/dashboard');
});
Q3What are locators in Playwright and why are they preferred over CSS/XPath selectors?
BasicLocators
Answer
A locator is a lazy reference to one or more DOM elements on the page. Unlike traditional Selenium-style `findElement` calls, locators are not resolved until you act on them, which means they survive page reloads, re-renders, and DOM mutations that happen between when you create the locator and when you use it. Modern Playwright pushes user-facing locators: `getByRole`, `getByText`, `getByLabel`, `getByPlaceholder`, `getByAltText`, `getByTitle`, and `getByTestId`.
These mimic how a real user (or assistive technology like a screen reader) finds the element, which makes them resilient to refactoring. The official priority order is: getByRole > getByLabel (for form fields) > getByPlaceholder > getByText > getByTitle > getByTestId, with CSS/XPath as a last resort. CSS and XPath selectors are still supported via `page.locator('.btn')` or `page.locator('//button')` but discouraged, they break when designers tweak class names, restructure the DOM, or migrate to a new component library.
The rule of thumb: prefer ARIA roles first because they double as accessibility checks (if `getByRole('button')` fails, your button is probably not accessible), fallback to test IDs (`data-testid` attributes you add specifically for testing) for elements that have no good user-facing identifier like a custom icon button. Locators also chain: `page.getByRole('list').getByRole('listitem').filter({ hasText: 'Active' })` reads naturally and updates lazily.
// ❌ Brittle, breaks if class name changes
await page.locator('.btn-primary.submit-form').click();
// ✅ Resilient, matches the user's mental model
await page.getByRole('button', { name: 'Submit' }).click();
await page.getByLabel('Email address').fill('a@b.c');
await page.getByTestId('checkout-cta').click();
Key Points
- Locators are lazy, resolved on action, not at creation
- Prefer getByRole > getByLabel > getByText > getByTestId
- Avoid CSS/XPath unless no other option exists
Q4How does Playwright's auto-waiting mechanism work?
BasicAuto-waiting
Answer
Before performing an action like `click()`, `fill()`, `check()`, or `selectOption()`, Playwright runs a set of actionability checks on the target element: it must be attached to the DOM, visible (non-zero size, not display:none, not visibility:hidden), stable (not animating, bounding box has not changed for two consecutive animation frames), enabled (not disabled), and able to receive events (not covered by another element at its center). If any check fails, Playwright keeps polling until the default action timeout (30 seconds) is hit. This eliminates almost all `sleep()` or `waitFor()` calls that plague Selenium tests.
Web-first assertions like `expect(locator).toBeVisible()`, `toHaveText()`, `toHaveURL()` work the same way, they retry until the condition is met or the assertion timeout (5 seconds by default) is reached. The result: you write tests as if everything were synchronous, and Playwright handles the timing. The implication is that you should NEVER add `page.waitForTimeout(2000)` calls, they are anti-patterns that slow down tests without helping reliability.
If a test feels like it needs `waitForTimeout`, it actually needs a web-first assertion to wait for the correct UI state. You can tune timeouts per-action (`locator.click({ timeout: 60_000 })`), per-test (`test.setTimeout(60_000)`), or globally in config.
Key Points
- Actionability checks: attached, visible, stable, enabled, receives events
- Default action timeout: 30s; assertion timeout: 5s
- No need for `waitForSelector` or `sleep` in most cases
- Never use `waitForTimeout`, it is always a code smell
Q5What is the difference between `page.locator()` and `page.$()`?
BasicLocators
Answer
`page.$()` is the legacy method, it returns an `ElementHandle` representing a single resolved element at that moment in time. If the DOM changes after the call (a React re-render, a list re-sort, a modal close), the handle becomes stale and your action fails with a `Node is detached from document` error. This is the same problem Selenium has with `StaleElementReferenceException`. `page.locator()` returns a `Locator` object, which is a lazy reference described by a query.
The actual DOM lookup happens each time you act on the locator, so it remains valid across re-renders and the action just retries against the current DOM. Playwright 1.27+ deprecated `$` and `$$` in favor of locators, and the deprecation warnings now appear directly in the test output. In 2026 code, you should never use `page.$()`, Playwright will warn you, the team lead will reject the PR, and the resulting tests will be flaky in ways that are painful to debug. The only legitimate use of `ElementHandle` today is when you need to pass a DOM reference into `page.evaluate()` for low-level scripting, and even then `locator.evaluate()` is usually preferable.
// ❌ Legacy, fails on re-render
const handle = await page.$('#submit');
await handle.click();
// ✅ Modern, retries automatically
const submitBtn = page.locator('#submit');
await submitBtn.click();
Q6How do you write assertions in Playwright tests?
BasicAssertions
Answer
Playwright bundles its own `expect` from `@playwright/test`. It is a superset of Jest's `expect` with a class of web-first matchers that auto-retry: `toBeVisible()`, `toBeHidden()`, `toHaveText()`, `toHaveURL()`, `toHaveValue()`, `toBeEnabled()`, `toBeDisabled()`, `toBeChecked()`, `toBeFocused()`, `toBeEmpty()`, `toHaveCount()`, `toHaveAttribute()`, `toHaveClass()`, `toContainText()`, `toHaveScreenshot()`, `toHaveTitle()`. These retry the underlying query until the assertion passes or the assertion timeout (5 seconds by default) is hit, no manual waiting required.
Non-retrying matchers (`toBe`, `toEqual`, `toContain`, `toMatchObject`, `toBeGreaterThan`) are also available for plain JS values like numbers, strings, and objects. Always prefer the retrying matchers when checking DOM state, otherwise you reintroduce flakiness. A subtle but important rule: always pass the locator to `expect`, not the result of `await locator.textContent()`.
The former retries until the DOM matches, the latter takes a snapshot at one moment and asserts on it. Negating retrying matchers works the same way, `await expect(loader).not.toBeVisible()` polls until the loader disappears or times out. You can also assert on async values with `await expect.poll(() => api.fetchOrders())` which retries the function until it returns a value matching the chained matcher, useful for backend state assertions.
// ❌ Flaky, element may not be in DOM yet
await expect(await page.locator('h1').textContent()).toBe('Welcome');
// ✅ Web-first, retries until the text appears or timeout hits
await expect(page.locator('h1')).toHaveText('Welcome');
// Other useful matchers
await expect(page).toHaveURL(/dashboard/);
await expect(page.getByRole('alert')).toBeVisible();
await expect(page.getByRole('list').getByRole('listitem')).toHaveCount(5);
Q7How do you handle navigation and page loading in Playwright?
BasicNavigation
Answer
`page.goto(url)` navigates the page and by default waits for the `load` event. You can change this with the `waitUntil` option: `'commit'` (HTTP response received, before any HTML is parsed, useful for testing that a request happened at all), `'domcontentloaded'` (DOM parsed, scripts may still be loading, faster for SPAs), `'load'` (default, all sub-resources loaded), or `'networkidle'` (no network activity for 500ms, sounds useful but is unreliable in SPAs that poll, so prefer waiting for specific elements via assertions). For navigation triggered by clicking a link or button, you typically do not need to wait at all, Playwright's auto-waiting on the NEXT action handles it.
If you must explicitly wait, use `page.waitForURL(/pattern/)` or the assertion form `await expect(page).toHaveURL(/pattern/)`. Avoid `page.waitForNavigation()` unless absolutely needed, it has been superseded by `waitForURL`. A common gotcha: if your app uses `History.pushState` for client-side routing (Next.js, React Router), navigation events fire but resources don't reload; `waitUntil: 'load'` returns immediately.
For SPAs, assert on a visible DOM element from the new page instead. Another tip: set a `baseURL` in your config so `page.goto('/dashboard')` works without hardcoding the host across every test.
// Basic navigation
await page.goto('/products');
// Wait until DOM is parsed (faster than 'load')
await page.goto('/heavy-page', { waitUntil: 'domcontentloaded' });
// Wait for a URL pattern after an action
await page.getByRole('link', { name: 'Settings' }).click();
await page.waitForURL(/\/settings/);
Q8What are fixtures in Playwright Test?
BasicFixtures
Answer
Fixtures are Playwright Test's dependency injection system, conceptually similar to pytest fixtures or React hooks. Built-in fixtures include `page` (fresh tab per test), `browser` (the underlying browser instance), `context` (isolated incognito session), `browserName` (the project's browser name like 'chromium'), and `request` (API client). You declare a fixture as a function parameter in your test, and Playwright creates it before the test runs and disposes of it after, you never manually instantiate or clean up.
The killer feature is custom fixtures, you can extend the base `test` to add your own (an authenticated user, a populated database row, a feature flag toggle, a Mailosaur inbox, a Stripe test customer) and they compose cleanly: fixture A can depend on fixture B, Playwright resolves the dependency graph per test. Fixtures have configurable scope: `test` (default, fresh per test, isolation by default) or `worker` (shared across all tests in the worker, useful for expensive one-time setup like spinning up a database container or compiling a binary). Worker-scoped fixtures are how teams cut authentication setup time from 30 seconds per test to 0.1 seconds per test on a 500-test suite. Fixtures replace the messy `beforeEach`/`afterEach` patterns of older frameworks, they are typed, composable, and lazy (only initialized if a test actually requests them as a parameter).
import { test as base, expect } from '@playwright/test';
type Fixtures = { adminUser: { token: string; id: number } };
export const test = base.extend<Fixtures>({
adminUser: async ({ request }, use) => {
const res = await request.post('/api/test/users', { data: { role: 'admin' } });
const user = await res.json();
await use(user);
await request.delete(`/api/test/users/${user.id}`);
},
});
test('admin can delete posts', async ({ page, adminUser }) => {
await page.context().addCookies([{ name: 'token', value: adminUser.token, domain: 'localhost', path: '/' }]);
// ... test body
});
Q9What is a browser context and how does it differ from a page?
BasicBrowser Contexts
Answer
A `BrowserContext` is an isolated browser session, conceptually equivalent to an incognito profile. It has its own cookies, localStorage, sessionStorage, IndexedDB, cache, service workers, and permissions. A `Page` is a single tab within a context.
A single `Browser` instance can have many contexts running simultaneously, and each context can have multiple pages (tabs or popups). This three-level hierarchy (Browser → Context → Page) is why Playwright tests can run in parallel without cross-contamination, every test gets its own context by default, so cookies and localStorage from one test never leak to another. Use this hierarchy deliberately: testing multi-user scenarios (chat between two users, marketplace flows between buyer and seller, social media interactions, collaborative document editing) is just two contexts in the same test.
Each context can be initialized with its own storageState (different authenticated user), viewport (one mobile + one desktop), geolocation, locale, timezone, and permissions. Reusing a single browser across contexts is also much faster than launching a new browser per test, Playwright does this automatically across workers.
test('two users chat', async ({ browser }) => {
const aliceCtx = await browser.newContext({ storageState: 'auth-alice.json' });
const bobCtx = await browser.newContext({ storageState: 'auth-bob.json' });
const alice = await aliceCtx.newPage();
const bob = await bobCtx.newPage();
await alice.goto('/chat');
await bob.goto('/chat');
await alice.getByPlaceholder('Type message').fill('hello bob');
await alice.keyboard.press('Enter');
await expect(bob.getByText('hello bob')).toBeVisible();
});
Q10How do you take screenshots and record videos in Playwright?
BasicDebugging
Answer
Screenshots: `await page.screenshot({ path: 'screen.png', fullPage: true })` captures the visible viewport or the full scrollable page (full page captures everything including content below the fold). You can scope to a single element with `await myButton.screenshot({ path: 'btn.png' })` for component-level captures. Options include `clip: { x, y, width, height }` for a specific rectangle, `omitBackground: true` for transparent screenshots of elements, and `animations: 'disabled'` to freeze CSS animations for stable captures.
For visual regression testing, use the web-first assertion `await expect(page).toHaveScreenshot('homepage.png')` which captures, compares against a baseline image stored in `__screenshots__`, and writes a diff PNG on mismatch. The first run creates the baseline; subsequent runs compare pixel-by-pixel with configurable thresholds (`maxDiffPixels`, `maxDiffPixelRatio`, `threshold`). Videos: configure in `playwright.config.ts` with `use: { video: 'retain-on-failure' }`.
Other options: `'on'` (record every test, expensive but useful when debugging a stubborn flaky test), `'off'` (default), `'on-first-retry'` (only the retry run, balances cost and coverage), and `'retain-on-failure'` (recommended for most CI setups, record everything but delete passing runs to save disk space). Videos are saved per test as `.webm` files in `test-results/` and shown inline in the HTML report, they are invaluable for debugging CI failures you cannot reproduce locally. Combined with the trace viewer, these three artifacts (screenshot, video, trace) give you full forensic detail on every failure with zero overhead on passing runs.
// playwright.config.ts
export default defineConfig({
use: {
screenshot: 'only-on-failure',
video: 'retain-on-failure',
trace: 'on-first-retry',
},
});
// Inside a test, visual regression
await expect(page).toHaveScreenshot('dashboard.png', { maxDiffPixels: 100 });
Q11How do you run Playwright tests in headed mode versus headless?
BasicExecution
Answer
By default, Playwright runs headless, no visible browser window, which is significantly faster (no GPU rendering, no compositor) and is required for CI runners that have no display. To watch the browser during development, pass `--headed` to the CLI: `npx playwright test --headed`. You can also slow down operations with `--slow-mo=500` (500 milliseconds delay before each action) so you can follow what's happening with the naked eye, useful for diagnosing 'why did this test fail' before reaching for the trace viewer.
For interactive debugging, `--ui` mode is even better, it launches the Playwright UI alongside the test, showing a tree of tests, a watch mode for re-running on file changes, a time-travel DOM snapshot at each step, network log, console log, and a locator picker that highlights the matching element when you hover over a CSS query. In CI, you always stay headless; locally, mix headed mode (passive observation), UI mode (active development), and `--debug` (step through line by line in the Inspector) depending on whether you're writing, debugging, or just sanity-checking. A nuance: headless Chromium is now Chrome's official 'new headless mode' (post-Chromium 112), which renders identically to headed mode, so there are far fewer 'works in headed, fails in headless' bugs than in 2020.
Q12What is the Playwright config file and what are its most important options?
BasicConfiguration
Answer
`playwright.config.ts` controls every aspect of the test run. Top fields you should know cold: `testDir` (where tests live, e.g. `'./e2e'`), `timeout` (per-test timeout, default 30 seconds, bump higher for long workflows), `expect.timeout` (assertion retry timeout, default 5 seconds), `retries` (auto-retry failing tests, typically 2 in CI and 0 locally so flakes are caught), `workers` (parallel workers count, defaults to half the CPU cores), `fullyParallel` (set true to parallelize tests within files), `forbidOnly` (set true in CI to fail the build if anyone left a `.only` modifier), `reporter` (html, list, json, junit, github, or a path to a custom reporter, you can chain multiple), `use` (default options for every test, `baseURL`, `viewport`, `locale`, `timezoneId`, `video`, `screenshot`, `trace`, `headless`, `actionTimeout`, `navigationTimeout`), and `projects` (multiple browser/viewport/auth configs run as separate test runs with their own naming). The projects array is how you get multi-browser parity: typically chromium, firefox, webkit, plus mobile-chrome and mobile-safari if you ship a responsive app.
The `webServer` option auto-starts your dev server before tests and shuts it down after, invaluable for CI. Use environment variables (`process.env.CI`) to switch behavior between local and CI: more retries in CI, no `--ui` mode, sharding enabled, GitHub Actions reporter.
import { defineConfig, devices } from '@playwright/test';
export default defineConfig({
testDir: './tests',
retries: process.env.CI ? 2 : 0,
workers: process.env.CI ? 4 : undefined,
reporter: [['html'], ['github']],
use: {
baseURL: 'http://localhost:3000',
trace: 'on-first-retry',
screenshot: 'only-on-failure',
},
projects: [
{ name: 'chromium', use: { ...devices['Desktop Chrome'] } },
{ name: 'firefox', use: { ...devices['Desktop Firefox'] } },
{ name: 'webkit', use: { ...devices['Desktop Safari'] } },
{ name: 'mobile-chrome', use: { ...devices['Pixel 7'] } },
],
});
Q13How do you intercept and mock network requests in Playwright?
IntermediateNetwork Interception
Answer
Use `page.route(urlPattern, handler)` to intercept requests. The handler receives a `Route` object you can `fulfill()` (return a custom response, status, headers, body), `continue()` (forward to the real server, optionally modifying URL, method, headers, or postData), or `abort()` (simulate a network failure or DNS error). This is how you test edge cases that are hard to set up server-side: empty states (return `[]`), error states (return 500), slow networks (delay before fulfilling), paginated responses, rate-limited responses, malformed JSON, and stale data.
For broader scope use `context.route()` (applies to all pages in the context) or `browserContext.route()` (applies on context creation, useful for setup). For complex realistic mocks use `page.routeFromHAR()` to replay a recorded HAR file from a real user session, record once via DevTools or `page.routeFromHAR({ update: true })`, then replay deterministically in tests. Routes match by URL glob (`'**/api/users/**'`), regex (`/\/api\/users\/\d+/`), or function predicate (`url => url.includes('graphql')`).
The matching is last-in-first-match within the same page/context, which lets you add narrow overrides on top of a broad default. Pair `page.waitForRequest` and `page.waitForResponse` with mocks to assert that specific calls actually happened with the expected payload.
test('shows error when API returns 500', async ({ page }) => {
await page.route('**/api/products', route => {
return route.fulfill({
status: 500,
contentType: 'application/json',
body: JSON.stringify({ error: 'Server down' }),
});
});
await page.goto('/products');
await expect(page.getByRole('alert')).toHaveText(/something went wrong/i);
});
test('abort all images for faster test', async ({ page }) => {
await page.route('**/*.{png,jpg,jpeg,webp}', route => route.abort());
await page.goto('/heavy-page');
});
Key Points
- route.fulfill() returns a fake response
- route.abort() simulates network failures
- route.continue() forwards with optional modifications
- Use routeFromHAR() to replay recorded traffic
Q14How do you implement the Page Object Model (POM) in Playwright?
IntermediatePatterns
Answer
Page Object Model is a pattern where each page (or significant component or section) becomes a class encapsulating its locators and the actions a user takes on it. Tests then read like English: `await loginPage.signIn('user', 'pass')` instead of three lines of locator soup. In Playwright, the constructor takes the `Page` instance and stores locators as readonly properties using the user-facing getters (`getByRole`, `getByLabel`, etc.).
Methods perform user actions like `goto()`, `signIn()`, `expectErrorMessage()`, and may return another Page Object (chainable POM, e.g. `LoginPage.signIn()` returns a `DashboardPage`). The pattern shines on large suites, when a designer renames a button, you change one locator in one class, not 50 test files. Common variations: (1) **Component objects**, extract repeated UI like a navbar, modal, dropdown, or product card into its own class that takes a `Locator` instead of a `Page`, scoping its queries within the component root. (2) **Action helpers**, for cross-page workflows (sign up, complete onboarding, place an order), a top-level helper takes the page and orchestrates multiple Page Objects. (3) **Fixture-driven POM**, in modern Playwright (2024+), many teams skip the explicit `new LoginPage(page)` boilerplate and define each Page Object as a fixture, so tests just declare `async ({ loginPage }) =>` as a parameter.
This composes naturally with auth fixtures and test data fixtures. Avoid the trap of putting assertions inside Page Objects, assertions belong in tests, Page Objects only do navigation and actions. The exception is generic readiness checks like `async waitUntilLoaded()` that internally use assertions to wait for the page to settle, these belong in the Page Object as preconditions for the actions that follow.
// pages/LoginPage.ts
import type { Page, Locator } from '@playwright/test';
export class LoginPage {
readonly emailInput: Locator;
readonly passwordInput: Locator;
readonly submitBtn: Locator;
constructor(public readonly page: Page) {
this.emailInput = page.getByLabel('Email');
this.passwordInput = page.getByLabel('Password');
this.submitBtn = page.getByRole('button', { name: 'Sign in' });
}
async goto() {
await this.page.goto('/login');
}
async signIn(email: string, password: string) {
await this.emailInput.fill(email);
await this.passwordInput.fill(password);
await this.submitBtn.click();
}
}
// In test
const loginPage = new LoginPage(page);
await loginPage.goto();
await loginPage.signIn('user@x.com', 'pass');
Q15How do you handle authentication across many tests efficiently?
IntermediateAuthentication
Answer
The naive approach, log in via the UI in every test, is slow and brittle. With 500 tests and a 6-second login flow, you waste 50 minutes per run just authenticating. The Playwright-idiomatic pattern is `storageState`: log in once, save cookies + localStorage + sessionStorage + IndexedDB to a JSON file, then have every test load it as the starting state.
Two implementations: (1) **Global setup project**, define a `setup` project in config with `testMatch: /auth\.setup\.ts/` that runs first, logs in via UI or (better) API, and writes `playwright/.auth/user.json`; other projects depend on it via `dependencies: ['setup']` and use `storageState: 'playwright/.auth/user.json'` in their `use` block. (2) **Worker-scoped fixture**, for tests needing different user roles or tenants, define a fixture that authenticates once per worker, returns the storageState path, and the test loads it. The savings are massive: a 500-test suite that took 30 minutes can drop to 4 minutes once you stop re-logging-in 500 times. Bonus optimizations: authenticate via API directly (skip the UI entirely, fastest), store auth files under `.auth/` and gitignore them (never commit tokens), and refresh the auth file in your nightly run so it doesn't expire mid-day. For multi-role apps, generate auth files for each role (admin, member, viewer, billing) and reference them by name in tests via `test.use({ storageState: 'playwright/.auth/admin.json' })`.
// auth.setup.ts
import { test as setup } from '@playwright/test';
setup('authenticate', async ({ page }) => {
await page.goto('/login');
await page.getByLabel('Email').fill(process.env.E2E_EMAIL!);
await page.getByLabel('Password').fill(process.env.E2E_PASSWORD!);
await page.getByRole('button', { name: 'Sign in' }).click();
await page.waitForURL('/dashboard');
await page.context().storageState({ path: 'auth.json' });
});
// playwright.config.ts
projects: [
{ name: 'setup', testMatch: /auth\.setup\.ts/ },
{
name: 'chromium',
use: { ...devices['Desktop Chrome'], storageState: 'auth.json' },
dependencies: ['setup'],
},
],
Key Points
- Log in once via setup project, save storageState to JSON
- All subsequent tests start authenticated for free
- Use API-based login when possible, even faster than UI
- Use worker-scoped fixtures for multi-role scenarios
Q16What is the trace viewer and how do you use it to debug flaky tests?
IntermediateDebugging
Answer
The Playwright trace viewer is the single biggest debugging advantage over Selenium/Cypress and one of the most-cited reasons teams migrate. Enable with `use: { trace: 'on-first-retry' }` in config, when a test fails and retries, Playwright records every action, every network call, console messages, source code that ran, and a DOM snapshot before AND after each step. The trace is saved as a `.zip` file in `test-results/`.
Open with `npx playwright show-trace trace.zip`, or click the trace link in the HTML report. The UI shows: (1) a timeline scrubber with bars representing each action's duration, (2) a left panel listing every action chronologically, (3) the main canvas showing the DOM snapshot at the selected timepoint (it is a real interactive DOM, you can right-click, inspect, even open DevTools), (4) the locator that was queried, highlighted in the DOM, (5) network log with full request/response inspection, (6) console log with `console.log/warn/error` output, (7) the source code that ran, with the failing line highlighted. For a flaky test, this turns 'it failed in CI but works locally' into a 5-minute investigation: scrub to the failed action, see the DOM state, see what locator matched (or didn't), see the network request that didn't fire, almost every race condition becomes obvious. In India, every senior SDET interview now expects you to demonstrate trace viewer fluency, and many companies ask candidates to walk through debugging a real failed trace in the interview.
Q17How do you run Playwright tests in parallel and what is sharding?
IntermediateParallelism
Answer
By default, Playwright runs test FILES in parallel across workers, each worker is its own Node.js process with its own browser instance, fully isolated from other workers. Tests within a single file run sequentially in one worker, sharing browser state between them (this is why fixtures are scoped to test by default, isolation requires explicit teardown). To parallelize within a file, opt in with `test.describe.configure({ mode: 'parallel' })`.
The `workers` config option (or `--workers=N` CLI flag) controls the count, typically half of available CPU cores locally to leave room for the editor and browser, 4-8 in CI depending on the runner size. Sharding splits the test suite across multiple CI machines: `npx playwright test --shard=1/4` runs the first quarter, `--shard=2/4` the second, and so on. Playwright hashes test file paths to distribute them evenly, the same shard number always picks the same tests, which is essential for retry semantics and reproducibility.
Combined with GitHub Actions matrix strategy, a 1000-test suite that takes 20 minutes on one machine drops to 5 minutes across four. Each shard produces a blob report; merge them afterward with `npx playwright merge-reports --reporter html ./all-blob-reports`. The combined report looks like a single run with all results in one place.
Pro tip: combine sharding with the `--grep` flag for tag-based filtering (`--grep @smoke`) to run only critical paths on PRs and the full suite on main branch merges. For very large suites, dynamic sharding (using historical test duration data to balance shards by time, not test count) further reduces total CI time.
# .github/workflows/e2e.yml
jobs:
test:
strategy:
matrix:
shard: [1/4, 2/4, 3/4, 4/4]
steps:
- uses: actions/checkout@v4
- run: npm ci
- run: npx playwright install --with-deps
- run: npx playwright test --shard=${{ matrix.shard }}
- uses: actions/upload-artifact@v4
with:
name: blob-report-${{ matrix.shard }}
path: blob-report
merge-reports:
needs: test
if: ${{ !cancelled() }} # the ${{ }} wrapper is required: bare ! is a YAML tag indicator
steps:
- run: npx playwright merge-reports --reporter html ./all-blob-reports
Q18What is the difference between `test.describe.serial`, `test.describe.parallel`, and the default mode?
IntermediateParallelism
Answer
Default: test FILES run in parallel across workers, tests INSIDE a file run sequentially in one worker, this preserves state between tests within a file (shared `page`, shared cookies) which can be convenient but also a source of bugs. `test.describe.configure({ mode: 'parallel' })` makes the tests inside that describe block run in parallel as well, each getting its own worker context, useful when tests are truly independent and you want maximum throughput. `test.describe.configure({ mode: 'serial' })` is the opposite, tests run one after the other in declared order in a single worker, and if one fails, all subsequent tests in the block are skipped (rather than running anyway and producing noise). Serial is occasionally needed for flows that absolutely cannot be made independent, a multi-step wizard test split across multiple `test()` blocks, or tests that build up state intentionally (create → read → update → delete the same resource). Most teams treat needing serial mode as a code smell, your tests should be independent so they can run in any order and in parallel, which catches bugs that the implicit ordering hides.
A common alternative to serial mode is to combine all the steps into a single `test()` with sub-steps via `await test.step('create user', async () => {...})`, you get the same readable structure without losing isolation. Setting `fullyParallel: true` in config makes everything parallel by default, which most modern Playwright projects do.
test.describe.configure({ mode: 'serial' });
let sharedPage: Page;
test.beforeAll(async ({ browser }) => {
sharedPage = await browser.newPage();
await sharedPage.goto('/wizard/step-1');
});
test('completes step 1', async () => {
await sharedPage.getByRole('button', { name: 'Next' }).click();
await expect(sharedPage).toHaveURL(/step-2/);
});
test('completes step 2', async () => {
// depends on step 1 having run, fails fast if step 1 failed
await sharedPage.getByRole('button', { name: 'Submit' }).click();
await expect(sharedPage.getByText('Success')).toBeVisible();
});
Q19How does Playwright handle iframes and how do you interact with them?
IntermediateFrames
Answer
Use `page.frameLocator(selector)` to get a `FrameLocator` for an iframe, then chain locators inside it: `page.frameLocator('iframe[name=payment]').getByLabel('Card number').fill('4242...')`. FrameLocator behaves like a normal locator, lazy (resolved on action), auto-waiting, and supports `getByRole`, `getByText`, `getByLabel`, etc. The iframe element itself can be located by any normal selector, CSS, role, name attribute. For nested iframes (an iframe inside an iframe, common with ad networks and embedded checkouts), chain frameLocators: `page.frameLocator('iframe[name=outer]').frameLocator('iframe[name=inner]').getByText('hello')`.
This is the standard pattern for payment integrations (Stripe, Razorpay, PayU), reCAPTCHA v2, hCaptcha, embedded YouTube/Vimeo videos, third-party chat widgets (Intercom, Zendesk), and embedded Google Maps. Old-style `page.frame({ name })` still works but FrameLocator is preferred in modern code because it composes cleanly with auto-waiting. Common gotcha: if the iframe content lives on a different origin, you cannot inspect its DOM via `page.evaluate` due to browser same-origin policy, but Playwright's protocol-level access via frameLocator still works normally for clicks and fills. For testing Stripe Elements specifically, Stripe ships data-testid attributes you can target inside the frame, so the test reads cleanly.
test('Razorpay checkout', async ({ page }) => {
await page.goto('/checkout');
await page.getByRole('button', { name: 'Pay ₹999' }).click();
// Razorpay opens its iframe
const rzpFrame = page.frameLocator('iframe.razorpay-checkout-frame');
await rzpFrame.getByPlaceholder('Card number').fill('4111 1111 1111 1111');
await rzpFrame.getByPlaceholder('MM / YY').fill('12 / 28');
await rzpFrame.getByPlaceholder('CVV').fill('123');
await rzpFrame.getByRole('button', { name: /Pay/ }).click();
await expect(page.getByText('Payment successful')).toBeVisible();
});
Q20How do you use the Playwright API request fixture for API testing without a browser?
IntermediateAPI Testing
Answer
The `request` fixture gives you a full HTTP client decoupled from any browser, useful for API-only tests, test data setup, asserting backend state during E2E flows, and warming caches before UI tests run. It shares cookies and storage state with the browser context when used via `page.request` or `context.request`, or runs completely standalone via the top-level `request` fixture (which has no browser overhead, much faster, ideal for pure API tests). Methods mirror the standard HTTP verbs: `get`, `post`, `put`, `patch`, `delete`, `fetch`, `head`.
Each returns an `APIResponse` with `.status()`, `.ok()`, `.json()`, `.text()`, `.body()`, `.headers()`, and `.url()`. This is how teams set up test data in 1 second instead of 10 seconds of UI clicks, log in via the API, seed the database via the API, then drive the UI only for the specific journey under test. Many companies write 80% of their automation as API tests and reserve Playwright browser tests for true user journeys that exercise rendering and interactivity.
A useful pattern is the hybrid test: use the API to seed a 'cart with 3 items', then drive the UI to verify checkout works correctly, fast and reliable. The `request` fixture also respects `baseURL`, default headers, and `extraHTTPHeaders` from config, so authentication tokens propagate automatically. You can also configure HTTPS bypassing (`ignoreHTTPSErrors`) and custom certificates for testing internal APIs, and use the `request` API with `routeFromHAR` to mock backend calls during component tests.
import { test, expect } from '@playwright/test';
test('API: creates a product and returns 201', async ({ request }) => {
const res = await request.post('/api/products', {
data: { name: 'Test Product', price: 999 },
headers: { Authorization: `Bearer ${process.env.API_TOKEN}` },
});
expect(res.status()).toBe(201);
const body = await res.json();
expect(body.id).toBeDefined();
});
test('E2E: assert backend state after UI action', async ({ page, request }) => {
await page.goto('/products/new');
await page.getByLabel('Name').fill('UI Created');
await page.getByRole('button', { name: 'Save' }).click();
await expect(page).toHaveURL(/\/products\/\d+/);
// Verify in the DB via API
const res = await request.get('/api/products?name=UI Created');
expect((await res.json()).length).toBe(1);
});
Q21How do you handle file uploads and downloads in Playwright?
IntermediateFiles
Answer
**Upload**: use `locator.setInputFiles(path)` on a `<input type=file>` element. Pass a string path for single-file upload, an array of paths for multi-upload, or an in-memory object `{ name, mimeType, buffer }` for buffer-based uploads (useful when you generate file content dynamically in tests). It works without ever opening the native OS file dialog, Playwright sets the file directly on the input element via protocol.
For modern drag-and-drop uploaders that hide the file input behind a styled button, find the actual `<input type=file>` (often `display:none` but still in the DOM) with `page.locator('input[type="file"]')` and call `setInputFiles` on it. **Download**: wrap the action that triggers a download in `page.waitForEvent('download')` and use the returned `Download` object: `await download.saveAs('/tmp/file.pdf')` or `await download.path()` to get the temporary download location. By default, downloads go to a temp directory under `test-results/` and are cleaned up after the test, set `downloadsPath` in your project config to keep them in a permanent location. You can also intercept downloads to read the content as a stream without saving to disk: `(await download.createReadStream()).pipe(...)`. This pattern works identically across Chromium, Firefox, and WebKit, which Selenium notoriously struggled with due to OS-level browser download dialogs.
test('upload resume', async ({ page }) => {
await page.goto('/profile');
await page.getByLabel('Upload resume').setInputFiles('./fixtures/sample.pdf');
await expect(page.getByText('sample.pdf')).toBeVisible();
});
test('download invoice', async ({ page }) => {
await page.goto('/orders/123');
const downloadPromise = page.waitForEvent('download');
await page.getByRole('link', { name: 'Download invoice' }).click();
const download = await downloadPromise;
expect(download.suggestedFilename()).toMatch(/invoice.*\.pdf$/);
await download.saveAs('./downloads/invoice.pdf');
});
Q22What are some common causes of flaky Playwright tests and how do you fix them?
IntermediateFlakiness
Answer
Flakiness is the #1 topic in SDET interviews and the #1 reason teams lose trust in their test suite. Common causes and fixes: (1) **Hardcoded waits**, `page.waitForTimeout(2000)` is the #1 sin. It either slows down every run or fails when the real wait is 3 seconds.
Replace with web-first assertions that retry. (2) **Race conditions in network mocks**, you set up `page.route()` AFTER the navigation that triggers the request, missing the first call. Always register routes BEFORE the navigation. (3) **Element re-renders**, if a React component remounts on state change, an `ElementHandle` becomes stale. Use locators, never handles. (4) **Animations**, Playwright waits for `transitionend` but some libraries (especially custom CSS animations or React Spring) don't fire it reliably.
Disable animations via CSS injection in test mode: `* { animation: none !important; transition: none !important; }`. (5) **Shared state between tests**, tests writing to the same DB row in parallel. Use unique data per test (worker-id prefixes, timestamps, UUIDs) or per-worker isolated test data. (6) **Auth expiring mid-suite**, long-running CI suites where the saved JWT expires. Re-run auth setup periodically or use longer-lived test tokens. (7) **Date/time-dependent UI**, 'tomorrow's date' assertions fail when the test runs at 23:59.
Freeze time with `await page.clock.install({ time: new Date('2026-05-12') })` (Playwright 1.45+). (8) **Real-time/websocket dependence**, websocket events arriving at unpredictable times. Mock the websocket via `page.route` or assert on the eventual UI state, not on event order. (9) **Third-party scripts**, Google Tag Manager, Intercom, Sentry causing intermittent slowdowns. Block them via route in test mode: `await page.route('**/{gtm,intercom,sentry}/**', r => r.abort())`. The trace viewer is your best friend for diagnosing the actual cause.
Key Points
- Remove all `waitForTimeout` calls, use retrying assertions
- Setup network mocks BEFORE navigation triggers them
- Use locators, never ElementHandles
- Disable animations in test mode
- Use unique test data, never share rows across tests
- Use page.clock.install for date-dependent UI
Q23How do you do visual regression testing with Playwright?
IntermediateVisual Testing
Answer
Built-in via `expect(page).toHaveScreenshot('name.png')` or `expect(locator).toHaveScreenshot('name.png')`. On the first run, Playwright creates a baseline image in `__screenshots__` (or your configured `snapshotDir`). On subsequent runs, it compares the live screenshot against the baseline pixel-by-pixel and fails if the difference exceeds the configured threshold.
Three thresholds control sensitivity: `threshold` (color difference per pixel, 0-1, default 0.2), `maxDiffPixels` (absolute count of differing pixels allowed), `maxDiffPixelRatio` (fraction of pixels allowed to differ). The HTML report shows the baseline, actual, and diff images side by side, with the diff colored to highlight changes. Update baselines with `npx playwright test --update-snapshots` after intentional UI changes, review carefully before committing.
Best practices: take screenshots of components, not full pages (smaller surface area = fewer false positives); mask dynamic regions (`mask: [page.locator('.timestamp'), page.locator('[data-test=user-count]')]`); commit baselines to git so they version with the code; run on a single OS in CI (font rendering, antialiasing, and subpixel positioning differ between Linux/macOS/Windows, typical CI uses Ubuntu in Docker for stable, reproducible results); name baselines per-project so different browsers can have different baselines. For larger-scale visual regression with a UI for reviewing diffs, integrate Percy, Chromatic, or Argos CI, they handle batch approval, branch comparison, and PR commenting.
test('homepage visual snapshot', async ({ page }) => {
await page.goto('/');
await expect(page).toHaveScreenshot('homepage.png', {
fullPage: true,
mask: [page.locator('[data-testid=user-count]'), page.locator('time')],
maxDiffPixels: 100,
});
});
Q24How do you handle mobile and responsive testing with Playwright?
IntermediateMobile
Answer
Playwright ships with hundreds of `devices` presets that bundle viewport dimensions, user agent string, device pixel ratio, mobile flag, and touch support: `devices['Pixel 7']`, `devices['iPhone 15 Pro']`, `devices['iPad Pro 11']`, `devices['Galaxy S24']`, and many more. Add them as projects in your config to run the same test suite on mobile and desktop in parallel. Mobile emulation enables touch events, `page.tap()` works instead of `click()`, swipe gestures are done via `page.touchscreen` (`page.touchscreen.tap(x, y)`), and `hover` triggers a `touchstart`/`touchend` sequence instead of a mouseover.
The `isMobile` fixture lets you write conditional logic: `test.skip(!isMobile, 'mobile-only test')`. Note: this is browser emulation, not real-device testing, the layout engine is still Chromium/WebKit running on your test machine, no native iOS/Android runtime, no actual hardware sensors, no battery/network throttling simulation. The user agent and viewport are spoofed but JavaScript engine performance characteristics differ from real devices.
For real device testing in India, teams typically pair Playwright (covers 90% of mobile responsive bugs at near-zero cost) with BrowserStack or Sauce Labs Real Device Cloud for the remaining 10% of native-specific issues like iOS keyboard behavior or Android WebView quirks. WebKit emulation in Playwright is particularly valuable because it catches Safari-specific bugs without owning a Mac, a long-standing Selenium pain point.
// playwright.config.ts
projects: [
{ name: 'desktop', use: { ...devices['Desktop Chrome'] } },
{ name: 'mobile-android', use: { ...devices['Pixel 7'] } },
{ name: 'mobile-ios', use: { ...devices['iPhone 15'] } },
],
// Test (runs once per project)
test('mobile menu opens on hamburger tap', async ({ page, isMobile }) => {
test.skip(!isMobile, 'desktop has horizontal nav');
await page.goto('/');
await page.getByRole('button', { name: 'Open menu' }).tap();
await expect(page.getByRole('navigation')).toBeVisible();
});
Q25How do you organize a large Playwright test suite at a company like Razorpay or Flipkart?
IntermediateArchitecture
Answer
Real-world structure used by Indian QA teams: `tests/` split by business domain (auth, checkout, search, dashboard, settings, notifications), not by page or test type. Each domain folder may have sub-folders for individual flows. `pages/` contains Page Objects, organized mirror-style to match `tests/`. `fixtures/` contains custom test fixtures (authenticated users, seeded data, feature flag toggles). `utils/` for helpers (date manipulation, test data factories using `@faker-js/faker`, currency parsing for INR). `api/` for API request wrappers and TypeScript types generated from the OpenAPI spec. Auth state per role lives in `.auth/` (git-ignored, generated by setup project). `playwright.config.ts` defines projects per browser × per environment (dev, staging), production smoke tests are a separate config file that runs against `https://app.example.com` with extra safety guards.
CI runs three suites with different cadences: PR smoke (5 minutes, critical paths only, runs on every PR), main-branch regression (30 minutes, full suite, sharded across 8 runners, runs on merge to main), nightly extended (cross-browser + mobile + visual regression, 60-90 minutes). Tag tests with `test.describe('@critical', ...)` and `test.describe('@smoke', ...)` so the CI grep filter can pull `--grep @critical` for fast feedback. Reports aggregate to a single HTML via `blob-merge`.
Razorpay-scale teams add Allure for trend dashboards, Currents.dev or Argos for visual review, and pipe failures to Slack via custom reporters. Most teams also maintain a 'flaky tests quarantine' tag, tests under investigation that don't block CI but still get traces uploaded for analysis.
Key Points
- Organize by business domain, not by page or test type
- Separate dirs: tests, pages, fixtures, utils, api, .auth
- Tag with @critical, @smoke, @regression for grep-based selection
- Three CI tiers: PR smoke / main regression / nightly extended
- Use blob reports + merge-reports for sharded CI
Q26How do you write custom matchers and extend the expect API in Playwright?
AdvancedCustom Matchers
Answer
Use `expect.extend()` to add domain-specific assertions. A custom matcher is a function that receives the actual value (locator, page, or whatever you assert on) plus expected args and returns `{ pass: boolean, message: () => string, name: string }`. For retrying matchers, the Playwright superpower, wrap the check in `expect.poll()` which repeatedly evaluates the function until it returns truthy or the timeout is hit, or use Playwright's internal `expect(locator).toPass()` helper which lets you build retry semantics by throwing on failure.
Common custom matchers in Indian fintech teams: `toBeWithinAmountTolerance` for rupee comparisons with floating-point rounding tolerance, `toShowToast` for design-system toast assertions that abstract away the toast root element, `toHaveAccessibleViolations` integrating axe-core for WCAG compliance checks, `toHaveSentEvent` to assert analytics events fired correctly through Segment/Mixpanel, `toBeWithinViewport` for scroll-position assertions, `toHaveTrackingHeaders` for verifying tracing context propagation. Custom matchers are typed via TypeScript generics on `expect.extend` so they get full autocomplete and signature checking in tests. Keep matchers in a `matchers.ts` file referenced from config via `globalSetup`. The win: tests read like specifications instead of like Playwright code, `await expect(page).toShowToast('Order placed')` is more readable than 4 lines of locator + assertion plumbing, and the abstraction makes design-system changes a one-place fix.
// matchers.ts
import { expect as baseExpect } from '@playwright/test';
import type { Locator } from '@playwright/test';
export const expect = baseExpect.extend({
async toShowToast(locator: Locator, expectedText: string | RegExp) {
const toast = locator.page().getByRole('status').last();
let pass = false;
let actual = '';
try {
await baseExpect(toast).toBeVisible({ timeout: 5000 });
actual = (await toast.textContent()) ?? '';
pass = typeof expectedText === 'string' ? actual.includes(expectedText) : expectedText.test(actual);
} catch (e) {}
return {
pass,
name: 'toShowToast',
message: () => pass
? `expected NOT to show toast "${expectedText}"`
: `expected toast "${expectedText}", got "${actual}"`,
};
},
});
// In a test
await expect(page).toShowToast('Payment successful');
Q27How do you do component testing with Playwright instead of just E2E?
AdvancedComponent Testing
Answer
Playwright Component Testing (still labeled experimental but production-used by many teams in 2026, including design-system teams at Razorpay and Postman) lets you render individual React/Vue/Svelte components in a real browser without a full app shell. Install with `npm init playwright@latest -- --ct`, it scaffolds a `playwright-ct.config.ts`, a Vite-based renderer (for React/Vue) or webpack (Svelte), and `playwright/index.html`/`index.ts` boot files where you import global CSS or configure providers. Tests look like normal Playwright tests but use a `mount` fixture instead of `page.goto`: `const component = await mount(<Button onClick={...}>Click</Button>)`.
Returned `component` is a `Locator` you can act on with all standard Playwright APIs. Benefits over Jest + jsdom: real browser rendering means CSS, layout (Flexbox, Grid, container queries), animations, IntersectionObserver, ResizeObserver, and event dispatch all behave exactly like production, no jsdom approximations or polyfills. You also get the trace viewer for free.
Trade-offs: slower than jsdom (real browser overhead, ~200ms per mount vs ~5ms for jsdom), so reserve for components where browser fidelity matters (drag-and-drop interactions, IntersectionObserver-based lazy loading, complex CSS with custom properties, accessibility behavior with screen readers via axe-core integration). Most teams use it for design-system libraries and visual regression on components, and keep pure-logic unit tests in Vitest/Jest where speed matters more than browser fidelity. The output integrates with the same HTML reporter and trace viewer as E2E tests, so you have one debugging story.
// Button.spec.tsx
import { test, expect } from '@playwright/experimental-ct-react';
import { Button } from './Button';
test('fires onClick when clicked', async ({ mount }) => {
let clicked = false;
const component = await mount(
<Button onClick={() => { clicked = true; }}>Submit</Button>
);
await component.click();
expect(clicked).toBe(true);
});
test('respects disabled prop', async ({ mount }) => {
const component = await mount(<Button disabled>Submit</Button>);
await expect(component).toBeDisabled();
});
Q28How would you architect a Playwright test suite for a 10,000+ test microservices platform?
AdvancedArchitecture
Answer
At 10k+ tests across many services and teams, the real problems are not Playwright APIs, they are governance, speed, and signal-to-noise. The architecture used by mature teams: (1) **Service-owned suites**, each microservice team owns its own Playwright repo and CI, with shared fixtures and Page Objects published as private npm packages (e.g. `@company/playwright-utils`, `@company/design-system-page-objects`). This gives teams autonomy while preventing copy-paste duplication. (2) **Cross-cutting suites**, a small platform-team-owned suite for true cross-service journeys (login → checkout → notification → email arrival).
Run after individual service deploys. (3) **Massive sharding**, 16-32 shards per suite in CI, runtime under 10 minutes per shard so PR feedback stays fast. (4) **Test impact analysis**, only run tests affected by the PR's diff, via tools like `playwright-test-impact` or custom mapping from changed files to affected tests. A 30-minute full suite drops to 4 minutes on a small PR. (5) **Flaky test isolation**, a separate workflow that retries individual flaky tests in isolation; quarantined tests are tagged with `@flaky` and skipped in the main suite via `--grep-invert @flaky`, but still run nightly to track their state. (6) **Trace storage + analytics**, every failed trace uploaded to S3, indexed by test name + git SHA, with dashboards in Looker or Grafana on flake rate per file, slowest tests, browser-specific failures. (7) **Visual review**, Percy, Chromatic, or Argos CI for PR-level visual diff approval workflows with branch comparison. (8) **Tenant-aware data isolation**, every test creates data with a unique prefix (`test-{worker-id}-{timestamp}`), with a nightly cleanup job. The hardest part is not Playwright at this scale, it is keeping the suite trustworthy.
Flake rate must stay under 0.5% or developers start ignoring failures, which is fatal. Invest in observability, dashboards, and a culture where flaky tests are an on-call rotation, not 'just retry it'.
Q29How do you write a Playwright reporter or plugin for custom CI integration?
AdvancedPlugins
Answer
Custom reporters implement the `Reporter` interface from `@playwright/test/reporter`. Override hooks: `onBegin` (called once with the suite tree, useful for setup), `onTestBegin` (each test starting, get title and project), `onTestEnd` (each test finishing with status, duration, errors, attachments, the most important hook), `onError` (suite-level errors, e.g. config issues), `onEnd` (called once when all tests finish, perfect for sending summaries). You can also override `printsToStdio` to silence terminal output for non-interactive reporters.
Common integrations Indian teams build: (1) Slack/Discord/Teams webhook reporter that posts a summary to a `#qa-failures` channel with links to traces, color-coded by severity. (2) JIRA/Linear reporter that auto-creates a bug ticket on a new failure (with dedup so a flake doesn't spam JIRA, query by test name + error signature). (3) Internal analytics reporter that pushes metrics to Datadog/SigNoz/CloudWatch, flake rate per test, slowest tests, browser-specific failures, retry rate trends. At GoodSpace and similar Indian companies, SigNoz is increasingly the destination of choice for self-hosted observability. (4) Test result database, write each run to PostgreSQL for historical trend analysis with a custom dashboard. (5) PagerDuty integration that wakes someone up only for production smoke-test failures. Reporters can be combined: `reporter: [['list'], ['html'], ['./my-slack-reporter.ts']]`. For deeper integration, `globalSetup`/`globalTeardown` let you run code once before/after the entire test run, useful for spinning up Docker containers, seeding databases, warming CDN caches, or recording start/end markers for distributed tracing.
// slack-reporter.ts
import type { Reporter, TestCase, TestResult } from '@playwright/test/reporter';
class SlackReporter implements Reporter {
private failures: { test: string; error: string }[] = [];
onTestEnd(test: TestCase, result: TestResult) {
if (result.status === 'failed') {
this.failures.push({
test: test.titlePath().join(' > '),
error: result.error?.message ?? 'unknown',
});
}
}
async onEnd() {
if (!this.failures.length) return;
const text = `*${this.failures.length} test(s) failed:*\n` +
this.failures.map(f => `• ${f.test}`).join('\n');
await fetch(process.env.SLACK_WEBHOOK!, {
method: 'POST',
body: JSON.stringify({ text }),
});
}
}
export default SlackReporter;
Q30How do you handle authentication in a multi-tenant SaaS platform with Playwright?
AdvancedMulti-tenancy
Answer
Multi-tenant SaaS, Postman, Razorpay Dashboard, Freshworks, Zoho, has tests across N tenants × M user roles. The naive approach (log in via UI for each combination) takes hours. The mature pattern: (1) **Tenant + role storage state matrix**, a `setup` project iterates over all `[tenant, role]` pairs at the start of the test run, authenticates via API directly (not UI), and writes `playwright/.auth/{tenant}-{role}.json`.
Generating 4 tenants × 3 roles = 12 auth files takes ~5 seconds via API vs 5+ minutes via UI. (2) **Parameterized fixture**, extend `test` with `tenant` and `role` parameters using `test.use({ tenant: 'acme', role: 'admin' })`; the fixture loads the matching storage state automatically. Tests just declare the user they need and Playwright wires up the rest. (3) **Tenant isolation in test data**, every test creates its tenant's data with a unique prefix (e.g., `test-{worker-id}-{timestamp}-{randomSuffix}`) so parallel tenants don't collide, and cleanup is straightforward. (4) **Cleanup via API**, `globalTeardown` deletes test tenants and their data after the run, never via UI clicks. (5) **Org-switching**, for tests that switch between tenants in a single flow, use multiple `BrowserContext` objects (not multiple logins on the same context), much faster and reflects real user behavior of having two tabs open with different orgs. (6) **Cross-tenant security tests**, explicitly assert that tenant A's user cannot access tenant B's data via direct URL navigation or API call. These catch the worst kind of bug (data leak between customers). The final piece: a custom matcher `expect(page).toBeAuthenticatedAs(user)` that asserts the rendered UI matches the expected tenant + role, guarding against silent auth bugs that would otherwise produce false positives (test passes because the wrong user happened to have the same permissions for the action being tested).
// fixtures.ts
import { test as base } from '@playwright/test';
type Role = 'admin' | 'member' | 'viewer';
type Tenant = 'acme' | 'globex';
export const test = base.extend<{ role: Role; tenant: Tenant }>({
role: ['member', { option: true }],
tenant: ['acme', { option: true }],
storageState: async ({ role, tenant }, use) => {
await use(`./auth/${tenant}-${role}.json`);
},
});
// Usage
test.use({ tenant: 'globex', role: 'admin' });
test('admin can invite member in globex tenant', async ({ page }) => {
// ... already authenticated as globex admin
});
Frequently Asked Questions
Is Playwright better than Cypress in 2026?
For new projects, yes, Playwright wins on cross-browser support (true Firefox + WebKit, not just Chromium), multi-tab and multi-origin handling, native free parallelism, the trace viewer, and out-of-the-box TypeScript ergonomics. Cypress still has a slightly more polished local UI for casual single-test exploration and a strong plugin ecosystem developed over a longer history, but the gap has closed substantially and Playwright's UI mode (`--ui`) now matches or exceeds it. Most teams (Razorpay, Postman, Microsoft, Vercel, Atlassian, Flipkart, Swiggy) have either migrated or started new projects on Playwright. The Cypress vs Playwright debate is largely settled in 2026, pick Playwright unless you have an existing Cypress suite that works fine and the migration cost outweighs the benefits. Cypress is not going away, and small teams already invested in it can stay productive, but for new SDET hires across India, Playwright proficiency is what employers screen for first.
How much does a Playwright/SDET engineer earn in India?
₹6-22 LPA in 2026 for QA automation engineers and SDETs with Playwright as a primary skill. Entry-level QA automation roles start at ₹6-9 LPA, mid-level SDET roles with 3-5 years of experience at ₹12-16 LPA, senior SDETs and QA leads with 6+ years at ₹18-22+ LPA. Principal/Staff SDETs at top product companies can reach ₹30+ LPA. Companies hiring heavily for Playwright skills include Razorpay, Postman, Flipkart QA teams, Swiggy, Microsoft India, Atlassian Bangalore, Vercel, Zoho, Freshworks, and most YC-backed startups in Bangalore. Roles that combine Playwright with strong TypeScript skills and CI/CD experience consistently pay 30-40% above pure manual QA roles.
Should I learn Playwright with TypeScript or JavaScript?
TypeScript, without question. Playwright's API is designed type-first, IDE autocomplete, parameter validation, fixture types, and Page Object Model refactors all work dramatically better with TypeScript. Custom matchers, fixtures, and reporters are far cleaner with proper generic types. Every Playwright job posting in India in 2026 expects TS familiarity. Even if your dev team writes the application in plain JavaScript, write your tests in TypeScript, Playwright's `tsconfig.json` is self-contained and runs `.ts` files directly without a separate build step.
Can Playwright replace Selenium completely?
For modern web apps, almost always yes. Selenium retains an edge in two scenarios: (1) testing on real mobile devices via Appium, where Playwright's mobile emulation is browser-only and cannot drive a real Android or iOS device, (2) supporting older browsers (Internet Explorer, very old Safari versions, niche embedded browsers), Playwright supports only the three modern engines (Chromium, Firefox, WebKit). For 95% of web E2E needs in 2026, Playwright is faster, less flaky, and easier to maintain than Selenium. Migration patterns vary by team, some big-bang convert, others run both in parallel during transition and gate new test creation to Playwright only. The Selenium IDE record-and-playback tool is still useful for non-developers, but Playwright's Codegen (`npx playwright codegen`) covers similar ground while producing far better code.
How long does it take to learn Playwright if I know JavaScript?
Two to three weeks of dedicated practice to be productive, write your first useful tests in a day, master fixtures and Page Objects in a week, become comfortable with network interception, trace viewer, parallelism, and CI integration in two-to-three weeks. The skill that takes longest to develop is writing non-flaky tests at scale and architecting a suite that stays trustworthy as it grows past a few hundred tests, that comes from 6-12 months of real-world exposure to a large suite, debugging actual production-grade flakes, and learning when to mock vs hit real services. For interview prep, focus on locator strategies, the difference between locators and ElementHandle, network interception patterns, parallelism modes, and being able to walk through a debugging session in the trace viewer. The official Playwright docs are excellent and contain interactive examples, they are the best starting point. Pair them with hands-on practice on a real application (open-source or your own), not just isolated example sites.
Does Playwright work for mobile app testing?
Playwright tests mobile web (responsive sites and mobile browsers via device emulation), not native iOS or Android apps. For native app automation, you still need Appium, Detox (React Native), or platform-specific frameworks like Espresso (Android) / XCUITest (iOS). In India, most companies pair Playwright (for the web product) with Appium or BrowserStack App Live / Sauce Labs Real Devices (for native apps). Playwright's mobile emulation is excellent for catching responsive bugs, mobile-specific UI behavior, touch event handling, and small-viewport regressions, but is not a substitute for testing on a real device for performance characteristics, native gesture interactions, or hardware-dependent features like Camera, Bluetooth, NFC, biometric authentication, and push notifications. If your mobile web traffic is significant (which is true for most Indian consumer products), invest in Playwright mobile projects covering Pixel and iPhone profiles as a minimum.
Introduction
Playwright has overtaken Cypress as the default end-to-end testing framework in 2026. Built by the original Puppeteer team at Microsoft and released as open-source in 2020, it ships with first-class TypeScript support, parallel test execution out of the box, true cross-browser coverage (Chromium, Firefox, WebKit), and a trace viewer that has changed how teams debug flaky tests forever. Modern QA teams at Razorpay, Postman, Flipkart, Swiggy, and most Bangalore-based product startups now treat Playwright as the baseline expectation for SDET candidates, being unable to discuss locator strategies or the trace viewer in 2026 is the equivalent of not knowing async/await in a backend interview.
If you are interviewing for an SDET or QA automation role in India today, expect deep questions on locator strategies (getByRole, getByText, getByTestId, modern locators, not CSS selectors), Playwright's auto-waiting mechanism and why it eliminates the need for hardcoded sleeps, network interception via route/fulfill/abort, Page Object Model patterns combined with custom fixtures, parallel vs serial test execution, CI sharding across multiple GitHub Actions runners, and the inevitable comparison of Playwright vs Cypress vs Selenium. Companies hiring senior SDETs probe component testing with experimental_ct, mobile emulation, custom expect matchers, plugin/reporter authoring, and how you handle authentication state across hundreds of tests without re-logging-in every time.
This guide covers the 30 most-asked Playwright interview questions in 2026, grouped by difficulty. Each answer includes the underlying concept, modern best practices for Playwright 1.49+, real-world gotchas drawn from production Indian QA teams, and a TypeScript code example where it helps clarify the answer. We focus on what teams actually ask in interviews, not theoretical Playwright trivia or features you would only need to know if you were contributing to Playwright itself. By the end of this guide you should be able to walk into any SDET interview at a top Indian product company and confidently discuss architecture decisions, debug a flaky test using the trace viewer, and articulate why Playwright is the right choice for modern web E2E testing.
Ready to practice Playwright interviews?
Don't just read, practice these Playwright questions live with an AI interviewer that asks follow-ups and scores your answers.