Fixtures, Parallelism, Config

7Fixtures, Parallelism & Config

What you will master here

  • Built-in fixtures (page, browser, context, request, browserName)
  • Custom fixtures with test.extend — setup/handoff/teardown
  • Composable fixtures (a fixture depending on another)
  • Worker-scoped vs test-scoped fixtures
  • workerIndex, parallelIndex, fullyParallel
  • storageState for fast authenticated tests
  • Projects, retries, timeouts, reporters
  • Full annotated playwright.config.ts

7.1 What a fixture is, intuitively

A fixture is a named piece of setup that produces a value your test can use, and cleans up afterward. The Playwright test runner injects fixtures into your test by parameter name.

// You did not create `page`. The runner did, and will close it after the test.
test('basic', async ({ page }) => {
  await page.goto('/');
});

The magic: the runner looks at your destructured parameter names ({ page }), looks up matching fixtures, runs their setup, hands the values in, then runs cleanup after the test ends — pass or fail.

7.2 The built-in fixtures

FixtureScopeWhat it gives you
browserworkerShared Browser process across all tests in a worker
browserNameworker"chromium" / "firefox" / "webkit" — the current project's browser
contexttestFresh BrowserContext per test (isolated cookies/storage)
pagetestFresh Page in the fresh context
requesttestAPIRequestContext for HTTP calls (covered in Module 5)
baseURLworkerConfigured base URL (so page.goto('/about') works)
Scope = lifetimeTest-scoped fixtures are created and destroyed for every test. Worker-scoped fixtures are created once per worker process and reused. Choose worker scope for expensive things (DB pool, browser launch).

7.3 The setup / handoff / teardown model

Every custom fixture is a single async function with this shape:

async function myFixture({ otherFixture }, use) {
  // 1. SETUP — runs before the test
  const value = await createSomething();

  // 2. HANDOFF — the test runs here, receiving `value`
  await use(value);

  // 3. TEARDOWN — runs after the test, even if it failed
  await value.dispose();
}
SETUP create resource before use(...) await use(value) test runs here receives value TEARDOWN cleanup resource after use(...) Teardown runs even if the test fails — guaranteed cleanup
Figure 4 — A fixture is one function: setup before use, teardown after.

7.4 Writing a custom fixture — full example

Below is the standard pattern: extend the base test with extra fixtures, then import the extended one in your specs.

// fixtures.ts
import { test as base } from '@playwright/test';
import { LoginPage } from './pages/LoginPage';

type Fixtures = {
  loginPage: LoginPage;
  authedUser: { email: string; token: string };
};

export const test = base.extend<Fixtures>({
  // Test-scoped: new per test
  loginPage: async ({ page }, use) => {
    const lp = new LoginPage(page);
    await lp.goto();
    await use(lp);          // teardown not needed here
  },

  // Worker-scoped: created once per worker, reused across tests
  authedUser: [async ({}, use) => {
    const res  = await fetch('https://api.example.com/login', {
      method: 'POST',
      body: JSON.stringify({ user: 'qa', pass: 'qa-pass' }),
    });
    const data = await res.json();
    await use({ email: 'qa@test.com', token: data.token });
    // optional teardown: invalidate token
  }, { scope: 'worker' }],
});

export { expect } from '@playwright/test';
// some.spec.ts
import { test, expect } from './fixtures';

test('login uses fixture', async ({ loginPage, authedUser }) => {
  await loginPage.fill(authedUser.email, 'x');
  // ...
});

7.5 Composable fixtures (depending on other fixtures)

Fixtures can declare dependencies by listing them as parameters. The runner resolves the dependency graph automatically.

export const test = base.extend<{
  apiClient: ApiClient;
  seededUser: User;
  loggedInPage: Page;
}>({
  // base
  apiClient: async ({}, use) => {
    const c = new ApiClient(process.env.API_URL!);
    await use(c);
    await c.dispose();
  },

  // depends on apiClient
  seededUser: async ({ apiClient }, use) => {
    const u = await apiClient.createUser({ name: 'Alice' });
    await use(u);
    await apiClient.deleteUser(u.id);
  },

  // depends on seededUser and the built-in `page`
  loggedInPage: async ({ page, seededUser }, use) => {
    await page.goto('/login');
    await page.getByLabel('Email').fill(seededUser.email);
    await page.getByLabel('Password').fill(seededUser.password);
    await page.getByRole('button', { name: 'Sign in' }).click();
    await use(page);
  },
});

