πŸ’‘ If you like this website, please share it with your friends and network! πŸš€
Back to 100 Questions
Framework Level
Senior / Lead
2026 Edition

Most Asked Playwright Framework-Level Interview Questions

πŸ”₯ Most Asked Question in SDET Interview

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!

🧠
Question 1 of 10

How do you design a scalable Playwright framework?

Folder structure?Where do you keep test data?How do you separate UI, API, and utilities?How do you handle environment configs?How do you support parallel execution?

A scalable Playwright framework follows a clear separation of concerns. Here's the recommended folder structure:

TypeScript
my-playwright-framework/
β”œβ”€β”€ tests/
β”‚   β”œβ”€β”€ ui/            # UI end-to-end tests
β”‚   β”œβ”€β”€ api/           # API-only tests
β”‚   └── integration/   # UI + API hybrid tests
β”œβ”€β”€ pages/             # Page Object Models
β”œβ”€β”€ fixtures/          # Custom Playwright fixtures
β”œβ”€β”€ helpers/           # Reusable utilities (dates, strings)
β”œβ”€β”€ data/              # Test data (JSON, factory functions)
β”œβ”€β”€ config/            # Environment configs
β”‚   β”œβ”€β”€ dev.env
β”‚   β”œβ”€β”€ staging.env
β”‚   └── prod.env
β”œβ”€β”€ playwright.config.ts
└── package.json

// playwright.config.ts β€” environment-aware config
import { defineConfig } from '@playwright/test';

const ENV = process.env.TEST_ENV || 'dev';
const config = require(`./config/${ENV}.env.json`);

export default defineConfig({
  fullyParallel: true,
  workers: process.env.CI ? 4 : 2,
  use: {
    baseURL: config.BASE_URL,
    trace: 'on-first-retry',
    screenshot: 'only-on-failure',
  },
});

πŸ’‘ Never hardcode URLs or credentials. Use environment-specific config files loaded at runtime. Keep Page Objects as action-only wrappers β€” no assertions inside them.

🚩

If you can't explain architecture clearly β†’ red flag.

⚑
Question 2 of 10

How do you manage browser lifecycle?

One browser per test?One browser per suite?Context per scenario?How do you prevent session leakage?

Playwright creates one Browser per worker (expensive), one BrowserContext per test (isolated), and one or more Pages per context (tabs). Never share context between tests β€” it causes state leakage.

TypeScript
// βœ… Correct: Playwright does this automatically per test
test('isolated test', async ({ page, context }) => {
  // context = fresh BrowserContext (no cookies from other tests)
  // page    = fresh Page within that context
  await page.goto('/dashboard');
});

// βœ… Worker-scoped browser (shared for performance)
// Context-scoped setup (isolated per test)
const test = base.extend<{}, { sharedBrowser: Browser }>({
  sharedBrowser: [async ({ playwright }, use) => {
    const browser = await playwright.chromium.launch();
    await use(browser);
    await browser.close();
  }, { scope: 'worker' }],
});

// ❌ WRONG β€” sharing context across tests causes flakiness
let sharedContext: BrowserContext;
test.beforeAll(async ({ browser }) => {
  sharedContext = await browser.newContext(); // leaks cookies/state!
});

πŸ’‘ Rule: 1 Browser per worker (shared). 1 BrowserContext per test (isolated). 1+ Pages per context. This gives you maximum speed with zero state leakage.

🚩

Understanding browser β†’ context β†’ page hierarchy is critical.

🎯
Question 3 of 10

How do you handle flaky tests?

Do you use retries?Do you analyze root cause?How do you handle dynamic loaders?When to use Promise.all()?

Flaky tests are caused by timing issues, shared state, or network variability. Playwright's auto-wait eliminates most timing issues, but you must still understand async patterns.

TypeScript
// playwright.config.ts β€” retries for CI stability
export default defineConfig({
  retries: process.env.CI ? 2 : 0, // retry twice in CI only
});

