
Top 100 Playwright
Interview Questions
Deep-dive answers with TypeScript code snippets, explanations, and real-world examples. Used by 1000s of QA engineers to crack interviews.
Test Your Skills: Playwright Quiz Series!
Ready to validate your knowledge? Take our Interactive Playwright Quizzes: Beginner β Medium β Advanced, earn your expert badges, and get shareable certificates!
Explain your playwright framework structure?
Wanted to learn Playwright framework structure? Perfect for the most popular senior QA interview question: "Explain your test automation framework structure." Click to explore Specs, POM, and Configs interactively!
Why Playwright over Selenium?
Playwright offers significant advantages over Selenium. Key reasons: built-in auto-waiting (no explicit waits needed), native multi-browser support (Chromium, WebKit, Firefox), faster WebSocket-based protocol instead of HTTP, out-of-the-box parallel execution, and rich debugging tools like Trace Viewer.
Deep Dive Explanation
Selenium uses WebDriver HTTP protocol (slow, polling-based). Playwright uses WebSocket (persistent connection, event-driven). The net result: Playwright tests run 2-5x faster and are dramatically less flaky. Playwright also supports modern browser features (Shadow DOM, iframes, multiple tabs, clipboard) out of the box that require workarounds in Selenium.
// Playwright - no waits needed, auto-waits built-in
await page.locator('#submit').click(); // Waits for visible + enabled
// Selenium equivalent - manual waits required
WebDriverWait wait = new WebDriverWait(driver, Duration.ofSeconds(10));
wait.until(ExpectedConditions.elementToBeClickable(By.id("submit"))).click();Difference between Cypress and Playwright?
Both are modern testing tools but differ fundamentally in architecture. Cypress runs JavaScript INSIDE the browser (same thread as the app). Playwright controls the browser from OUTSIDE via WebSocket β making it capable of multi-tab, multi-origin, and iframe testing that Cypress struggles with.
Deep Dive Explanation
Key comparison table: Cypress - JS only, Chromium/Firefox only, same-origin, in-browser, great DX, real-time reload. Playwright - JS/TS/Python/Java/C#, Chromium/Firefox/WebKit, cross-origin, out-of-process, excellent debugging tools, fastest execution.
// Playwright: Multi-origin in ONE test (impossible in Cypress without workarounds)
test('multi-origin flow', async ({ page }) => {
await page.goto('https://app.com/login');
await page.getByRole('button', { name: 'SSO Login' }).click();
// Playwright follows the redirect to another domain seamlessly
await page.waitForURL('https://sso-provider.com/auth');
await page.getByLabel('Username').fill('user@app.com');
await page.getByRole('button', { name: 'Sign In' }).click();
await page.waitForURL('https://app.com/dashboard');
});What is Auto-Waiting in Playwright?
Auto-waiting is Playwright's built-in mechanism that checks 5 actionability conditions before executing any action. It eliminates the #1 cause of flaky tests: timing issues.
Deep Dive Explanation
The 5 actionability checks are evaluated using JavaScript injected into the browser, polling via requestAnimationFrame (tied to the browser's render cycle). This makes it more precise than Selenium's polling mechanism. The default timeout is 30 seconds per action, configurable per-action or globally.
// No sleep() or waitForElement() needed!
await page.getByRole('button', { name: 'Submit' }).click();
// β Playwright automatically waits until the button is:
// β
Attached to the DOM
// β
Visible (not hidden/display:none)
// β
Stable (not animating)
// β
Receives events (not covered by overlay)
// β
Enabled (not disabled attribute)
// Only if ALL 5 pass β Playwright clicksHow do you handle Multiple Tabs?
Each browser tab is a `Page` object within a `BrowserContext`. Playwright uses the `context.waitForEvent('page')` pattern combined with `Promise.all()` to reliably capture new tabs opened by link clicks.
Deep Dive Explanation
The race condition trap: if you start waiting AFTER the click, the 'page' event may have already fired and been missed. Promise.all() prevents this by starting both the event listener and the click simultaneously.
test('handle new tab', async ({ context, page }) => {
await page.goto('https://app.com');
// Promise.all ensures listener is registered BEFORE the click
const [newTab] = await Promise.all([
context.waitForEvent('page'), // Wait for new tab
page.getByRole('link', { name: 'Open Preview' }).click() // Triggers it
]);
await newTab.waitForLoadState('domcontentloaded');
// Now interact with BOTH tabs
await expect(newTab).toHaveTitle('Preview - App');
await expect(page).toHaveURL('https://app.com'); // Original still active
await newTab.close();
});Explain Fixtures in Playwright.
Fixtures are Playwright's dependency injection system for tests. They provide isolated, reusable setup/teardown logic. Unlike `beforeEach`, fixtures are composable, type-safe, lazy (only created when requested), and can be shared across test files.
Deep Dive Explanation
The `use()` callback is the dividing line between setup (before `use()`) and teardown (after `use()`). Teardown always runs, even if the test throws an exception β guaranteed cleanup. This is superior to `afterEach` hooks which can be skipped if `beforeEach` throws.
import { test as base, expect } from '@playwright/test';
// Define a custom 'loggedInPage' fixture
const test = base.extend<{ loggedInPage: Page }>({
loggedInPage: async ({ page }, use) => {
// SETUP: runs before test
await page.goto('/login');
await page.getByLabel('Email').fill('admin@example.com');
await page.getByLabel('Password').fill('secret');
await page.getByRole('button', { name: 'Sign In' }).click();
await page.waitForURL('/dashboard');
await use(page); // β TEST RUNS HERE
// TEARDOWN: runs after test (even if test fails)
await page.goto('/logout');
}
});
test('uses logged in page', async ({ loggedInPage }) => {
await expect(loggedInPage.getByText('Welcome, Admin')).toBeVisible();
});How do you capture Trace Files?
Playwright traces are full recordings of a test run: DOM snapshots, network traffic, console logs, and action timings. Enable them in config and view with the built-in Trace Viewer GUI.
Deep Dive Explanation
Trace options: 'on' (always), 'off' (never), 'retain-on-failure' (only for failed tests), 'on-first-retry' (best for CI - captures trace when a test fails and is being retried). The `sources: true` option includes your TypeScript source files in the trace, making it easy to see which line of code caused each action.
// playwright.config.ts
export default defineConfig({
use: {
// Best practice: capture trace on first retry (avoids storing traces for all passing tests)
trace: 'on-first-retry',
},
});
// View a trace file
// npx playwright show-trace playwright-report/data/some-test-trace.zip
// Programmatic trace control
test('manual trace', async ({ page, context }) => {
await context.tracing.start({ screenshots: true, snapshots: true, sources: true });
await page.goto('/complex-flow');
// ... test steps
await context.tracing.stop({ path: './traces/complex-flow.zip' });
});Parallel Execution in Playwright?
Playwright runs tests in parallel by default using worker processes. Each worker handles one test at a time in its own browser. You control parallelism via `workers` config and `fullyParallel` flag.
Deep Dive Explanation
Workers are isolated OS processes β a crash in one worker doesn't affect others. Playwright reuses browser instances within a worker across tests (for performance) but creates a fresh BrowserContext per test (for isolation). The sweet spot for workers is usually equal to the number of CPU cores.
// playwright.config.ts
export default defineConfig({
fullyParallel: true, // All tests across ALL files run in parallel
workers: process.env.CI ? 4 : '50%', // 4 workers in CI, half CPUs locally
});
// Force parallel within a single file
test.describe.configure({ mode: 'parallel' });
// Force serial (one at a time) within a describe
test.describe.serial('checkout flow', () => {
test('step 1: add to cart', async ({ page }) => { /* ... */ });
test('step 2: checkout', async ({ page }) => { /* ... */ });
});
// CLI: Run with specific worker count
// npx playwright test --workers=8
// Shard across machines (divide suite into N parts)
// npx playwright test --shard=1/4 (machine 1 of 4)API Testing in Playwright?
Playwright includes `APIRequestContext` via the `request` fixture for sending HTTP requests without a browser. It shares cookies with the browser context, making combined UI+API testing seamless.
Deep Dive Explanation
The API + UI hybrid pattern (API for data setup, UI for interaction testing) is the most efficient testing strategy. It's dramatically faster than setting up data via the UI and avoids coupling your setup to UI implementation details that change frequently.
import { test, expect } from '@playwright/test';
// Pure API test (no browser launched)
test('REST API: create and verify user', async ({ request }) => {
// CREATE
const createRes = await request.post('/api/users', {
data: { name: 'Alice', email: 'alice@test.com' },
headers: { 'Content-Type': 'application/json' }
});
expect(createRes.status()).toBe(201);
const { id } = await createRes.json();
// READ
const getRes = await request.get(`/api/users/${id}`);
await expect(getRes).toBeOK();
expect((await getRes.json()).name).toBe('Alice');
// CLEANUP
await request.delete(`/api/users/${id}`);
});
// Use API to set up data for UI test
test('UI test with API setup', async ({ page, request }) => {
const { id } = await (await request.post('/api/products', {
data: { name: 'Test Widget', price: 9.99 }
})).json();
await page.goto(`/products/${id}`);
await expect(page.getByRole('heading')).toHaveText('Test Widget');
});Headless vs Headed Mode?
Headless mode (default) runs the browser without a visible window β faster and required for CI. Headed mode opens a visible browser β essential for debugging and test development.
Deep Dive Explanation
In modern Chromium, there are two headless modes: 'old' headless (a separate browser binary) and 'new' headless (same Chrome binary, just no window). Playwright uses the new headless mode by default for Chromium, which behaves identically to headed Chrome β eliminating headless-specific rendering bugs.
// playwright.config.ts - environment-aware config
export default defineConfig({
use: {
headless: !process.env.HEADED, // headless by default, headed when HEADED=1
},
});
// CLI options:
// npx playwright test β headless (default)
// npx playwright test --headed β headed (shows browser)
// npx playwright test --debug β headed + Inspector + slowMo
// Performance comparison:
// Headless: ~30-60% faster (no rendering overhead)
// Headed: slower but lets you SEE what the test does
// When to use headed:
// - Writing new tests (visual verification)
// - Debugging failures (use page.pause())
// - Recording codegen scriptsHow to fill & clear values in the input text field in Playwright?
`fill()` clears the existing value and sets new text in one atomic operation. `clear()` removes all text. `pressSequentially()` types character-by-character (for autocomplete/keyboard-event-driven inputs).
Deep Dive Explanation
Use `fill()` for 99% of cases β it's atomic and reliable. Use `pressSequentially()` when the input has JavaScript that fires on individual keystrokes (autocomplete, phone number formatting, OTP fields). Never use the deprecated `.type()` method in new tests.
const emailInput = page.getByLabel('Email');
// fill() - fastest, triggers input + change events, clears first
await emailInput.fill('user@example.com');
// clear() - removes all text, leaves field focused
await emailInput.clear();
// pressSequentially() - simulates actual keystrokes (for autocomplete)
await emailInput.pressSequentially('user@example.com', { delay: 50 }); // 50ms per key
// Verify the value
await expect(emailInput).toHaveValue('user@example.com');
// Append to existing text (don't clear first)
await emailInput.fill(''); // Clear manually if needed
await emailInput.press('End'); // Move cursor to end
await emailInput.type(' extra'); // DEPRECATED - use pressSequentiallyWant to practice writing these Playwright scripts?
Don't just read the answers. Run and modify real JavaScript & TypeScript automation scripts instantly in your browser with our sandboxed Practice Editor.
How to enter values in the input text field sequentially and validate it?
Use `pressSequentially()` to simulate character-by-character typing (triggering keydown/keypress/keyup events). Then validate with `toHaveValue()` Web-First assertion.
Deep Dive Explanation
The `delay` option in `pressSequentially()` controls milliseconds between keystrokes. Setting a realistic delay (50-150ms) makes the typing feel human to JavaScript event handlers that might throttle or debounce rapid input. For OTP or PIN inputs that listen for individual key events, this is essential.
const searchInput = page.getByRole('searchbox');
// Type sequentially with delay (simulates human typing)
await searchInput.pressSequentially('playwright', { delay: 100 });
// Verify the value
await expect(searchInput).toHaveValue('playwright');
// Verify autocomplete suggestions appear
await expect(page.getByRole('listbox')).toBeVisible();
await expect(page.getByRole('option', { name: /playwright/i })).toBeVisible();
// Select the first suggestion
await page.getByRole('option').first().click();
// Verify final state
await expect(searchInput).toHaveValue('Playwright Testing');What is a Playwright Test Runner?
`@playwright/test` is Playwright's purpose-built test runner. It provides parallel execution, fixtures, web-first assertions, tracing, retries, multiple reporters, and built-in TypeScript support β all in one package.
Deep Dive Explanation
Before `@playwright/test`, teams combined Playwright with Jest or Mocha. This worked but missed out on web-first assertions (auto-retrying), the fixture system, and integrated tracing. The native runner was purpose-built for browser automation concerns. It also includes `playwright/test` which provides the `test`, `expect`, and `defineConfig` exports that are the foundation of every test file.
What are Fixtures in Playwright?
Fixtures are Playwright's dependency injection mechanism. They provide isolated, reusable setup/teardown environments to tests. Built-in fixtures include `page`, `context`, `browser`, `request`. You can extend them with custom fixtures for your application.
Deep Dive Explanation
See Question 5 for a deep dive on custom fixtures. Key properties of fixtures: 1) Lazy β only created if test requests them. 2) Isolated β each test gets its own instance. 3) Composable β fixtures can depend on other fixtures. 4) Scoped β can be 'test' or 'worker' scoped.
// Built-in fixtures available in every test:
test('built-in fixtures', async ({
page, // Fresh Page in an isolated BrowserContext
context, // The BrowserContext for this test
browser, // The shared Browser instance
request, // APIRequestContext for HTTP calls
browserName // 'chromium' | 'firefox' | 'webkit'
}) => {
console.log(`Running on: ${browserName}`);
await page.goto('/');
});What is the difference between Browser and Browser Context in Playwright?
A `Browser` is the physical browser process (Chromium.exe). A `BrowserContext` is a lightweight isolated session within that browser (like an incognito window). Multiple contexts share one browser process but have zero shared state.
Deep Dive Explanation
The Browser β Context β Page hierarchy is what makes Playwright's multi-user testing possible. One browser process can simultaneously run tests for User A, User B, and User C β each in completely isolated contexts β without launching 3 browser processes.
// Browser is expensive to create - shared across tests in a worker
const browser = await chromium.launch(); // Launches browser process
// BrowserContext is cheap - created per test automatically
const context1 = await browser.newContext(); // User A session (own cookies/storage)
const context2 = await browser.newContext(); // User B session (completely isolated)
const context3 = await browser.newContext({ storageState: 'admin.json' }); // Admin
// Each context can have multiple pages (tabs)
const page1 = await context1.newPage();
const page2 = await context1.newPage(); // Second tab in User A's session
// Contexts are independent:
await context1.addCookies([{ name: 'session', value: 'user-a', domain: 'app.com' }]);
const cookiesInContext2 = await context2.cookies(); // [] - empty, no leakageHow do you handle waiting / synchronization issues in Playwright?
Use the hierarchy: (1) Auto-waiting (built-in, free). (2) Web-First assertions (`expect().toBeVisible()`). (3) Explicit waits (`waitFor`, `waitForResponse`). Never use `waitForTimeout` (sleep).
Deep Dive Explanation
The mental model: Playwright should always wait for SOMETHING SPECIFIC (element state, network call, URL change), never for an arbitrary amount of time. If you're reaching for `waitForTimeout`, ask yourself 'what condition am I actually waiting for?' and wait for that condition instead.
// β
Tier 1: Auto-waiting (automatic for all actions)
await page.getByRole('button', { name: 'Load' }).click(); // Waits for button
// β
Tier 2: Web-First assertion (retries until true)
await expect(page.locator('.results')).toBeVisible({ timeout: 10000 });
// β
Tier 3: Wait for specific network response
await page.waitForResponse(res =>
res.url().includes('/api/data') && res.status() === 200
);
// β
Tier 3: Wait for URL change after navigation
await page.waitForURL('**/dashboard');
// β
Tier 3: Wait for element state change
await page.locator('#spinner').waitFor({ state: 'hidden' });
// β NEVER: Hard sleep (unreliable, slow)
await page.waitForTimeout(3000); // Don't do this!How do you run tests in parallel or in multiple browsers/devices/contexts?
Configure `projects` in `playwright.config.ts` to run your test suite across multiple browsers and devices. Each project is an independent test run with its own browser and configuration.
Deep Dive Explanation
Playwright bundles 50+ device descriptors (phones, tablets, laptops) with correct viewport, user-agent, touch events, and device pixel ratio. Running across Chrome, Firefox, and WebKit (Safari) with one command ensures cross-browser compatibility without managing multiple browser installations.
import { defineConfig, devices } from '@playwright/test';
export default defineConfig({
fullyParallel: true,
projects: [
// Desktop browsers
{ name: 'Chrome', use: { ...devices['Desktop Chrome'] } },
{ name: 'Firefox', use: { ...devices['Desktop Firefox'] } },
{ name: 'Safari', use: { ...devices['Desktop Safari'] } },
// Mobile emulation
{ name: 'Mobile iOS', use: { ...devices['iPhone 14'] } },
{ name: 'Mobile Android', use: { ...devices['Pixel 7'] } },
// Custom viewport
{ name: 'HD', use: { viewport: { width: 1920, height: 1080 } } },
],
});
// Run specific project:
// npx playwright test --project=Firefox
// npx playwright test --project="Mobile iOS"How do you run Playwright tests headlessly vs headed?
Playwright runs headless by default (no visible browser). Pass `--headed` CLI flag or set `headless: false` in config for a visible browser. Use `--debug` for headed mode with the Inspector attached.
Deep Dive Explanation
The `--debug` flag is more powerful than `--headed` alone: it also attaches the Playwright Inspector (a GUI for stepping through tests), enables step-by-step execution, and automatically applies `slowMo` to make actions visible. It's the recommended debugging mode.
// CLI options
// npx playwright test β headless (default, for CI)
// npx playwright test --headed β headed (see browser)
// npx playwright test --debug β headed + Playwright Inspector
// PWDEBUG=1 npx playwright test β same as --debug
// Config-based
export default defineConfig({
use: {
headless: true, // default
// headless: false, // for debugging sessions
},
});
// Environment-aware (common pattern)
export default defineConfig({
use: {
headless: process.env.CI === 'true', // headless in CI, headed locally
},
});What testing frameworks does TypeScript support in Playwright?
Playwright natively supports TypeScript with zero configuration. Write `.ts` test files and run them directly β `@playwright/test` handles transpilation automatically via esbuild.
Deep Dive Explanation
Playwright uses esbuild to transpile TypeScript on-the-fly (no `tsc` step required). This is significantly faster than traditional TypeScript compilation. You get full IntelliSense, type-checking in your IDE, and typed custom fixtures β all the benefits of TypeScript with none of the build overhead.
// TypeScript test - no tsconfig or setup needed!
import { test, expect, Page } from '@playwright/test';
// Full type safety on all Playwright APIs
async function login(page: Page, email: string, password: string) {
await page.getByLabel('Email').fill(email);
await page.getByLabel('Password').fill(password);
await page.getByRole('button', { name: 'Sign In' }).click();
}
test('typed test', async ({ page }) => {
await login(page, 'admin@test.com', 'secret');
await expect(page).toHaveURL('/dashboard');
});
// Custom typed fixture
import { test as base } from '@playwright/test';
type MyFixtures = { adminPage: Page };
const test2 = base.extend<MyFixtures>({
adminPage: async ({ page }, use) => {
await login(page, 'admin@test.com', 'secret');
await use(page);
}
});How to slow down test execution in Playwright?
Use the `slowMo` option in launch options to add a fixed delay (in ms) between every Playwright action. Use `--debug` flag or `page.pause()` for step-through debugging.
Deep Dive Explanation
Remove `slowMo` before committing code to the repository β a `slowMo: 1000` with 100 tests adds 100+ seconds to your CI pipeline for no benefit. Use environment variables to toggle it on/off, keeping your pipeline fast while enabling it locally for observation.
// playwright.config.ts - add slowMo for debugging
export default defineConfig({
use: {
launchOptions: {
slowMo: 500, // 500ms pause after each action (not in CI!)
},
headless: false, // See the slowdown visually
},
});
// Better: Only slow down in development
export default defineConfig({
use: {
launchOptions: {
slowMo: process.env.SLOWMO ? parseInt(process.env.SLOWMO) : 0,
},
},
});
// Run with: SLOWMO=1000 npx playwright test --headed
// Alternative: pause at a specific point
test('debug specific step', async ({ page }) => {
await page.goto('/checkout');
await page.pause(); // Opens Inspector here, then continue manually
await page.getByRole('button', { name: 'Pay' }).click();
});What is the importance of getByRole locators in Playwright?
`getByRole` is Playwright's top-priority locator because it targets elements the same way users and screen readers do β by their ARIA role and accessible name. It's the most resilient locator to HTML refactoring.
Deep Dive Explanation
ARIA roles are part of the W3C accessibility spec. Every HTML element has an implicit role (button β 'button', a href β 'link', input β 'textbox'). `getByRole` uses the accessibility tree (not the DOM), making it immune to class name changes, HTML restructuring, and CSS refactoring. If `getByRole` can't find your button, a screen reader user can't either β a dual benefit.
// Buttons, links, inputs by role
await page.getByRole('button', { name: 'Add to Cart' }).click();
await page.getByRole('link', { name: 'Privacy Policy' }).click();
await page.getByRole('textbox', { name: 'Search' }).fill('laptop');
await page.getByRole('checkbox', { name: 'Remember me' }).check();
await page.getByRole('combobox', { name: 'Country' }).selectOption('US');
// Headings by level
await expect(page.getByRole('heading', { level: 1 })).toHaveText('Dashboard');
// Tables
await expect(page.getByRole('columnheader', { name: 'Price' })).toBeVisible();
await page.getByRole('row', { name: /Alice/ }).getByRole('button', { name: 'Edit' }).click();
// Hidden option (filter out hidden elements - default behavior)
await page.getByRole('button', { name: 'Submit', includeHidden: false }).click();Ready to practice E2E web automation on real demo apps?
Stop writing scripts on static websites. Automate dynamic tables, flights, e-commerce checkouts, and REST APIs inside our fully integrated QA Automation Playground.
What is the importance of getByText locator in Playwright?
`getByText` locates elements by their visible text content. It is best used for non-interactive elements like headings, paragraphs, and labels where you need to assert text is visible on the page.
Deep Dive Explanation
Unlike getByRole, getByText does NOT filter out hidden elements by default. If multiple elements share the same text, you may get a strict mode violation. Prefer getByRole for interactive elements (buttons, links) and reserve getByText for asserting paragraph content or non-interactive labels.
// Exact match
await expect(page.getByText('Welcome back!')).toBeVisible();
// Partial match (default)
await page.getByText('Sign in').click();
// Case-insensitive
await page.getByText('submit', { exact: false }).click();What is the importance of getByLabel locator in Playwright?
`getByLabel` finds form elements (input, textarea, select) by their associated `<label>` text. This is the recommended way to locate form fields because it reflects how users identify fields on a form.
Deep Dive Explanation
Using getByLabel enforces accessible forms. If your label association breaks (e.g., the `for` attribute is removed), the test will fail β alerting you to an accessibility regression. This is a huge advantage over CSS selectors which would silently pass.
// HTML: <label for="email">Email Address</label><input id="email" />
await page.getByLabel('Email Address').fill('user@example.com');
// Works with aria-label too
await page.getByLabel('Search').fill('playwright');How to interact with elements using chaining locators in Playwright?
Playwright lets you chain locators to narrow down elements precisely. Instead of a complex CSS/XPath, you combine semantic locators to target the exact element in a specific context.
Deep Dive Explanation
Chaining makes selectors resilient and self-documenting. Each step narrows the scope, so there is no ambiguity. This is far superior to writing fragile nth-child CSS selectors or long XPath expressions.
// Find the 'Add to Cart' button only within the 'Laptop' product card
await page
.getByRole('listitem')
.filter({ hasText: 'Laptop Pro' })
.getByRole('button', { name: 'Add to Cart' })
.click();
// Scoped to a section
const nav = page.getByRole('navigation', { name: 'Main' });
await nav.getByRole('link', { name: 'Products' }).click();How to handle radio buttons using Playwright?
Radio buttons are best located using `getByLabel` or `getByRole('radio')` and interacted with using `.check()`. You can assert their state with `toBeChecked()`.
Deep Dive Explanation
Never use `.click()` on radio buttons in critical tests β `.check()` is idempotent (safe to call even if already checked) and Playwright will wait for the radio to be enabled before acting.
// Check a radio button by its label
await page.getByLabel('Female').check();
// Verify it's selected
await expect(page.getByLabel('Female')).toBeChecked();
// Check using role
await page.getByRole('radio', { name: 'Express Shipping' }).check();
// Verify another is NOT checked
await expect(page.getByLabel('Male')).not.toBeChecked();How to handle a checkbox using Playwright?
Checkboxes use `.check()`, `.uncheck()`, and `.setChecked(bool)`. The `.setChecked()` method is the most robust as it works regardless of current state.
Deep Dive Explanation
`.setChecked(true/false)` is preferred in data-driven tests because it ensures the correct state regardless of the checkbox's current state, preventing double-toggling bugs.
const agreeCheckbox = page.getByLabel('I agree to Terms');
// Check it
await agreeCheckbox.check();
await expect(agreeCheckbox).toBeChecked();
// Uncheck it
await agreeCheckbox.uncheck();
// Set state conditionally (most robust)
await agreeCheckbox.setChecked(true);
await agreeCheckbox.setChecked(false);How to capture video in Playwright?
Playwright can record a video of every test by enabling the `video` option in the configuration. Videos are saved per test and are invaluable for debugging failures in CI.
Deep Dive Explanation
Use `retain-on-failure` in CI to save disk space β videos are only kept when a test fails. The video file is finalized after the page/context is closed, so always close the context before reading the video path.
// In playwright.config.ts
export default defineConfig({
use: {
video: 'retain-on-failure', // Options: 'on', 'off', 'retain-on-failure', 'on-first-retry'
},
});
// Manually saving video path in a test
test('record video', async ({ page }, testInfo) => {
await page.goto('/dashboard');
// ... test steps
// Video is automatically saved after test completes
const videoPath = await page.video()?.path();
console.log('Video saved at:', videoPath);
});What are Playwright Hooks?
Playwright Test provides four lifecycle hooks: `test.beforeAll`, `test.afterAll`, `test.beforeEach`, and `test.afterEach`. They allow you to run setup and teardown code at specific points in the test lifecycle.
Deep Dive Explanation
beforeAll/afterAll share a single worker context, so state CAN leak between tests when using them. Prefer beforeEach/afterEach (or Fixtures) for true test isolation.
import { test, expect } from '@playwright/test';
test.beforeAll(async ({ browser }) => {
// Runs once before all tests in this file
console.log('Suite started');
});
test.beforeEach(async ({ page }) => {
// Runs before EACH test
await page.goto('/');
});
test.afterEach(async ({ page }, testInfo) => {
// Runs after EACH test
if (testInfo.status !== testInfo.expectedStatus) {
await page.screenshot({ path: `failure-${testInfo.title}.png` });
}
});
test.afterAll(async () => {
// Runs once after all tests
console.log('Suite finished');
});What are the key differences between fixtures and beforeEach hook?
Both set up test state, but fixtures are composable, type-safe, reusable across files, and support lazy initialization. `beforeEach` is simpler but file-scoped and not reusable.
Deep Dive Explanation
Key differences: 1) Fixtures are lazy (only created if requested). 2) Fixtures are composable (one fixture can use another). 3) Fixtures can be shared across test files by exporting them. 4) Fixtures have automatic teardown via the `use()` callback pattern.
// beforeEach approach (file-scoped, not reusable)
test.beforeEach(async ({ page }) => {
await page.goto('/login');
await page.fill('#user', 'admin');
await page.fill('#pass', 'secret');
await page.click('#submit');
});
// Fixture approach (reusable across files, type-safe)
const test = base.extend<{ loggedInPage: Page }>({
loggedInPage: async ({ page }, use) => {
await page.goto('/login');
await page.fill('#user', 'admin');
await page.fill('#pass', 'secret');
await page.click('#submit');
await use(page); // hand off to test
// teardown runs here automatically
}
});What is test.describe() in Playwright?
`test.describe()` groups related tests together, creating a named block. It helps organize tests logically and allows shared hooks (beforeEach/afterEach) that only apply to tests within that group.
Deep Dive Explanation
You can nest `test.describe()` blocks for sub-grouping. Use `test.describe.only()` to run a single group during development, or `test.describe.configure({ mode: 'parallel' })` to run tests within a group in parallel.
import { test, expect } from '@playwright/test';
test.describe('User Authentication', () => {
test.beforeEach(async ({ page }) => {
await page.goto('/login');
});
test('valid login', async ({ page }) => {
await page.fill('#email', 'user@test.com');
await page.fill('#pass', 'password');
await page.click('[type=submit]');
await expect(page).toHaveURL('/dashboard');
});
test('invalid login shows error', async ({ page }) => {
await page.fill('#email', 'wrong@test.com');
await page.fill('#pass', 'wrong');
await page.click('[type=submit]');
await expect(page.getByText('Invalid credentials')).toBeVisible();
});
});Transition from SDET to GenAI Test Architect
Stop writing traditional deterministic automation. Learn how to architect, test, and secure probabilistic AI Agents, validate LLM systems, and lead next-gen QA. Explore our complete 1-Year master roadmap.
How to handle dynamic elements that appear unpredictably?
Use Web-First assertions like `expect(locator).toBeVisible()` which automatically retry until the element appears (within the timeout). For specific conditions, use `locator.waitFor({ state: 'visible' })`.
Deep Dive Explanation
Never use `page.waitForTimeout()` (sleep) for dynamic elements β it creates slow, brittle tests. Web-First assertions are the correct pattern: they retry every 100ms until the condition is met or the timeout expires.
// Web-First assertion auto-retries until element appears
await expect(page.getByText('Success! Order placed.')).toBeVisible({ timeout: 10000 });
// Wait for a specific state explicitly
await page.locator('.toast-notification').waitFor({ state: 'visible' });
await page.locator('.loading-spinner').waitFor({ state: 'hidden' });
// Poll for dynamic content
await expect(page.locator('.data-table')).toHaveCount(10, { timeout: 15000 });How do Retries work in Playwright?
Playwright can automatically re-run failed tests a configured number of times. The entire test (including beforeEach hooks) is re-executed from scratch on each retry.
Deep Dive Explanation
Retries enable trace capture on failure: set `trace: 'on-first-retry'` to get a full trace only when a test fails and is being retried. This reduces storage overhead while ensuring you always have a trace for genuinely failing tests.
// playwright.config.ts
export default defineConfig({
retries: process.env.CI ? 2 : 0, // 2 retries in CI, 0 locally
});
// Per-test retry override
test('flaky network test', async ({ page }) => {
test.info().retry; // Get current retry number (0 = first run)
// ...
}, { retries: 3 });
// Check retry count inside a test
test('with retry logic', async ({ page }, testInfo) => {
if (testInfo.retry > 0) {
await page.goto('/clear-cache'); // Extra step on retry
}
});What test runner and assertion library does Playwright support?
Playwright has its own built-in test runner (`@playwright/test`) with integrated Web-First assertions via `expect`. It also works with Jest and Mocha, but the native runner is strongly recommended.
Deep Dive Explanation
The native runner's `expect` library is fundamentally different from Jest's expect β it is 'web-first', meaning assertions automatically retry until they pass. Jest's expect is synchronous and does not retry, making it unsuitable for async UI assertions.
// Native @playwright/test (recommended)
import { test, expect } from '@playwright/test';
test('native runner', async ({ page }) => {
await page.goto('https://playwright.dev');
await expect(page).toHaveTitle(/Playwright/);
await expect(page.getByRole('heading', { level: 1 })).toBeVisible();
});
// With custom expect matchers
expect.extend({
async toHaveNoConsoleErrors(page) {
// custom assertion logic
}
});What are Web-First Assertions in Playwright?
Web-First Assertions are Playwright's built-in `expect` methods that automatically wait and retry until the condition is met or the timeout expires. They are the correct way to assert UI state.
Deep Dive Explanation
The retry-until-pass mechanism eliminates race conditions. If a button becomes disabled 200ms after a click (while Playwright's assertion runs), toBeDisabled() will catch it rather than failing prematurely. This is the biggest quality-of-life improvement in modern Playwright vs Selenium.
// These all AUTO-RETRY until true (up to timeout)
await expect(page).toHaveTitle('Dashboard');
await expect(page).toHaveURL('/dashboard');
await expect(locator).toBeVisible();
await expect(locator).toBeHidden();
await expect(locator).toBeEnabled();
await expect(locator).toBeDisabled();
await expect(locator).toBeChecked();
await expect(locator).toHaveText('Welcome, John');
await expect(locator).toHaveValue('john@email.com');
await expect(locator).toHaveCount(5);
await expect(locator).toHaveAttribute('aria-expanded', 'true');
await expect(locator).toHaveClass(/active/);How to use Negative Assertions in Playwright?
Prepend `.not` before any Web-First assertion to invert it. Like positive assertions, `.not` assertions also auto-retry, waiting for the condition to become false.
Deep Dive Explanation
Avoid using `locator.isVisible()` or `locator.isHidden()` for assertions β they return immediately without waiting. Always use the assertion form `expect(locator).not.toBeVisible()` which retries.
// Negative assertions - all auto-retry
await expect(page.locator('.error-banner')).not.toBeVisible();
await expect(page.getByRole('button', { name: 'Submit' })).not.toBeDisabled();
await expect(page.locator('#user-menu')).not.toHaveText('Guest');
await expect(page).not.toHaveURL('/login');
// Useful after actions
await page.getByRole('button', { name: 'Delete' }).click();
await expect(page.getByText('Record #42')).not.toBeVisible({ timeout: 5000 });When to say no to Playwright Automation?
Playwright is not always the right tool. Avoid automating: (1) One-time tasks better done manually, (2) Highly unstable UIs under heavy development, (3) CAPTCHA/anti-bot systems, (4) Desktop (non-browser) applications, (5) Tests requiring real hardware (camera, USB).
Deep Dive Explanation
The cost of maintaining automation must be lower than the cost of manual testing. If a feature changes every sprint and breaks automation constantly, the ROI is negative. Also, Playwright is browser-based β native desktop apps, mobile (real device), and hardware integration tests require different tools (Appium, WinAppDriver, etc.).
What is the difference between Hard and Soft Assertions in Playwright?
Hard assertions (default) stop the test immediately on failure. Soft assertions (`expect.soft()`) allow the test to continue running and collect ALL failures, reporting them together at the end.
Deep Dive Explanation
Use soft assertions when validating multiple independent fields on a page (like a profile form) where you want to see all broken fields at once. Use hard assertions for critical pre-conditions β if the page title is wrong, there's no point checking the form fields.
test('hard vs soft assertions', async ({ page }) => {
await page.goto('/profile');
// HARD assertion - test stops here if it fails
await expect(page).toHaveTitle('User Profile');
// SOFT assertions - test continues even if these fail
await expect.soft(page.getByLabel('Name')).toHaveValue('John Doe');
await expect.soft(page.getByLabel('Email')).toHaveValue('john@example.com');
await expect.soft(page.getByLabel('Phone')).toHaveValue('+1-555-0100');
// Test continues here regardless of soft assertion results
await page.getByRole('button', { name: 'Save' }).click();
// All soft assertion failures are reported at test end
});What are Non-Retrying (Generic) Assertions in Playwright?
Non-retrying assertions are plain synchronous checks (similar to Jest's `expect`) that do NOT auto-wait. They evaluate immediately and are used for non-UI values like API response data, variables, or computed values.
Deep Dive Explanation
The key distinction: Web-First assertions like `expect(locator).toBeVisible()` retry. Generic assertions like `expect(value).toBe(x)` do not retry. Never use generic assertions on locators β always use Web-First assertions for DOM checks.
test('non-retrying assertions', async ({ request }) => {
const response = await request.get('/api/users/1');
// Non-retrying - evaluates immediately
expect(response.status()).toBe(200);
expect(response.ok()).toBeTruthy();
const body = await response.json();
expect(body.name).toBe('John Doe');
expect(body.role).toContain('admin');
expect(body.items).toHaveLength(3);
expect(body.score).toBeGreaterThan(0);
expect(body.email).toMatch(/@example\.com$/);
});How to handle single select dropdown in Playwright?
Use `locator.selectOption()` on `<select>` elements. You can select by value, label, or index. For custom dropdowns (non-native `<select>`), use `.click()` to open and then `getByRole` or `getByText` to pick an option.
Deep Dive Explanation
Always prefer `selectOption()` for native `<select>` elements as it's reliable and triggers the correct change events. For custom dropdowns built with divs/spans, treat them like regular UI interactions: click to open, then click the option.
// Native <select> dropdown
const dropdown = page.locator('#country-select');
// Select by visible text/label
await dropdown.selectOption({ label: 'United States' });
// Select by value attribute
await dropdown.selectOption({ value: 'US' });
// Select by index (0-based)
await dropdown.selectOption({ index: 2 });
// Verify selection
await expect(dropdown).toHaveValue('US');
// Custom dropdown (not a native <select>)
await page.getByRole('combobox', { name: 'Country' }).click();
await page.getByRole('option', { name: 'United States' }).click();What is timeout in Playwright?
Playwright has multiple timeout layers: (1) Action timeout β how long to wait for an element to be actionable. (2) Assertion timeout β how long to retry a Web-First assertion. (3) Navigation timeout β how long `goto()` waits. (4) Test timeout β the total time a test can run.
Deep Dive Explanation
The hierarchy matters: if your test timeout is 30s but an action takes 20s, you have only 10s left for remaining assertions. Always set timeouts based on actual observed performance, not arbitrary large values.
// playwright.config.ts - global settings
export default defineConfig({
timeout: 30000, // Test timeout: 30s
expect: {
timeout: 5000, // Assertion timeout: 5s
},
use: {
actionTimeout: 10000, // Action timeout: 10s
navigationTimeout: 30000, // Navigation timeout: 30s
},
});
// Override per-action
await page.locator('#slow-element').click({ timeout: 15000 });
// Override per-assertion
await expect(page.locator('.result')).toBeVisible({ timeout: 20000 });
// Override test timeout
test('long test', async ({ page }) => {
test.setTimeout(60000); // This test gets 60s
});What is the importance of getByPlaceholder locator in Playwright?
`getByPlaceholder` finds input elements by their `placeholder` attribute. It's useful when an input has no visible label but has a descriptive placeholder text.
Deep Dive Explanation
While useful, placeholder-based locators are slightly less robust than `getByLabel` because placeholder text often changes more frequently and is less semantically meaningful. Prefer `getByLabel` when an accessible label exists. Use `getByPlaceholder` for search inputs and filter boxes that typically lack labels.
// HTML: <input placeholder="Search products..." type="text">
await page.getByPlaceholder('Search products...').fill('laptop');
// Case-insensitive match
await page.getByPlaceholder('enter email', { exact: false }).fill('test@example.com');
// Useful for search bars without labels
await page.getByPlaceholder('Type to filter...').fill('admin');
await expect(page.getByPlaceholder('Type to filter...')).toHaveValue('admin');What is the importance of getByAltText locator in Playwright?
`getByAltText` finds `<img>`, `<area>`, and `<input type='image'>` elements by their `alt` attribute. It's the semantic way to locate images, especially for accessibility validation.
Deep Dive Explanation
A missing or empty `alt` attribute is a WCAG accessibility failure. Tests using `getByAltText` serve a dual purpose: they verify the element is visible AND implicitly validate that proper alt text is in place. If an image is purely decorative, it should have `alt=''`.
// HTML: <img src="/logo.png" alt="CareerRaah Logo" />
await expect(page.getByAltText('CareerRaah Logo')).toBeVisible();
// Click an image link
await page.getByAltText('Home').click();
// Validate alt text exists (accessibility check)
const images = page.locator('img');
const count = await images.count();
for (let i = 0; i < count; i++) {
const alt = await images.nth(i).getAttribute('alt');
expect(alt).not.toBeNull(); // All images must have alt text
}How to repeat a test in Playwright irrespective of pass or fail?
Use `test.describe.configure({ retries: N })` or the global `retries` config to repeat tests. For guaranteed repetition regardless of outcome, use a loop inside the test or the `--repeat-each` CLI flag.
Deep Dive Explanation
`--repeat-each` is excellent for identifying flaky tests before merging to main. Run your test suite with `--repeat-each=5` in a dedicated flakiness pipeline. Any test that fails even once across the 5 runs is considered flaky and needs investigation.
// Run every test 3 times via CLI (useful for flakiness detection)
// npx playwright test --repeat-each=3
// Or in config
export default defineConfig({
repeatEach: 3,
});
// Custom loop inside a test
test('stability check', async ({ page }) => {
for (let i = 0; i < 5; i++) {
await page.goto('/');
await expect(page.getByRole('heading', { level: 1 })).toBeVisible();
console.log(`Run ${i + 1} passed`);
}
});What is the difference between expect(locator).toBeVisible() and locator.isVisible()?
`toBeVisible()` is a Web-First assertion that auto-retries until the element is visible (up to timeout). `isVisible()` is an immediate boolean check that returns `true` or `false` right now with NO waiting.
Deep Dive Explanation
The critical mistake beginners make: using `isVisible()` as an assertion. If the element hasn't rendered yet (e.g., waiting for an API response), `isVisible()` returns `false` immediately and you write `if (!isVisible) fail()` β creating a flaky test. Always use `expect().toBeVisible()` for assertions.
// toBeVisible() - RETRIES until visible or timeout (RECOMMENDED for assertions)
await expect(page.locator('.success-message')).toBeVisible({ timeout: 5000 });
// isVisible() - IMMEDIATE check, no waiting (use for conditional logic only)
const isLoggedIn = await page.locator('#user-avatar').isVisible();
if (isLoggedIn) {
await page.getByRole('button', { name: 'Logout' }).click();
} else {
await page.goto('/login');
}Explain key common Playwright matchers and their purpose?
Playwright provides a rich set of Web-First matchers for UI assertions and generic matchers for value checks.
Deep Dive Explanation
All element matchers above are Web-First (they retry). The screenshot matcher (`toHaveScreenshot`) enables visual regression testing β Playwright compares against a stored baseline and fails if pixels differ beyond a threshold.
// PAGE matchers
await expect(page).toHaveTitle(/Dashboard/);
await expect(page).toHaveURL('https://app.com/home');
// ELEMENT visibility/state
await expect(locator).toBeVisible();
await expect(locator).toBeHidden();
await expect(locator).toBeEnabled();
await expect(locator).toBeDisabled();
await expect(locator).toBeChecked();
await expect(locator).toBeEditable();
await expect(locator).toBeEmpty(); // empty input
// CONTENT matchers
await expect(locator).toHaveText('Exact text');
await expect(locator).toContainText('partial');
await expect(locator).toHaveValue('input value');
await expect(locator).toHaveAttribute('href', '/about');
await expect(locator).toHaveClass(/active/);
await expect(locator).toHaveCount(5);
// SCREENSHOT
await expect(page).toHaveScreenshot('homepage.png');How to handle multi-select dropdown in Playwright?
For native `<select multiple>` elements, pass an array to `selectOption()`. For custom multi-select UI components, interact with each option individually.
Deep Dive Explanation
The `toHaveValues()` matcher (plural) is specifically designed for multi-select validation. It checks that the selected options match the expected array exactly β the order doesn't matter.
// Native <select multiple>
const multiSelect = page.locator('#skills-select');
// Select multiple by value
await multiSelect.selectOption(['javascript', 'typescript', 'python']);
// Select multiple by label
await multiSelect.selectOption([
{ label: 'JavaScript' },
{ label: 'TypeScript' },
]);
// Verify selected values
await expect(multiSelect).toHaveValues(['javascript', 'typescript', 'python']);
// Deselect all
await multiSelect.selectOption([]);
// Custom multi-select (checkbox-based)
await page.getByRole('checkbox', { name: 'JavaScript' }).check();
await page.getByRole('checkbox', { name: 'TypeScript' }).check();How can users change the default timeout for assertions in Playwright?
The assertion timeout (how long Web-First assertions retry) defaults to 5 seconds. You can change it globally in `playwright.config.ts` or per-assertion.
Deep Dive Explanation
Never set timeouts too high as a workaround for slow apps β it inflates test execution time. Instead, investigate WHY the element is slow to appear (API delay? animation?) and address the root cause. Use per-assertion overrides only for known slow operations like file processing or email delivery.
// 1. Global: playwright.config.ts
export default defineConfig({
expect: {
timeout: 10000, // All assertions wait up to 10s
},
});
// 2. Per-assertion override
await expect(page.locator('.slow-widget')).toBeVisible({ timeout: 30000 });
// 3. Change default globally at runtime
expect.configure({ timeout: 15000 });
// 4. Reset to default
expect.configure({ timeout: 5000 });What is the importance of getByTitle locator in Playwright?
`getByTitle` locates elements by their `title` attribute. This is useful for icon buttons (that have no visible text) or elements with tooltip-style titles.
Deep Dive Explanation
The `title` attribute is less common than `aria-label` in modern accessible UIs. Prefer `getByRole('button', { name: 'Close dialog' })` when possible, as ARIA labels are better supported by screen readers. Use `getByTitle` specifically when an element only has a `title` attribute and no accessible role name.
// HTML: <button title="Close dialog">X</button>
await page.getByTitle('Close dialog').click();
// HTML: <abbr title="HyperText Markup Language">HTML</abbr>
await expect(page.getByTitle('HyperText Markup Language')).toBeVisible();
// Partial match
await page.getByTitle('Export', { exact: false }).click();How do you handle multiple elements that match the same locator?
When a locator matches multiple elements, use `.first()`, `.last()`, `.nth(n)`, `.filter()`, or add more specificity to the locator to disambiguate.
Deep Dive Explanation
`.nth()` and `.first()` are fragile β the order of elements can change. Always prefer `.filter({ hasText: '...' })` or scoping with a parent locator when possible, as these are resilient to DOM reordering.
const rows = page.getByRole('row');
// Index-based access
await rows.first().click();
await rows.last().getByRole('button', { name: 'Edit' }).click();
await rows.nth(2).getByRole('checkbox').check(); // 0-indexed
// Filter for specificity (PREFERRED)
await page
.getByRole('row')
.filter({ hasText: 'John Doe' })
.getByRole('button', { name: 'Edit' })
.click();
// Count elements
const count = await rows.count();
expect(count).toBe(10);
// Iterate all matching elements
for (const row of await rows.all()) {
const text = await row.textContent();
console.log(text);
}How do you filter locators in Playwright?
Use `.filter()` on a locator to narrow results based on text content, another locator presence, or a custom predicate. Filters can be chained.
Deep Dive Explanation
Filters are evaluated lazily β they don't trigger DOM queries until an action or assertion is made. This means you can build complex filter chains without performance overhead.
// Filter by text content
const adminUsers = page.getByRole('listitem').filter({ hasText: 'Admin' });
// Filter by child element presence
const rowsWithCheckbox = page.getByRole('row').filter({
has: page.getByRole('checkbox', { checked: true })
});
// Filter by NOT having text
const incompleteItems = page.getByRole('listitem').filter({
hasNot: page.getByText('β Complete')
});
// Chain filters
const activeAdmins = page
.getByRole('listitem')
.filter({ hasText: 'Admin' })
.filter({ has: page.locator('.status-active') });
await expect(activeAdmins).toHaveCount(3);What makes Playwright different from Selenium and Cypress?
Playwright combines the best of both worlds: Selenium's multi-browser support (Chrome, Firefox, Safari) with Cypress's modern DX (auto-waiting, great debugging). But it goes further with native multi-tab, multi-origin, iframe support, and a WebSocket-based protocol for speed.
Deep Dive Explanation
Selenium: mature, multi-language, WebDriver protocol (slower, no auto-wait). Cypress: great DX, but single-tab, single-origin, JS only. Playwright: multi-language (JS/TS/Python/Java/C#), multi-browser, multi-tab, multi-origin, fast WebSocket protocol, built-in auto-waiting.
// Playwright advantage: Multi-context in one test
test('multi-user scenario', async ({ browser }) => {
const userAContext = await browser.newContext({ storageState: 'auth-admin.json' });
const userBContext = await browser.newContext({ storageState: 'auth-user.json' });
const adminPage = await userAContext.newPage();
const userPage = await userBContext.newPage();
await adminPage.goto('/admin/messages');
await adminPage.getByRole('button', { name: 'Send Alert' }).click();
// Verify user receives it (in same test)
await expect(userPage.getByText('New Alert!')).toBeVisible();
});How does Playwright achieve auto-waiting internally?
Playwright implements actionability checks using JavaScript injected into the browser page. Before every action, it polls the DOM using `requestAnimationFrame` to verify the element passes all checks before proceeding.
Deep Dive Explanation
Internally, Playwright: 1) Evaluates a JavaScript expression in the browser to check element state. 2) If the check fails, it schedules a re-check via `requestAnimationFrame` (next paint cycle). 3) It keeps retrying until the element passes or the timeout expires. This is far more efficient than Selenium's polling mechanism and ties directly to the browser's rendering pipeline.
What are browser contexts and why are they important?
A `BrowserContext` is a lightweight, isolated browser session β like a fresh incognito window. Each context has its own cookies, localStorage, sessionStorage, and cache. They are crucial for test isolation and multi-user testing.
Deep Dive Explanation
Without browser contexts, you'd need a separate browser process per test (slow/expensive). Contexts make isolation fast and cheap. Playwright Test creates a new context per test by default, guaranteeing no state leakage between tests.
// Each test gets a fresh context automatically in @playwright/test
test('isolated test', async ({ context, page }) => {
// context is fresh - no cookies from other tests
await context.addCookies([{ name: 'session', value: 'abc', domain: 'app.com' }]);
await page.goto('/dashboard');
});
// Creating contexts manually for multi-user tests
const ctx1 = await browser.newContext(); // User A session
const ctx2 = await browser.newContext(); // User B session
// They share ZERO stateHow does Playwright handle multiple tabs within a single test?
Each browser tab is a `Page` object. When an action opens a new tab, you listen for the `page` event on the context. You can then interact with both pages simultaneously.
Deep Dive Explanation
The `Promise.all()` pattern is critical β it registers the event listener BEFORE triggering the click, ensuring the new tab event is never missed. Starting the wait after the click would create a race condition.
test('multiple tabs', async ({ context, page }) => {
await page.goto('https://app.com');
// Wait for new tab to open when clicking a link
const [newTab] = await Promise.all([
context.waitForEvent('page'),
page.getByRole('link', { name: 'Open Preview' }).click()
]);
await newTab.waitForLoadState('networkidle');
await expect(newTab).toHaveTitle('Preview Mode');
// Both tabs are accessible
await expect(page).toHaveURL('https://app.com'); // Original still works
await newTab.close();
});What is the difference between page and context?
A `Page` is a single browser tab/window. A `BrowserContext` is the isolated session container that holds multiple pages. The context controls shared state (cookies, permissions, storage) across all its pages.
Deep Dive Explanation
Think of Context as the user session and Page as the browser tab. One session can have many tabs. This architecture is what enables Playwright's powerful multi-tab testing without the overhead of launching a new browser.
// Context -> Page relationship
const context = await browser.newContext({
baseURL: 'https://app.com',
storageState: 'auth.json', // Applied to ALL pages in this context
viewport: { width: 1280, height: 720 },
permissions: ['geolocation'],
});
const page1 = await context.newPage(); // Tab 1 - shares context cookies
const page2 = await context.newPage(); // Tab 2 - shares same session
// Cookie set in page1 is visible in page2
await page1.goto('/set-cookie');
await page2.goto('/read-cookie'); // Can read itWhy are Playwright locators considered more reliable than CSS/XPath?
Playwright's built-in locators (`getByRole`, `getByLabel`, etc.) are based on user-visible attributes and accessibility semantics rather than DOM structure. This makes them resilient to internal HTML refactoring.
Deep Dive Explanation
CSS and XPath describe WHERE an element is in the DOM. Playwright locators describe WHAT an element IS (its role and name). Developers frequently refactor HTML structure, but they rarely change what a button does or what a label says. This is why semantic locators have dramatically lower maintenance costs.
// BRITTLE: CSS selector breaks when HTML structure changes
await page.locator('div.container > div:nth-child(3) > button').click();
// BRITTLE: XPath breaks when nesting changes
await page.locator('//div[@class="modal"]//button[contains(@class,"submit")]').click();
// RESILIENT: getByRole survives any HTML restructuring
await page.getByRole('button', { name: 'Submit Order' }).click();
// RESILIENT: getByLabel survives class/id changes
await page.getByLabel('Email address').fill('user@example.com');What is the difference between locator() and querySelector()?
`locator()` is Playwright's lazy, auto-waiting, strict selector. `querySelector()` (via `page.$`) is an immediate DOM query that returns an `ElementHandle` β it does NOT auto-wait and is considered a legacy API.
Deep Dive Explanation
The Playwright team strongly recommends never using `ElementHandle` (the object returned by `page.$`). It's a snapshot of the DOM at a point in time and becomes stale. Locators always re-query the DOM on each action, making them inherently fresh.
// locator() - RECOMMENDED (lazy, retries, strict)
const btn = page.locator('button#submit');
await btn.click(); // Waits for button to be actionable
// page.$() - LEGACY (immediate, returns null if not found)
const el = await page.$('button#submit'); // Returns null if not ready yet
if (el) {
await el.click(); // NO auto-waiting
}
// page.evaluate with querySelector - for reading values only
const value = await page.evaluate(() => {
return document.querySelector('#output')?.textContent;
});How does strict mode work in Playwright selectors?
Strict mode means that if a locator matches MORE than one element and you try to perform a single-element action (like `.click()`), Playwright throws an error instead of silently acting on the first match.
Deep Dive Explanation
Strict mode is a FEATURE, not a bug. It prevents 'lucky' test passes where Playwright accidentally clicks the right button out of multiple matches. In Selenium, clicking the first match would silently pass even if it was the wrong element.
// If 3 buttons match this, .click() throws:
// Error: strict mode violation: locator('button.submit') resolved to 3 elements
await page.locator('button.submit').click(); // β Throws if multiple match
// Fix 1: Be more specific
await page.getByRole('button', { name: 'Submit Order' }).click(); // β
// Fix 2: Use .first() if order is guaranteed
await page.locator('button.submit').first().click(); // β οΈ Use sparingly
// Fix 3: Scope to parent container
await page.locator('#checkout-form').locator('button.submit').click(); // β
What happens if multiple elements match a locator?
If multiple elements match and you call a single-element action (click, fill, etc.), Playwright throws a 'strict mode violation'. Methods like `.count()`, `.all()`, and `.nth()` are designed for multi-element locators.
Deep Dive Explanation
When you EXPECT multiple elements (like a list), use `.count()` and `.all()`. When you accidentally get multiple elements (strict mode violation), it's a signal to make your locator more specific. The error message even tells you how many elements matched and their details.
// Getting count of all matches
const buttonCount = await page.getByRole('button').count();
// Iterating all matches
const allLinks = await page.getByRole('link').all();
for (const link of allLinks) {
console.log(await link.textContent());
}
// Asserting all match a condition
await expect(page.getByRole('listitem')).toHaveCount(5);
// Checking text of all matches
await expect(page.getByRole('option')).toHaveText([
'Option A', 'Option B', 'Option C'
]);When would you use getByRole() over getByText()?
Use `getByRole()` for interactive elements (buttons, links, inputs, checkboxes) because it filters hidden elements and is semantically correct. Use `getByText()` for asserting visible static content (paragraphs, headings, labels).
Deep Dive Explanation
The key rule: if a user INTERACTS with it (clicks, types), use `getByRole`. If you just want to VERIFY text is visible on screen, use `getByText`. The role-based approach also aligns with accessibility standards β if `getByRole` can't find your button, a screen reader user can't either.
// Interactive elements β getByRole (PREFERRED)
await page.getByRole('button', { name: 'Sign In' }).click();
await page.getByRole('link', { name: 'Privacy Policy' }).click();
await page.getByRole('textbox', { name: 'Email' }).fill('user@example.com');
// Static content β getByText
await expect(page.getByText('Your order has been placed')).toBeVisible();
await expect(page.getByText('Welcome, John!')).toBeVisible();
// Heading β getByRole with level
await expect(page.getByRole('heading', { name: 'Dashboard', level: 1 })).toBeVisible();What conditions does Playwright wait for before performing actions?
Before any action (click, fill, etc.), Playwright checks 5 actionability conditions: Attached (in DOM), Visible (not hidden), Stable (not animating), Receives Events (not covered by overlay), and Enabled (not disabled).
Deep Dive Explanation
The 'Receives Events' check is what catches situations where a modal overlay or cookie banner is covering a button. Playwright will wait for the overlay to disappear before clicking, eliminating the 'element intercepted' errors common in Selenium.
// Playwright checks these AUTOMATICALLY before every action:
// 1. Attached - element is in the DOM
// 2. Visible - element has non-empty bounding box, no display:none/visibility:hidden
// 3. Stable - element's position hasn't changed across 2 consecutive animation frames
// 4. Receives events - nothing is overlapping/intercepting the click point
// 5. Enabled - no 'disabled' attribute for inputs/buttons
// You can check actionability manually:
await page.locator('#submit').waitFor({ state: 'visible' });
// Or check each condition:
const isVisible = await page.locator('#btn').isVisible();
const isEnabled = await page.locator('#btn').isEnabled();
const isEditable = await page.locator('#input').isEditable();Can you disable auto-waiting? When would you?
Yes. You can bypass auto-waiting by using `page.evaluate()` to run JavaScript directly, or `locator.dispatchEvent('click')`. You'd do this to simulate programmatic actions rather than real user interactions.
Deep Dive Explanation
Only disable auto-waiting when intentionally testing edge cases like: clicking a disabled button to verify nothing happens, triggering hidden programmatic event listeners, or testing that your app correctly handles events on overlapped elements. In production test suites, disabling auto-wait is almost always a red flag.
// Force click without actionability checks (bypasses auto-wait)
await page.locator('#hidden-trigger').dispatchEvent('click');
// Use JS evaluation to bypass all checks
await page.evaluate(() => {
document.querySelector('#submit')?.click();
});
// waitFor with specific state (explicit, controlled)
await page.locator('#element').waitFor({ state: 'attached' }); // Just in DOM, not visibleWhat is the difference between waitForSelector and locator actions?
`waitForSelector` is a legacy API that returns an `ElementHandle`. Modern Playwright replaces it with `locator.waitFor()` which is integrated with the locator API and returns the locator itself for chaining.
Deep Dive Explanation
The fundamental issue with `waitForSelector` is that it returns an `ElementHandle` β a frozen reference to a DOM node. If the DOM updates between the wait and the action (e.g., list re-renders), the handle becomes stale. Locators always re-query, so they're always fresh.
// LEGACY: waitForSelector (returns ElementHandle - avoid)
const el = await page.waitForSelector('.result');
await el.click(); // ElementHandle can go stale!
// MODERN: locator.waitFor() (returns locator - preferred)
const locator = page.locator('.result');
await locator.waitFor({ state: 'visible' });
await locator.click(); // Re-queries DOM on click
// Even better: Just use Web-First assertions
await expect(page.locator('.result')).toBeVisible();
await page.locator('.result').click();How does Playwright handle flaky tests?
Playwright provides multiple strategies: automatic retries, trace capture on failure, video recording, and built-in auto-waiting that eliminates the #1 cause of flakiness (timing issues).
Deep Dive Explanation
Most flakiness comes from 3 sources: 1) Timing (fixed by auto-waiting), 2) State leakage between tests (fixed by browser context isolation), 3) Environment instability (mitigated by retries + traces). The trace viewer is especially powerful β it shows exactly which DOM state existed at the moment of failure.
// playwright.config.ts - anti-flakiness config
export default defineConfig({
retries: process.env.CI ? 2 : 0,
use: {
trace: 'on-first-retry', // Capture trace on first retry
video: 'retain-on-failure',
screenshot: 'only-on-failure',
},
});
// Detect flakiness: run each test 5 times
// npx playwright test --repeat-each=5
// Use test.fixme() to mark known flaky tests for investigation
test.fixme('known flaky - @JIRA-123', async ({ page }) => {
// Will be skipped but noted in report
});What are common synchronization pitfalls in Playwright?
The most common pitfalls are: using `waitForTimeout` (sleep), asserting with `isVisible()` instead of `toBeVisible()`, not awaiting async calls, and checking state before navigation completes.
Deep Dive Explanation
The most impactful change you can make to a flaky test suite is removing all `waitForTimeout` calls and replacing them with proper Web-First assertions. Each hard sleep is a bet on timing that will eventually lose.
// β PITFALL 1: Hard sleep
await page.waitForTimeout(3000); // Slow AND unreliable
// β
FIX: Wait for specific condition
await expect(page.locator('.data-loaded')).toBeVisible();
// β PITFALL 2: Non-retrying check as assertion
const visible = await page.locator('#result').isVisible();
expect(visible).toBe(true); // Fails if element not yet rendered
// β
FIX: Web-First assertion
await expect(page.locator('#result')).toBeVisible();
// β PITFALL 3: Missing await on navigation
page.goto('/dashboard'); // NOT awaited - race condition!
// β
FIX: Always await
await page.goto('/dashboard');
// β PITFALL 4: Acting before load
await page.goto('/app');
await page.click('#btn'); // Page may not be fully interactive
// β
FIX: Wait for network
await page.goto('/app', { waitUntil: 'networkidle' });How do you mock network requests in Playwright?
Use `page.route()` to intercept requests by URL pattern, then call `route.fulfill()` to respond with mock data, `route.continue()` to pass through, or `route.abort()` to simulate failures.
Deep Dive Explanation
Route mocking makes tests deterministic β they don't depend on backend state or availability. This is essential for testing error states, loading states, and edge cases that are hard to reproduce with real data.
test('mocked API response', async ({ page }) => {
// Intercept before navigation
await page.route('**/api/products', async route => {
await route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify([
{ id: 1, name: 'Mock Product', price: 29.99 }
]),
});
});
await page.goto('/products');
await expect(page.getByText('Mock Product')).toBeVisible();
});
// Simulate 500 error
await page.route('**/api/users', route => route.fulfill({ status: 500 }));
// Abort request (simulate network failure)
await page.route('**/api/data', route => route.abort());What is route.fulfill() vs route.continue()?
`route.fulfill()` intercepts the request and responds with YOUR mock data β the request never reaches the server. `route.continue()` lets the request proceed to the real server, optionally modifying headers or the URL.
Deep Dive Explanation
Use `fulfill()` for pure mocking (fast, no network dependency). Use `continue()` for adding auth headers or logging. The hybrid `route.fetch()` + `fulfill()` pattern is powerful for modifying real responses without fully mocking them.
// fulfill() - Replace response entirely with mock
await page.route('**/api/user', route => route.fulfill({
status: 200,
body: JSON.stringify({ name: 'Mock User', role: 'admin' }),
}));
// continue() - Pass through but modify request
await page.route('**/api/**', route => route.continue({
headers: {
...route.request().headers(),
'X-Test-Header': 'playwright-test',
'Authorization': 'Bearer test-token-123'
}
}));
// Hybrid: modify response from real server
await page.route('**/api/products', async route => {
const response = await route.fetch(); // Get real response
const data = await response.json();
data.push({ id: 999, name: 'Injected Product' }); // Mutate it
await route.fulfill({ json: data }); // Return modified response
});How can you test APIs without a UI in Playwright?
Playwright's `request` fixture provides a full `APIRequestContext` for sending HTTP requests directly without a browser. Use it in tests via `{ request }` fixture parameter.
Deep Dive Explanation
The `request` fixture in `@playwright/test` shares cookies with the browser context by default, making it perfect for authenticated API calls after a UI login. You can also create standalone `APIRequestContext` instances via `playwright.request.newContext()` for pure API tests.
import { test, expect } from '@playwright/test';
test('CRUD API tests', async ({ request }) => {
// POST - Create
const createRes = await request.post('/api/users', {
data: { name: 'Alice', role: 'tester' },
});
expect(createRes.status()).toBe(201);
const user = await createRes.json();
// GET - Read
const getRes = await request.get(`/api/users/${user.id}`);
expect(getRes.ok()).toBeTruthy();
expect((await getRes.json()).name).toBe('Alice');
// PUT - Update
await request.put(`/api/users/${user.id}`, {
data: { name: 'Alice Updated' },
});
// DELETE
const deleteRes = await request.delete(`/api/users/${user.id}`);
expect(deleteRes.status()).toBe(204);
});How do you intercept GraphQL requests?
GraphQL APIs typically use a single endpoint (e.g., `/graphql`). Intercept it with `page.route()` and inspect the request body to match the specific operation name, then fulfill with appropriate mock data.
Deep Dive Explanation
The `request.postDataJSON()` method parses the GraphQL request body automatically. Always use `route.continue()` as the fallback for operations you're not mocking, otherwise all unmatched GraphQL calls will hang.
test('GraphQL interception', async ({ page }) => {
await page.route('**/graphql', async route => {
const request = route.request();
const body = request.postDataJSON();
// Match by operation name
if (body?.operationName === 'GetUserProfile') {
await route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify({
data: {
user: { id: '1', name: 'Mock User', email: 'mock@test.com' }
}
}),
});
} else {
// Pass through all other GraphQL operations
await route.continue();
}
});
await page.goto('/profile');
await expect(page.getByText('Mock User')).toBeVisible();
});What are HAR files and how are they used in Playwright?
HAR (HTTP Archive) is a JSON format that records all network activity. Playwright can record a HAR file and later replay it, serving all recorded responses without hitting the real server.
Deep Dive Explanation
HAR-based testing is excellent for: 1) Creating offline/hermetic tests that never hit real APIs. 2) Refreshing mock data easily (just run with `update: true`). 3) Testing complex sequences of API calls without writing individual `route.fulfill()` handlers.
// STEP 1: Record HAR file
test('record har', async ({ page, context }) => {
await context.routeFromHAR('tests/fixtures/api.har', { update: true });
await page.goto('/app'); // Real network calls are recorded
// HAR file is written when context closes
});
// STEP 2: Replay HAR file in tests (offline)
test('use recorded har', async ({ page, context }) => {
await context.routeFromHAR('tests/fixtures/api.har', {
update: false, // Replay mode - no real network calls
notFound: 'abort', // Abort requests not in the HAR
});
await page.goto('/app');
// All responses come from HAR file
await expect(page.getByText('Dashboard')).toBeVisible();
});How do you reuse login sessions across tests?
Save the browser's authentication state (cookies + localStorage) to a JSON file after login, then configure tests to load that state. This skips the login UI for every test.
Deep Dive Explanation
This pattern reduces a 50-test suite from doing 50 logins to 1 login. The `dependencies` configuration ensures the setup project runs first. Add `playwright/.auth/` to `.gitignore` to avoid committing credentials.
// auth.setup.ts - Run once to save auth state
import { test as setup } from '@playwright/test';
setup('authenticate', async ({ page }) => {
await page.goto('/login');
await page.getByLabel('Email').fill('admin@example.com');
await page.getByLabel('Password').fill('secret123');
await page.getByRole('button', { name: 'Sign In' }).click();
await page.waitForURL('/dashboard');
// Save session to file
await page.context().storageState({ path: 'playwright/.auth/user.json' });
});
// playwright.config.ts - Use saved state in all tests
export default defineConfig({
projects: [
{ name: 'setup', testMatch: /auth.setup.ts/ },
{
name: 'authenticated tests',
use: { storageState: 'playwright/.auth/user.json' },
dependencies: ['setup'],
},
],
});What is storage state and how is it managed?
Storage state is a JSON snapshot of a browser context's cookies, localStorage, and sessionStorage. It allows you to pre-load authenticated sessions without going through the login UI.
Deep Dive Explanation
Store multiple auth states for different roles: `admin.json`, `editor.json`, `viewer.json`. Test role-based access control by running the same tests with different storage states. Always regenerate storage state files when tokens expire or auth mechanisms change.
// Save storage state
await context.storageState({ path: 'auth.json' });
// auth.json structure:
// {
// "cookies": [...],
// "origins": [{
// "origin": "https://app.com",
// "localStorage": [{ "name": "token", "value": "eyJhbG..." }]
// }]
// }
// Load in new context
const context = await browser.newContext({
storageState: 'auth.json'
});
// Or load inline (e.g., from CI secrets)
const context = await browser.newContext({
storageState: {
cookies: [],
origins: [{
origin: 'https://app.com',
localStorage: [{ name: 'token', value: process.env.TEST_TOKEN! }]
}]
}
});How would you test multi-user scenarios?
Create multiple browser contexts, each with different `storageState` (representing different users), and interact with them concurrently in a single test to verify real-time interactions.
Deep Dive Explanation
This pattern is unique to Playwright β no other browser automation tool supports true multi-user concurrent testing in a single test process this elegantly. It's especially powerful for testing WebSocket/real-time features, collaborative apps, and role-based workflows.
test('admin sends alert, user receives it', async ({ browser }) => {
// Create two isolated sessions
const adminContext = await browser.newContext({
storageState: 'playwright/.auth/admin.json'
});
const userContext = await browser.newContext({
storageState: 'playwright/.auth/user.json'
});
const adminPage = await adminContext.newPage();
const userPage = await userContext.newPage();
await adminPage.goto('/admin');
await userPage.goto('/dashboard');
// Admin sends notification
await adminPage.getByRole('button', { name: 'Broadcast Alert' }).click();
await adminPage.getByRole('textbox', { name: 'Message' }).fill('System maintenance in 5 mins');
await adminPage.getByRole('button', { name: 'Send' }).click();
// Verify user receives it (real-time)
await expect(userPage.getByText('System maintenance in 5 mins')).toBeVisible({ timeout: 10000 });
await adminContext.close();
await userContext.close();
});How do cookies and local storage work in Playwright contexts?
Cookies and storage are scoped to the `BrowserContext`. New contexts start clean. You can add, read, and delete cookies/storage programmatically within tests.
Deep Dive Explanation
Pre-seeding cookies via `context.addCookies()` is faster than logging in via the UI, but less realistic. The best approach is `storageState` (which combines both cookies and localStorage). Use direct cookie manipulation sparingly β only for edge cases like testing cookie expiry.
// COOKIES
// Add cookies to context
await context.addCookies([{
name: 'session_id',
value: 'abc-123',
domain: 'app.com',
path: '/',
}]);
// Read cookies
const cookies = await context.cookies();
const sessionCookie = cookies.find(c => c.name === 'session_id');
// Clear all cookies
await context.clearCookies();
// LOCAL STORAGE (via evaluate)
await page.evaluate(() => {
localStorage.setItem('theme', 'dark');
localStorage.setItem('lang', 'en');
});
const theme = await page.evaluate(() => localStorage.getItem('theme'));
console.log(theme); // 'dark'What are the risks of shared authentication state?
Sharing auth state between tests creates hidden dependencies. If one test modifies user data (profile, settings, permissions), subsequent tests may fail due to unexpected state changes.
Deep Dive Explanation
The safest approach is to never modify persistent state in tests that share auth. For tests that must modify data, use API calls to set up AND tear down that data, or use unique data per test run (e.g., generate a unique email for each test user).
// β RISKY: Shared auth state with side effects
test('update profile', async ({ page }) => {
// Uses shared auth.json
await page.goto('/profile');
await page.getByLabel('Name').fill('Changed Name'); // Modifies shared state!
await page.getByRole('button', { name: 'Save' }).click();
});
test('check profile name', async ({ page }) => {
// May fail if 'update profile' ran first and changed the name
await expect(page.getByLabel('Name')).toHaveValue('Original Name'); // β
});
// β
SAFER: Use API to reset state before/after
test.afterEach(async ({ request }) => {
await request.put('/api/profile', { data: { name: 'Original Name' } });
});How does Playwright run tests in parallel?
Playwright spawns multiple worker processes that run tests simultaneously. Each worker operates independently with its own browser, browser context, and page β guaranteeing complete isolation.
Deep Dive Explanation
Worker count impacts resource usage. A good rule: set workers equal to the number of CPU cores available. In Docker-based CI, over-provisioning workers causes memory pressure and paradoxically slows down execution.
// playwright.config.ts
export default defineConfig({
// Run ALL tests across files in parallel (no ordering)
fullyParallel: true,
// Control number of workers
workers: process.env.CI ? 4 : '50%', // 4 in CI, half CPU cores locally
// Override per describe block
});
// In test file - force parallel within a file
test.describe.configure({ mode: 'parallel' });
// Run with specific workers via CLI
// npx playwright test --workers=4
// Sharding for CI (split across multiple machines)
// Machine 1: npx playwright test --shard=1/3
// Machine 2: npx playwright test --shard=2/3
// Machine 3: npx playwright test --shard=3/3What is the role of workers in Playwright Test?
Workers are isolated Node.js processes. Each worker handles one test at a time, running it in its own browser instance. Workers are created up to the configured maximum and reused across tests.
Deep Dive Explanation
When a worker finishes one test, it picks up the next available test from the queue. Playwright reuses workers (and their browser instances) across tests within the same project for efficiency. However, each new test gets a fresh browser CONTEXT β ensuring complete state isolation. The browser process itself is reused, saving the overhead of browser launch.
How do you control test execution order?
By default, tests within a file run sequentially in the order they are defined. Tests across files run in parallel. You can use `test.describe.serial()` to enforce serial execution within a group.
Deep Dive Explanation
Avoid relying on test ordering in general β it creates fragile, order-dependent suites. The exception is true workflows where each step depends on the previous (e.g., a checkout flow). In those cases, `test.describe.serial()` is the right tool, and Playwright will intelligently skip subsequent steps if an earlier one fails.
// Tests within this describe run in order, one at a time
test.describe.serial('E-commerce checkout flow', () => {
test('1. Add item to cart', async ({ page }) => { /* ... */ });
test('2. Proceed to checkout', async ({ page }) => { /* ... */ });
test('3. Enter payment details', async ({ page }) => { /* ... */ });
test('4. Confirm order', async ({ page }) => { /* ... */ });
});
// If test 2 fails, tests 3 and 4 are skipped automatically
// Run a specific test file only
// npx playwright test tests/checkout.spec.ts
// Run tests matching a grep pattern
// npx playwright test --grep="checkout"What are the trade-offs of parallel execution?
Parallel execution is faster but requires tests to be fully independent (no shared state). It uses more resources (CPU/RAM) and can cause flakiness if tests compete for the same test data.
Deep Dive Explanation
The biggest challenge with parallel tests is database contention β if two parallel tests try to create/modify the same database record, one will fail. Strategies: 1) Use unique identifiers per test, 2) Use separate test databases per worker, 3) Mock the database/API layer entirely.
// Trade-offs table (conceptual):
// β
PROS: β CONS:
// Faster execution More CPU/RAM usage
// Scales with hardware Tests must be stateless
// Finds ordering bugs DB conflicts if tests share data
// CI time reduction Harder to debug parallel failures
// SOLUTION: Use unique test data per worker
test('create unique user', async ({ page }, testInfo) => {
const uniqueEmail = `user-${testInfo.workerIndex}-${Date.now()}@test.com`;
await page.getByLabel('Email').fill(uniqueEmail);
// Each parallel worker uses a different email - no conflicts
});How do you debug performance bottlenecks?
Use Playwright Trace Viewer to analyze network waterfall charts, identify slow API calls, and see action timings. The `page.metrics()` API can capture Chrome performance metrics programmatically.
Deep Dive Explanation
The Trace Viewer's timeline view shows each network request, its duration, and when it was triggered relative to user actions. This makes it easy to spot slow API calls, unoptimized waterfalls, and render-blocking resources.
// Capture performance metrics
test('check page performance', async ({ page }) => {
await page.goto('/dashboard', { waitUntil: 'networkidle' });
const metrics = await page.evaluate(() =>
JSON.stringify(window.performance.getEntriesByType('navigation'))
);
const [navEntry] = JSON.parse(metrics);
console.log('DOMContentLoaded:', navEntry.domContentLoadedEventEnd);
console.log('Load time:', navEntry.loadEventEnd);
// Assert performance budget
expect(navEntry.loadEventEnd).toBeLessThan(3000); // Must load under 3s
});
// Enable tracing for network analysis
await context.tracing.start({ screenshots: true, snapshots: true });
await page.goto('/heavy-page');
await context.tracing.stop({ path: 'perf-trace.zip' });
// Open: npx playwright show-trace perf-trace.zipWhat is Playwright Trace Viewer and how does it help?
Trace Viewer is Playwright's built-in debugging GUI. It provides a full timeline of test actions with: DOM snapshots at every step, network log, console output, and screenshots β allowing you to 'time-travel' through a test failure.
Deep Dive Explanation
The Trace Viewer shows: 1) Action log (every click, fill, navigation), 2) DOM snapshot before and after each action, 3) Network requests and responses, 4) Console messages, 5) Source code at the point of each action. This makes CI debugging feasible without needing to reproduce failures locally.
// Enable tracing in config
export default defineConfig({
use: { trace: 'on-first-retry' }
});
// After a failure, view the trace:
// npx playwright show-trace test-results/trace.zip
// Manually start/stop tracing
await context.tracing.start({ screenshots: true, snapshots: true, sources: true });
// ... run test steps
await context.tracing.stop({ path: './trace.zip' });How do you debug a failed test in CI?
The key tools are: (1) Trace files (CI artifact), (2) Screenshots on failure, (3) Videos, (4) Console log capture, and (5) Detailed HTML reports. Configure all of these in `playwright.config.ts` for CI environments.
Deep Dive Explanation
The golden rule of CI debugging: if you can't reproduce it locally, the trace file is your only window into what happened. Always upload `playwright-report/` and `test-results/` as CI artifacts with at least 30 days retention.
// playwright.config.ts - CI debug configuration
export default defineConfig({
retries: process.env.CI ? 2 : 0,
reporter: process.env.CI ? 'github' : 'html',
use: {
trace: 'on-first-retry',
screenshot: 'only-on-failure',
video: 'retain-on-failure',
},
});
// GitHub Actions - upload artifacts
// .github/workflows/playwright.yml:
// - name: Upload test results
// if: always()
// uses: actions/upload-artifact@v3
// with:
// name: playwright-report
// path: playwright-report/
// retention-days: 30
// Capture console errors in test
test('capture console', async ({ page }) => {
const errors: string[] = [];
page.on('console', msg => {
if (msg.type() === 'error') errors.push(msg.text());
});
await page.goto('/app');
expect(errors).toHaveLength(0);
});What is the use of page.pause()?
`page.pause()` stops test execution at that point and opens the Playwright Inspector, letting you inspect the page, run Playwright commands interactively, and continue execution manually.
Deep Dive Explanation
`page.pause()` only works in headed mode (not in CI). It's the equivalent of a browser breakpoint for Playwright tests. Use it temporarily during development, then remove it before committing. The Inspector also lets you hover over elements to see their resolved locators.
test('debug with pause', async ({ page }) => {
await page.goto('/checkout');
await page.getByLabel('Card Number').fill('4111111111111111');
await page.pause(); // βΈοΈ Test stops here, Inspector opens
// After you click 'Resume' in Inspector, execution continues
await page.getByRole('button', { name: 'Pay Now' }).click();
});
// Run in headed mode for pause to work
// npx playwright test --headed
// Or use PWDEBUG env var for full debug mode
// PWDEBUG=1 npx playwright testHow do screenshots and videos work in Playwright?
Playwright supports manual screenshots via `page.screenshot()` and automatic screenshots via configuration. Videos are recorded at the browser context level and finalized when the context closes.
Deep Dive Explanation
Visual regression with `toHaveScreenshot()` creates a baseline image on first run (update with `--update-snapshots`). Subsequent runs compare against the baseline. This catches unintended CSS changes that functional tests miss.
// Manual screenshot
await page.screenshot({ path: 'homepage.png' });
await page.screenshot({ path: 'full-page.png', fullPage: true });
// Element screenshot
await page.locator('.chart-widget').screenshot({ path: 'chart.png' });
// Config-based automatic screenshots
export default defineConfig({
use: {
screenshot: 'only-on-failure', // or 'on', 'off'
video: 'retain-on-failure', // or 'on', 'off', 'on-first-retry'
},
});
// Visual regression (compare to baseline)
await expect(page).toHaveScreenshot('dashboard.png', {
maxDiffPixels: 100, // Allow 100 pixels to differ
threshold: 0.1, // 10% pixel color difference tolerance
});How can you capture console logs?
Listen to the `console` event on the `page` object to capture all browser console output, including logs, warnings, and errors.
Deep Dive Explanation
Capturing console errors is a powerful zero-cost quality check. Many real bugs (failed API calls, JavaScript errors) show up as console errors but don't visually break the UI. Adding a console error assertion to every test catches these silent failures automatically.
test('capture all console output', async ({ page }) => {
const logs: string[] = [];
const errors: string[] = [];
// Listen BEFORE navigating
page.on('console', msg => {
if (msg.type() === 'error') {
errors.push(`ERROR: ${msg.text()}`);
} else {
logs.push(`[${msg.type()}] ${msg.text()}`);
}
});
page.on('pageerror', err => {
errors.push(`Uncaught: ${err.message}`);
});
await page.goto('/app');
await page.getByRole('button', { name: 'Load Data' }).click();
// Assert no console errors
expect(errors).toEqual([]);
console.log('Console output:', logs.join('\n'));
});How do you handle file uploads and downloads?
For uploads, use `page.setInputFiles()` on the file input element. For downloads, listen for the `download` event with `Promise.all()` before triggering the download action.
Deep Dive Explanation
The `Promise.all()` pattern for both file chooser and download is critical β register the event listener before triggering the action to avoid race conditions. The `setInputFiles()` approach bypasses the OS file picker dialog entirely, making it reliable in CI.
// FILE UPLOAD - Standard input
await page.locator('input[type="file"]').setInputFiles('tests/fixtures/resume.pdf');
// Multiple files
await page.locator('input[type="file"]').setInputFiles([
'tests/fixtures/doc1.pdf',
'tests/fixtures/doc2.pdf',
]);
// Upload via file chooser dialog (for custom upload buttons)
const [fileChooser] = await Promise.all([
page.waitForEvent('filechooser'),
page.getByRole('button', { name: 'Upload File' }).click(),
]);
await fileChooser.setFiles('tests/fixtures/resume.pdf');
// FILE DOWNLOAD
const [download] = await Promise.all([
page.waitForEvent('download'),
page.getByRole('button', { name: 'Export CSV' }).click(),
]);
const path = await download.path();
await download.saveAs('./downloads/' + download.suggestedFilename());
console.log('Downloaded:', download.suggestedFilename());How does Playwright support iframe interactions?
Use `page.frameLocator('selector')` to scope locators inside an iframe. This creates a persistent tunnel to the frame and supports all standard Playwright locators within that scope.
Deep Dive Explanation
The `frameLocator` approach is superior to the old `page.frames()` API because it's lazy (re-queries on each action) and works with Playwright's auto-waiting. The old `frame.waitForSelector()` approach is legacy. Always prefer `frameLocator` for new tests.
// Basic iframe interaction
const frame = page.frameLocator('iframe#payment-frame');
await frame.getByLabel('Card Number').fill('4111111111111111');
await frame.getByLabel('Expiry').fill('12/26');
await frame.getByLabel('CVC').fill('123');
await frame.getByRole('button', { name: 'Pay' }).click();
// Nested iframes
const innerFrame = page
.frameLocator('iframe#outer')
.frameLocator('iframe#inner');
await innerFrame.getByRole('textbox').fill('test');
// Access frame by URL
const frame2 = page.frame({ url: /payment-gateway/ });
if (frame2) {
await frame2.fill('#card', '4111111111111111');
}What is the difference between frame and frameLocator?
`frameLocator` is a Playwright locator that scopes element queries inside an iframe β it's lazy and re-queries on every action. `frame` (via `page.frame()`) returns a `Frame` object β an eager reference to an iframe that can go stale.
Deep Dive Explanation
The key difference: `frameLocator` is a locator β it participates in auto-waiting and strict mode. `frame` is an eager handle that becomes invalid if the iframe navigates or is replaced. For modern tests, always use `frameLocator`.
// frameLocator - PREFERRED (lazy, works with auto-waiting)
const frameLocator = page.frameLocator('iframe#chat');
await frameLocator.getByRole('textbox').fill('Hello');
// frame() - LEGACY (eager, can be stale)
const frame = page.frame('chat-frame'); // by name attribute
if (frame) {
await frame.fill('#message-input', 'Hello'); // Can fail if frame reloads
}
// frame by URL pattern
const paymentFrame = page.frame({ url: /payment/ });
// Get all frames
const frames = page.frames();
frames.forEach(f => console.log(f.url()));How do you test drag-and-drop functionality?
Use `locator.dragTo(target)` for standard HTML5 drag-and-drop. For complex custom drag implementations (using mouse events), use the lower-level `page.mouse` API.
Deep Dive Explanation
The `steps` parameter in `mouse.move()` is crucial for custom DnD β it breaks the move into multiple intermediate steps, simulating a real mouse drag. Without steps, some drag handlers may not trigger properly as they expect incremental position events.
// Standard HTML5 drag-and-drop
await page.locator('#source-item').dragTo(page.locator('#target-zone'));
// With position offsets for precise targeting
await page.locator('#card').dragTo(page.locator('#column-done'), {
sourcePosition: { x: 10, y: 10 },
targetPosition: { x: 100, y: 50 },
});
// Custom drag using mouse API (for non-HTML5 DnD)
const source = page.locator('#draggable');
const target = page.locator('#droppable');
const sourceBox = await source.boundingBox();
const targetBox = await target.boundingBox();
await page.mouse.move(sourceBox!.x + 5, sourceBox!.y + 5);
await page.mouse.down();
await page.mouse.move(targetBox!.x + 5, targetBox!.y + 5, { steps: 10 });
await page.mouse.up();
// Assert result
await expect(page.locator('#droppable')).toContainText('Dropped!');How do you handle browser permissions (geolocation, camera)?
Grant permissions at the `BrowserContext` level before the test navigates. This affects all pages within that context.
Deep Dive Explanation
Browser permissions are a common source of test hangs in CI β if a test expects a permission dialog that never appears (because CI doesn't support it), the test stalls. Always pre-grant required permissions in the context configuration.
// Grant permissions in config (applies globally)
export default defineConfig({
use: {
permissions: ['geolocation', 'notifications'],
geolocation: { latitude: 40.7128, longitude: -74.0060 }, // New York
},
});
// Or per context in a test
test('geolocation test', async ({ browser }) => {
const context = await browser.newContext({
permissions: ['geolocation'],
geolocation: { latitude: 51.5074, longitude: -0.1278 }, // London
});
const page = await context.newPage();
await page.goto('/store-finder');
await expect(page.getByText('London Stores')).toBeVisible();
await context.close();
});
// Grant permission at runtime
await context.grantPermissions(['camera', 'microphone']);
// Revoke permissions
await context.clearPermissions();How do you integrate Playwright with CI/CD pipelines?
Playwright has official GitHub Actions support and works with Jenkins, GitLab CI, CircleCI, and Azure DevOps. The key is installing browsers, running in headless mode, and uploading reports as artifacts.
Deep Dive Explanation
Key CI considerations: 1) Use `npx playwright install --with-deps` to install both browsers AND OS-level dependencies. 2) Always run `if: always()` on artifact upload so reports are available even after failure. 3) Use CI-specific reporter (`reporter: 'github'`) for inline GitHub PR annotations.
# .github/workflows/playwright.yml
name: Playwright Tests
on: [push, pull_request]
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with: { node-version: 20 }
- name: Install dependencies
run: npm ci
- name: Install Playwright browsers
run: npx playwright install --with-deps
- name: Run Playwright tests
run: npx playwright test
env:
BASE_URL: ${{ secrets.STAGING_URL }}
TEST_USER: ${{ secrets.TEST_USER }}
- name: Upload report
if: always()
uses: actions/upload-artifact@v4
with:
name: playwright-report
path: playwright-report/
retention-days: 30What reports can Playwright generate?
Playwright has multiple built-in reporters: HTML (interactive), JSON (for integrations), JUnit (for CI systems like Jenkins), GitHub (PR annotations), and Dot/List (CLI). You can configure multiple simultaneously.
Deep Dive Explanation
The HTML report is the most valuable for development β it shows a searchable list of all tests with pass/fail status, attached screenshots, videos, and traces. The JUnit XML format integrates with Jenkins and most CI dashboards to display test trends over time.
// playwright.config.ts
export default defineConfig({
reporter: [
['html', { outputFolder: 'playwright-report', open: 'never' }],
['json', { outputFile: 'test-results/results.json' }],
['junit', { outputFile: 'test-results/junit.xml' }],
['github'], // GitHub Actions annotations
['list'], // Live CLI output
],
});
// View HTML report
// npx playwright show-report
// Custom reporter (advanced)
class MyReporter implements Reporter {
onTestEnd(test, result) {
if (result.status === 'failed') {
// Send Slack notification, create Jira ticket, etc.
}
}
}How do you manage environment-specific configs?
Use environment variables combined with `playwright.config.ts` to configure base URLs, credentials, and settings per environment (local, staging, production).
Deep Dive Explanation
Never hardcode URLs or credentials in test files. Always use environment variables. Add `.env` files to `.gitignore` and store secrets in CI secret managers (GitHub Secrets, AWS SSM, etc.). The `baseURL` config option enables using relative URLs like `page.goto('/dashboard')` instead of absolute URLs in tests.
// playwright.config.ts
export default defineConfig({
use: {
baseURL: process.env.BASE_URL || 'http://localhost:3000',
extraHTTPHeaders: {
'X-API-Key': process.env.API_KEY || '',
},
},
});
// .env.staging
// BASE_URL=https://staging.myapp.com
// API_KEY=staging-key-xyz
// .env.production
// BASE_URL=https://myapp.com
// API_KEY=prod-key-xyz
// Run with specific env:
// BASE_URL=https://staging.myapp.com npx playwright test
// Use dotenv for .env files
// npm install dotenv
// require('dotenv').config({ path: `.env.${process.env.NODE_ENV}` });How do you run tests across multiple browsers?
Define 'projects' in `playwright.config.ts`, each targeting a different browser or device. A single `npx playwright test` command will run all tests across all configured projects.
Deep Dive Explanation
Playwright includes 50+ pre-configured device descriptors (via `devices`) that set the correct viewport, user-agent, and deviceScaleFactor. Chromium covers Chrome and Edge, WebKit covers Safari (including iOS Safari emulation), and Firefox covers Firefox.
import { defineConfig, devices } from '@playwright/test';
export default defineConfig({
projects: [
// Desktop browsers
{ name: 'Chrome', use: { ...devices['Desktop Chrome'] } },
{ name: 'Firefox', use: { ...devices['Desktop Firefox'] } },
{ name: 'Safari', use: { ...devices['Desktop Safari'] } },
{ name: 'Edge', use: { ...devices['Desktop Edge'] } },
// Mobile devices (emulation)
{ name: 'Mobile Chrome', use: { ...devices['Pixel 5'] } },
{ name: 'Mobile Safari', use: { ...devices['iPhone 13'] } },
// Tablet
{ name: 'iPad', use: { ...devices['iPad (gen 7)'] } },
],
});
// Run only a specific project
// npx playwright test --project=Chrome
// npx playwright test --project="Mobile Safari"How do you handle secrets in Playwright tests?
Never hardcode secrets in test files. Use environment variables, CI secret managers, or Playwright's `storageState` to handle credentials securely.
Deep Dive Explanation
Add `playwright/.auth/*.json` to `.gitignore` since storage state files contain real session tokens. In GitHub Actions, add secrets via Settings > Secrets and reference them as `${{ secrets.TEST_PASSWORD }}`. For local dev, use a `.env` file loaded with `dotenv`.
// β NEVER do this
const password = 'SuperSecret123'; // Committed to git!
// β
Use environment variables
const password = process.env.TEST_PASSWORD;
// β
Access in playwright.config.ts
export default defineConfig({
use: {
extraHTTPHeaders: {
'Authorization': `Bearer ${process.env.AUTH_TOKEN}`,
},
},
});
// β
In tests
test('login with secure credentials', async ({ page }) => {
await page.getByLabel('Password').fill(process.env.TEST_PASSWORD!);
});
// .gitignore
// .env
// playwright/.auth/
// test-results/What happens if a test closes a browser context prematurely?
If a test closes the browser context before Playwright's cleanup runs, video and trace files won't be finalized, storage state can't be saved, and subsequent test isolation may break.
Deep Dive Explanation
Playwright Test's fixture system manages context lifecycle correctly. When you manually manage contexts, you're responsible for proper cleanup in all code paths (success and failure). The `try/finally` pattern ensures `context.close()` always runs.
// β Closing context manually can cause issues
test('broken context close', async ({ browser }) => {
const context = await browser.newContext();
const page = await context.newPage();
await page.goto('/');
await context.close(); // Video/trace not finalized if tracing was active!
});
// β
Let Playwright manage the context lifecycle (use fixtures)
test('proper way', async ({ page }) => {
// Playwright creates and destroys the context for you
await page.goto('/');
// Context is closed properly after test + teardown
});
// If you must manage context manually, use try/finally
test('manual context with cleanup', async ({ browser }) => {
const context = await browser.newContext();
try {
const page = await context.newPage();
await page.goto('/');
} finally {
await context.close(); // Always closes, even on failure
}
});Can Playwright tests run without a browser? Explain.
Yes β when using Playwright's `request` fixture for pure API testing, no browser is launched. The `APIRequestContext` sends HTTP requests directly from Node.js without any browser overhead.
Deep Dive Explanation
API-only tests execute much faster (no browser launch overhead) and are perfect for smoke testing backend services, validating authentication flows, and setting up/tearing down test data. Running Playwright API tests alongside UI tests in the same framework ensures consistent tooling and reporting.
import { test, expect } from '@playwright/test';
// This test runs WITHOUT a browser
test('pure API test - no browser needed', async ({ request }) => {
const response = await request.get('https://api.github.com/repos/microsoft/playwright');
expect(response.ok()).toBeTruthy();
const data = await response.json();
expect(data.name).toBe('playwright');
expect(data.stargazers_count).toBeGreaterThan(50000);
});
// Create standalone API context (outside test runner)
import { request } from '@playwright/test';
const apiContext = await request.newContext({
baseURL: 'https://api.example.com',
extraHTTPHeaders: { 'Authorization': 'Bearer token' },
});
const res = await apiContext.get('/users');How does Playwright ensure test isolation?
Playwright ensures isolation through two mechanisms: (1) Each test gets a fresh `BrowserContext` (no shared cookies/storage), and (2) Workers are separate OS processes (no shared memory).
Deep Dive Explanation
True isolation means: test A cannot affect test B's browser state. Playwright achieves this via BrowserContexts (cheap isolated sessions) automatically provisioned by the `context` and `page` fixtures. This is why Playwright tests are safe to run in parallel without cross-contamination.
// Playwright Test automatically provides isolated fixtures:
test('test A', async ({ page, context }) => {
await context.addCookies([{ name: 'pref', value: 'dark', domain: 'app.com' }]);
await page.goto('/settings');
// Only this test sees the 'dark' cookie
});
test('test B', async ({ page }) => {
const cookies = await page.context().cookies();
// cookies is EMPTY - completely isolated from test A
expect(cookies).toHaveLength(0);
});
// Global setup/teardown for DB seeding (runs once)
// playwright.config.ts
export default defineConfig({
globalSetup: './global-setup.ts',
globalTeardown: './global-teardown.ts',
});What are key locators Playwright supports?
Playwright provides 8 built-in semantic locators, plus `locator()` for CSS/XPath. The priority order is: getByRole > getByLabel > getByPlaceholder > getByText > getByAltText > getByTitle > getByTestId > locator(CSS).
Deep Dive Explanation
The priority order isn't arbitrary β it aligns with how users and assistive technologies interact with the page. The higher in priority, the more resilient to HTML refactoring and the better the accessibility signal. Only use CSS/XPath when semantic locators are truly insufficient.
// Priority 1: Best - Role-based (accessibility)
page.getByRole('button', { name: 'Submit' })
// Priority 2: Form labels
page.getByLabel('Email Address')
// Priority 3: Input placeholders
page.getByPlaceholder('Search...')
// Priority 4: Text content
page.getByText('Welcome back!')
// Priority 5: Image alt text
page.getByAltText('Company Logo')
// Priority 6: Title attribute
page.getByTitle('Close dialog')
// Priority 7: Test ID (explicit contract)
page.getByTestId('submit-btn') // matches data-testid="submit-btn"
// Last resort: CSS / XPath
page.locator('button.primary')
page.locator('//button[@data-action="submit"]')What is a 'strict mode violation,' and how do you resolve it?
A strict mode violation occurs when a locator matches more than one element and a single-element action is attempted. Playwright throws an error listing all matched elements to help you diagnose and fix the selector.
Deep Dive Explanation
The error message Playwright provides is intentionally verbose β it lists ALL matched elements with their HTML. Use this output to understand WHY your locator is ambiguous, then choose the fix that adds the least fragility. Adding a `data-testid` is the nuclear option β reserve it for cases where semantic differentiation is genuinely impossible.
// ERROR: strict mode violation: locator('a.btn') resolved to 4 elements:
// 1. <a class="btn" href="/login">Login</a>
// 2. <a class="btn" href="/register">Register</a>
// 3. <a class="btn" href="/forgot">Forgot Password</a>
// 4. <a class="btn" href="/help">Help</a>
// β Ambiguous locator
await page.locator('a.btn').click(); // Throws!
// β
Fix 1: Add semantic specificity
await page.getByRole('link', { name: 'Login' }).click();
// β
Fix 2: Scope to parent
await page.locator('header').getByRole('link', { name: 'Login' }).click();
// β
Fix 3: Filter
await page.locator('a.btn').filter({ hasText: 'Login' }).click();
// β
Fix 4: Add data-testid in app code (for truly ambiguous cases)
// <a class="btn" data-testid="login-btn" href="/login">Login</a>
await page.getByTestId('login-btn').click();Does Playwright support headless mode? Why is it useful?
Yes, Playwright runs headless by default. In headless mode, the browser runs as a background process with no visible UI. This makes tests faster, requires no display server, and is the standard mode for CI/CD pipelines.
Deep Dive Explanation
Headless mode is 30-60% faster than headed mode because the browser skips painting, layout, and rendering to screen. It also works in server environments with no display (Docker containers, Linux VMs in CI). However, some CSS animations, canvas rendering, and WebGL features may behave slightly differently in headless β always validate critical visual tests in headed mode too.
// Headless is the DEFAULT - no config needed for CI
// npx playwright test β runs headless automatically
// Explicitly set headless mode
export default defineConfig({
use: { headless: true }, // Default
});
// Switch to headed (for local debugging)
// npx playwright test --headed
// Or in config for local dev
export default defineConfig({
use: {
headless: !process.env.HEADED, // Headed when HEADED=1 env var is set
},
});
// Chromium-specific: CDP headless (new headless)
export default defineConfig({
use: {
channel: 'chrome',
headless: true, // Uses 'new' headless mode in Chrome
},
});Also preparing for Selenium Interviews?
Explore our comprehensive 100 Selenium Scenario-Based Interview Questions featuring Selenium 4 + Java code snippets.
Finished practicing?
Head back to the main lobby to explore more interview prep tracks and dashboard tools.