// Test only declares what it needs. The graph is built automatically.
test('dashboard greets the user', async ({ loggedInPage, seededUser }) => {
  await expect(loggedInPage.getByText(`Hello, ${seededUser.name}`)).toBeVisible();
});

7.6 Overriding built-in fixtures

You can replace a built-in fixture with your own customised version — most commonly context to add tracing or set defaults.

export const test = base.extend({
  context: async ({ browser }, use) => {
    const context = await browser.newContext({
      viewport: { width: 1440, height: 900 },
      locale: 'en-IN',
      timezoneId: 'Asia/Kolkata',
    });
    await context.tracing.start({ screenshots: true, snapshots: true });
    await use(context);
    await context.tracing.stop({ path: `traces/${Date.now()}.zip` });
    await context.close();
  },
});

7.7 Parallelism: workers, workerIndex, parallelIndex

Playwright runs tests across worker processes, each holding one browser. Inside a worker, tests run sequentially by default; with fullyParallel on, tests within a file also split across workers.

workers: 3, fullyParallel: true Worker 0 browser process test-1 (context A) test-4 (context B) test-7 (context C) Worker 1 browser process test-2 (context A) test-5 (context B) test-8 (context C) Worker 2 browser process test-3 (context A) test-6 (context B) test-9 (context C)
Figure 5 — Tests split across workers; each worker owns one browser; every test gets its own context.

workerIndex vs parallelIndex

// Use parallelIndex to pick a unique account per parallel slot
import { test as base } from '@playwright/test';
const accounts = ['user0', 'user1', 'user2', 'user3'];

export const test = base.extend<{ account: string }>({
  account: [async ({}, use, info) => {
    await use(accounts[info.parallelIndex]);
  }, { scope: 'worker' }],
});

7.8 fullyParallel

fullyParallel: true (config or per-file test.describe.configure) lets tests within the same file run on different workers. Without it, tests in one file always run on the same worker, sequentially.

// playwright.config.ts
export default defineConfig({ fullyParallel: true });

// or per-file
test.describe.configure({ mode: 'parallel' });
Beware shared mutable statefullyParallel breaks any test that depends on a previous test in the same file (e.g. test 1 creates a user, test 2 logs in). Each test must be self-contained.

7.9 storageState — auth, the right way

Logging in via the UI in every test is slow and flaky. The pattern: log in once in a setup step, save the cookies + localStorage to a JSON file, then load that file into every test's context.

// auth.setup.ts — runs once before tests
import { test as setup, expect } from '@playwright/test';

setup('authenticate as admin', async ({ page }) => {
  await page.goto('/login');
  await page.getByLabel('Email').fill(process.env.ADMIN_EMAIL!);
  await page.getByLabel('Password').fill(process.env.ADMIN_PASS!);
  await page.getByRole('button', { name: 'Sign in' }).click();
  await expect(page.getByText('Dashboard')).toBeVisible();
  // Save cookies + localStorage to a file
  await page.context().storageState({ path: '.auth/admin.json' });
});
// playwright.config.ts — wire setup + projects
export default defineConfig({
  projects: [
    { name: 'setup', testMatch: /auth\.setup\.ts/ },
    {
      name: 'chromium',
      dependencies: ['setup'],
      use: { ...devices['Desktop Chrome'], storageState: '.auth/admin.json' },
    },
  ],
});
Multiple rolesSave admin.json, user.json, guest.json. Override per test with test.use({ storageState: '.auth/user.json' }).

7.10 Projects

A project is a named test configuration. Use projects for: cross-browser runs, mobile emulation, smoke vs full suite, multiple environments.

// playwright.config.ts
import { defineConfig, devices } from '@playwright/test';