// βœ… Wait for a specific condition β€” not for time
await page.locator('.spinner').waitFor({ state: 'hidden' }); // wait for loader to disappear
await expect(page.locator('.results')).toHaveCount(10);       // wait for data to load

// βœ… Promise.all β€” prevents race conditions with tabs/dialogs
const [popup] = await Promise.all([
  page.waitForEvent('popup'),
  page.getByRole('button', { name: 'Open' }).click(), // triggers popup
]);

// βœ… Wait for network response before asserting
await Promise.all([
  page.waitForResponse(r => r.url().includes('/api/users') && r.status() === 200),
  page.getByRole('button', { name: 'Load Users' }).click(),
]);

// ❌ NEVER do this
await page.waitForTimeout(3000); // hard sleep = flaky test

πŸ’‘ Root-cause analysis checklist: 1) Is the element actually present? 2) Is there a race condition? 3) Is the test sharing state? 4) Is the backend slow? Fix the root cause, don't just add retries.

🚩

Auto-wait is powerful, but only if you understand async behavior.

πŸ“Š
Question 4 of 10

How do you generate reports with screenshots & videos?

HTML reportTrace ViewerVideo recordingAttachments in CI

A good reporting strategy means a developer can diagnose a failure entirely from the CI artifact β€” no local rerun needed.

TypeScript
// playwright.config.ts β€” full reporting setup
export default defineConfig({
  reporter: [
    ['html', { open: 'never' }],      // HTML report for humans
    ['junit', { outputFile: 'results.xml' }], // For Jenkins/CI
    ['github'],                         // GitHub Actions annotations
  ],
  use: {
    screenshot: 'only-on-failure',      // Screenshot on fail
    video: 'retain-on-failure',         // Video on fail
    trace: 'on-first-retry',            // Trace on retry
  },
});

// GitHub Actions β€” upload reports as artifacts
// .github/workflows/playwright.yml
// - uses: actions/upload-artifact@v3
//   if: always()
//   with:
//     name: playwright-report
//     path: playwright-report/
//     retention-days: 30

// Custom attachment in test (e.g., API response body)
test('attach API response', async ({ page }, testInfo) => {
  const response = await page.request.get('/api/users');
  await testInfo.attach('api-response', {
    body: await response.text(),
    contentType: 'application/json',
  });
});

πŸ’‘ The Trace Viewer (`npx playwright show-trace trace.zip`) shows DOM snapshots, network requests, console logs, and action timings β€” everything you need to debug without rerunning.

🚩

Can you debug the failure without rerunning locally?

🏷️
Question 5 of 10

How do you manage test tagging strategy?

@smoke@regression@api@criticalCan you run selective suites in pipeline?

Tags let you run targeted subsets of your suite in CI pipelines β€” smoke tests on every PR, full regression nightly.

TypeScript
// Tag tests using test.describe or --grep CLI flag

// Option 1: In test title (simple)
test('@smoke @critical login works', async ({ page }) => { /*...*/ });
test('@regression checkout flow', async ({ page }) => { /*...*/ });

// Option 2: Using tags array (Playwright v1.42+, recommended)
test('login works', {
  tag: ['@smoke', '@critical'],
}, async ({ page }) => { /*...*/ });

// Option 3: In describe block
test.describe('@api', () => {
  test('create user', async ({ request }) => { /*...*/ });
  test('delete user', async ({ request }) => { /*...*/ });
});

// Run selective tags via CLI:
// npx playwright test --grep @smoke          β†’ smoke only
// npx playwright test --grep @critical       β†’ critical only
// npx playwright test --grep-invert @slow    β†’ skip slow tests

// GitHub Actions: run smoke on PR, regression on schedule
// on: pull_request β†’ npx playwright test --grep @smoke
// on: schedule     β†’ npx playwright test (all tests)

πŸ’‘ Recommended tag taxonomy: @smoke (5–10 min, every PR), @regression (full suite, nightly), @api (API-only, fastest), @critical (must-never-fail), @slow (deprioritised in pipeline).

πŸ”
Question 6 of 10

How do you handle authentication efficiently?

Login once?Use storageState?Handle OTP?Role-based testing?

