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
| Fixture | Scope | What it gives you |
|---|---|---|
browser | worker | Shared Browser process across all tests in a worker |
browserName | worker | "chromium" / "firefox" / "webkit" — the current project's browser |
context | test | Fresh BrowserContext per test (isolated cookies/storage) |
page | test | Fresh Page in the fresh context |
request | test | APIRequestContext for HTTP calls (covered in Module 5) |
baseURL | worker | Configured base URL (so page.goto('/about') works) |
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();
}
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.
workerIndex vs parallelIndex
- workerIndex — a unique 0-based id per worker for the whole run. If workers exit and respawn, the new one gets a new index. Good for "give each worker its own DB schema".
- parallelIndex — id of a parallel slot (0…workers-1). Reused if a worker dies. Good when you have a fixed pool of resources (e.g. 4 prebuilt test accounts).
// 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' });
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' },
},
],
});
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',
},
});
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?
{ page }) and runs setup before the test, teardown after. Encapsulates resources so tests stay short and focused.Explain the setup / use / teardown lifecycle.
({ 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?
[fn, { scope: 'worker' }].How do you write a custom fixture?
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?
async ({ apiClient }, use) => { ... } declares this fixture needs apiClient.How would you log in once and reuse the session?
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?
What does fullyParallel do?
What's a project and why use multiple?
dependencies.How do you make a project depend on another?
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?
What is storageState and what's inside it?
How would you support several roles (admin, user, guest)?
.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?
What does the webServer config do?
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?
[['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?
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?
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?
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?
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 }.