export default defineConfig({
  projects: [
    { name: 'chromium', use: { ...devices['Desktop Chrome'] } },
    { name: 'firefox',  use: { ...devices['Desktop Firefox'] } },
    { name: 'webkit',   use: { ...devices['Desktop Safari'] } },
    { name: 'mobile-chrome', use: { ...devices['Pixel 7'] } },
    { name: 'mobile-safari', use: { ...devices['iPhone 14'] } },
    { name: 'smoke', testMatch: /.*\.smoke\.spec\.ts/ },
  ],
});

Run one project: npx playwright test --project=chromium. Run all: just npx playwright test.

7.11 Retries, timeouts, reporters

export default defineConfig({
  timeout: 30_000,              // per-test timeout
  expect: { timeout: 5_000 },   // per-assertion timeout
  retries: process.env.CI ? 2 : 0,
  reporter: [
    ['html', { open: 'never' }],
    ['list'],
    ['junit', { outputFile: 'results/junit.xml' }],
  ],
  use: {
    actionTimeout: 10_000,
    navigationTimeout: 30_000,
    trace: 'on-first-retry',
    screenshot: 'only-on-failure',
    video: 'retain-on-failure',
  },
});
Retries on CI onlyLocal: flake = bug. CI: retry once to absorb infra noise, but watch retry rate — a high one means your tests are unstable.

7.12 Full annotated playwright.config.ts

import { defineConfig, devices } from '@playwright/test';

export default defineConfig({
  testDir: './tests',
  outputDir: 'test-results',          // screenshots/videos/traces output

  fullyParallel: true,                // tests within file run in parallel
  forbidOnly: !!process.env.CI,       // fail if .only is committed
  workers: process.env.CI ? 4 : undefined,
  retries: process.env.CI ? 2 : 0,
  timeout: 30_000,
  expect: { timeout: 5_000 },

  reporter: [['html'], ['list']],

  use: {
    baseURL: process.env.BASE_URL ?? 'http://localhost:3000',
    actionTimeout: 10_000,
    navigationTimeout: 30_000,
    trace: 'on-first-retry',
    screenshot: 'only-on-failure',
    video: 'retain-on-failure',
    headless: true,
    viewport: { width: 1440, height: 900 },
    locale: 'en-US',
    timezoneId: 'UTC',
    ignoreHTTPSErrors: true,
    testIdAttribute: 'data-testid',
    extraHTTPHeaders: { 'x-test-mode': '1' },
  },

  projects: [
    { name: 'setup', testMatch: /.*\.setup\.ts/ },
    { name: 'chromium',
      dependencies: ['setup'],
      use: { ...devices['Desktop Chrome'], storageState: '.auth/user.json' } },
    { name: 'firefox',
      dependencies: ['setup'],
      use: { ...devices['Desktop Firefox'], storageState: '.auth/user.json' } },
    { name: 'webkit',
      dependencies: ['setup'],
      use: { ...devices['Desktop Safari'], storageState: '.auth/user.json' } },
  ],

  webServer: {                        // boot the app for tests if needed
    command: 'npm run start',
    url: 'http://localhost:3000',
    reuseExistingServer: !process.env.CI,
    timeout: 120_000,
  },
});

Module 3 — Interview Q&A bank