Log in once via API, save the session as storageState, and reuse it across all tests. Each test gets a pre-authenticated browser context in milliseconds.

TypeScript
// global-setup.ts β€” login ONCE before the entire suite
import { chromium } from '@playwright/test';

export default async function globalSetup() {
  const browser = await chromium.launch();
  const page = await browser.newPage();

  // Login via API (fastest β€” no UI interaction)
  const response = await page.request.post('/api/auth/login', {
    data: { email: 'admin@test.com', password: process.env.ADMIN_PASS }
  });
  // Save cookies + localStorage to file
  await page.context().storageState({ path: '.auth/admin.json' });

  await browser.close();
}

// playwright.config.ts
export default defineConfig({
  globalSetup: './global-setup.ts',
  projects: [
    {
      name: 'admin-tests',
      use: { storageState: '.auth/admin.json' }, // pre-authenticated!
    },
    {
      name: 'user-tests',
      use: { storageState: '.auth/user.json' },
    },
  ],
});

// Tests start pre-logged-in β€” no login UI interaction needed
test('admin dashboard', async ({ page }) => {
  await page.goto('/admin'); // Already authenticated!
  await expect(page.getByText('Admin Panel')).toBeVisible();
});

πŸ’‘ For OTP/MFA: intercept the OTP via API or email client in global setup. For multiple roles: run globalSetup for each role and save separate storageState files.

🚩

If you log in for every test, your suite will crawl.

🌍
Question 7 of 10

How do you manage environments?

Dev / QA / Staging / ProdBase URLsSecretsCI variables

All environment-specific values (URLs, credentials, API keys) must live outside the codebase β€” in env files locally, in CI secrets in pipelines.

TypeScript
// .env.dev (gitignored)
BASE_URL=http://localhost:3000
API_URL=http://localhost:4000
ADMIN_EMAIL=admin@dev.com

// .env.staging
BASE_URL=https://staging.myapp.com
API_URL=https://api.staging.myapp.com

// playwright.config.ts β€” reads from env
import { defineConfig } from '@playwright/test';
import * as dotenv from 'dotenv';

// Load environment file based on TEST_ENV variable
dotenv.config({ path: `.env.${process.env.TEST_ENV || 'dev'}` });

export default defineConfig({
  use: {
    baseURL: process.env.BASE_URL!, // never hardcoded
  },
});

// GitHub Actions secrets (never in code)
// Settings β†’ Secrets β†’ STAGING_ADMIN_PASS
// env:
//   ADMIN_PASS: ${{ secrets.STAGING_ADMIN_PASS }}
//   TEST_ENV: staging

// Run against different envs:
// TEST_ENV=dev      npx playwright test
// TEST_ENV=staging  npx playwright test
// TEST_ENV=prod     npx playwright test --grep @smoke

πŸ’‘ The .env files must be in .gitignore. Secrets must NEVER be committed. Use CI/CD secret management (GitHub Secrets, AWS SSM, Azure Key Vault) for all credentials.

🚩

Hardcoded URLs = junior mistake.

πŸ“¦
Question 8 of 10

How do you integrate Playwright with CI/CD?

GitHub Actions?Docker?Parallel workers?Artifact storage?

A production-grade CI pipeline runs tests in parallel, stores artifacts on failure, and reports results inline in the PR.

TypeScript
# .github/workflows/playwright.yml
name: Playwright Tests
on:
  push:
    branches: [main]
  pull_request:
    branches: [main]

jobs:
  test:
    timeout-minutes: 30
    runs-on: ubuntu-latest
    strategy:
      matrix:
        shard: [1, 2, 3, 4] # 4 parallel machines

    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 chromium

      - name: Run tests (sharded)
        run: npx playwright test --shard=${{ matrix.shard }}/4
        env:
          TEST_ENV: staging
          ADMIN_PASS: ${{ secrets.STAGING_ADMIN_PASS }}

      - name: Upload report
        uses: actions/upload-artifact@v4
        if: always() # upload even on failure
        with:
          name: report-shard-${{ matrix.shard }}
          path: playwright-report/
          retention-days: 14

πŸ’‘ Sharding splits your test suite across multiple machines (--shard=1/4 runs 25% of tests). 100 tests Γ— 4 shards = same wall-clock time as 25 tests. This is how teams keep CI under 10 minutes.

🚩

Modern automation must be pipeline-ready.

🧩
Question 9 of 10

How do you implement Page Object Model correctly?

When NOT to use POM?How to avoid bloated classes?Why avoid assertions inside POM?

POM wraps page interactions into reusable classes. The golden rule: Page Objects contain ACTIONS only β€” never assertions. Tests contain assertions.

TypeScript
// pages/LoginPage.ts β€” actions only, no expect()
export class LoginPage {
  constructor(private page: Page) {}

  async goto() {
    await this.page.goto('/login');
  }

  async login(email: string, password: string) {
    await this.page.getByLabel('Email').fill(email);
    await this.page.getByLabel('Password').fill(password);
    await this.page.getByRole('button', { name: 'Sign In' }).click();
  }

  // ❌ WRONG β€” assertion inside POM
  // async verifyLoggedIn() {
  //   await expect(this.page).toHaveURL('/dashboard');
  // }
}

// tests/login.spec.ts β€” assertions ONLY in tests
test('valid login', async ({ page }) => {
  const loginPage = new LoginPage(page);
  await loginPage.goto();
  await loginPage.login('admin@test.com', 'secret');

  // βœ… Assertion is in the test, not the POM
  await expect(page).toHaveURL('/dashboard');
  await expect(page.getByText('Welcome, Admin')).toBeVisible();
});

// When NOT to use POM:
// - One-off tests / exploratory tests
// - Very simple pages (login-only tests can use inline helpers)
// - When fixtures already provide the setup logic

πŸ’‘ Bloated POM anti-pattern: one massive 500-line class for the whole app. Solution: split by domain (CheckoutPage, CartPage, ProductPage). Each class should be under 100 lines. If it's bigger, split it.

🚩

Framework maturity shows here.

πŸ”₯
Question 10 of 10

How would you improve an existing Selenium framework using Playwright?

Built-in waitsNetwork mockingAPI testing supportParallel executionReduced infra complexity

This is a migration strategy question. Show you understand the pain points of Selenium and how Playwright directly addresses each one.

TypeScript
// BEFORE: Selenium β€” manual waits, slow, flaky
WebDriverWait wait = new WebDriverWait(driver, Duration.ofSeconds(30));
WebElement btn = wait.until(ExpectedConditions.elementToBeClickable(By.id("submit")));
btn.click();
Thread.sleep(2000); // 😱

// AFTER: Playwright β€” auto-wait, no Thread.sleep
await page.getByRole('button', { name: 'Submit' }).click(); // auto-waits

// ─── Network Mocking (impossible in Selenium) ──────────────
await page.route('/api/products', route => route.fulfill({
  json: [{ id: 1, name: 'Widget', price: 9.99 }]
}));

// ─── API Testing (built-in, no RestAssured needed) ─────────
const res = await request.post('/api/users', {
  data: { name: 'Test User' }
});
expect(res.status()).toBe(201);

// ─── Parallel execution (built-in, no Selenium Grid) ───────
// playwright.config.ts
export default defineConfig({
  workers: 8, // 8 parallel browsers, zero infra setup
  fullyParallel: true,
});

// ─── Migration strategy ─────────────────────────────────────
// Phase 1: New tests β†’ Playwright only
// Phase 2: High-value old tests β†’ migrate to Playwright
// Phase 3: Retire Selenium Grid (save infra cost)

πŸ’‘ Migration ROI points to mention: No Selenium Grid = no infra cost. No explicit waits = less maintenance. Built-in API testing = fewer dependencies. Parallel by default = faster feedback. Trace Viewer = faster debugging.

🚩

This is where senior candidates shine.

Want all 100 Playwright Questions?

Deep-dive answers for every topic β€” from auto-waiting to advanced fixtures.

View All 100 Questions