What is a fixture in Playwright?
A named, async piece of setup that produces a value the test consumes. The test runner injects fixtures by parameter name ({ page }) and runs setup before the test, teardown after. Encapsulates resources so tests stay short and focused.
Explain the setup / use / teardown lifecycle.
A fixture function takes ({ deps }, use). Code before await use(value) is setup; await use(value) hands the value to the test (and any dependent fixture); code after runs teardown. Teardown executes even if the test failed.
Difference between worker-scoped and test-scoped fixtures?
Test-scoped: created/destroyed per test (default). Worker-scoped: created once per worker process and reused. Use worker scope for expensive shared resources (DB pool, auth token, browser launch). Specify with [fn, { scope: 'worker' }].
How do you write a custom fixture?
Call test.extend on the base test, passing an object whose keys are new fixture names and values are async functions of ({ deps }, use). Export the extended test from a file and import it in your specs.
How do fixtures depend on other fixtures?
By listing them in the destructuring parameter. Playwright resolves the dependency graph topologically, sets up dependencies first, tears down in reverse order. Example: async ({ apiClient }, use) => { ... } declares this fixture needs apiClient.
How would you log in once and reuse the session?
Use a setup project that logs in via UI or API, calls page.context().storageState({ path: 'auth.json' }) to dump cookies+localStorage. Then in regular projects set use: { storageState: 'auth.json' }. Each test loads the auth cheaply.
workerIndex vs parallelIndex — when does each matter?
workerIndex grows monotonically — if a worker crashes and respawns, the new one gets a new index. parallelIndex represents a stable "slot" 0…workers-1 that is reused. Use parallelIndex when assigning a fixed pool of resources (e.g. one test account per slot); use workerIndex when you genuinely need a fresh unique id.
What does fullyParallel do?
Splits tests within the same file across workers. Default: tests in one file run sequentially in one worker (so they can share state). With fullyParallel, every test is independent — better parallelism, but tests must be self-contained.
What's a project and why use multiple?
A named test configuration. Use multiple projects for: cross-browser (chromium/firefox/webkit), mobile emulation, smoke vs full suite, per-environment runs, or to wire a setup phase via dependencies.
How do you make a project depend on another?
Use dependencies: ['setup']. The dependency project runs to completion first; if it fails, dependents don't run. Common pattern: a setup project that authenticates and writes storageState, depended on by browser projects.
What does retries: process.env.CI ? 2 : 0 express?
On CI, retry a failed test up to 2 times to absorb infra noise (a flaky network call, an unlucky timing). Locally, never retry — a local flake is a real bug you should fix. Watch the CI retry rate; high values mean tests are unstable.
What is storageState and what's inside it?
A JSON file containing the cookies and localStorage/sessionStorage of a BrowserContext at a moment in time. Loading it into a new context "restores" the session — used to skip UI login.
How would you support several roles (admin, user, guest)?
A setup project per role that logs in and saves to .auth/admin.json, .auth/user.json, .auth/guest.json. Default to one in use.storageState; override per file with test.use({ storageState: '.auth/admin.json' }).
How do test timeout, expect timeout, and action timeout differ?
test timeout: wall-clock limit for the whole test (default 30 s). expect timeout: per-assertion limit for web-first assertions (default 5 s). action timeout: per-action limit (click, fill — default 0 = use test timeout). Tune all three from config.
What does the webServer config do?
Boots a local server (your app) before tests, waits for the URL to respond, runs tests, kills it after. reuseExistingServer: !process.env.CI means "locally don't restart if a dev server is already running, on CI always start fresh".
What are reporters and which do you use?
Reporters render the run output. Built-in: list (console), line, dot, html (interactive report with traces/screenshots), junit (XML for CI dashboards), json, github, blob (for shard merging). Common combination: [['list'], ['html', { open: 'never' }], ['junit']].
How do you override config for a single test or file?
test.use({ ... }) at module scope or inside a describe block to override fixtures/options for that scope.
How do you skip a test conditionally?
test.skip(condition, 'reason') at start of a test, or test.fixme() for known broken. test('x', ...) can be replaced with test.skip('x', ...) for always-skip.
What is forbidOnly and why?
If forbidOnly: true, the runner fails if any test.only(...) is present. Prevents accidentally pushing a commit that only runs one test on CI. Standard pattern: forbidOnly: !!process.env.CI.
How would you share state between two tests intentionally?
You usually shouldn't — tests should be independent. If you must (e.g. an end-to-end purchase flow), put them in one file, set test.describe.configure({ mode: 'serial' }), and accept that one failing test fails the rest. Prefer instead a single test that exercises the full flow.
How do you increase parallelism on CI?
Two levers. workers: N in the runner — usually CPU-bound, 4–8 is typical. And sharding across CI machines: --shard=1/4 on machine A, --shard=2/4 on B, etc. Multiplies workers × shards.
If a test needs a freshly seeded user, where does that logic go?
A test-scoped custom fixture seededUser that hits the API to create one in setup and deletes it in teardown — depending on an apiClient fixture for the HTTP plumbing. The test just declares { seededUser }.