Introduction
If you are building a serious Playwright automation framework, writing tests directly with page, request, and repeated setup code does not scale well. Enterprise projects need reusable authentication, API clients, test data, Page Objects, role-based users, environment configuration, and reliable cleanup.
This is where Playwright custom fixtures advanced techniques become valuable.
Playwright Test fixtures let you define the environment a test needs and automatically manage fixture setup and teardown. Playwright analyzes the fixtures required by a test and prepares only those dependencies. Built-in fixtures such as page, context, browser, and request already provide a strong foundation, while test.extend() allows teams to create their own fixtures.
For senior QA engineers and SDETs, advanced fixtures are more than a convenience. They become the dependency-injection layer of the Playwright Automation Framework.
A well-designed fixture architecture can look like:
Playwright Test
|
Custom test object
|
+—————–+——————+
| | |
Authentication API Client Test Data
| | |
+———+——-+——————+
|
Page Objects
|
Test Cases
This advanced Playwright fixtures tutorial explains how to build that architecture using practical Playwright TypeScript examples.
What Are Playwright Fixtures?
A fixture is reusable setup and teardown logic that provides an object, service, or environment to a test.
For example:
import { test, expect } from ‘@playwright/test’;
test(‘verify dashboard’, async ({ page }) => {
await page.goto(‘/dashboard’);
await expect(page).toHaveTitle(/Dashboard/);
});
Here, page is a built-in Playwright fixture.
Playwright creates the required fixture before the test and cleans it up afterward. The built-in context fixture gives each test an isolated browser context, while page belongs to that context. This isolation is a core reason Playwright tests can safely run in parallel.
A custom fixture follows the same lifecycle model.
Built-In vs Custom Playwright Fixtures
| Fixture | Typical Scope | Purpose |
| browser | Worker | Browser instance |
| context | Test | Isolated browser context |
| page | Test | Browser page |
| request | Test | API request context |
| browserName | Worker/Test usage | Current browser |
| Custom fixture | Test/Worker | Application-specific setup |
Use built-in fixtures for browser infrastructure.
Use custom fixtures for application infrastructure.
Examples include:
- adminUser
- authenticatedPage
- apiClient
- testData
- dashboardPage
- database
- customer
- tenant
- featureFlags
Why Use Custom Fixtures in Enterprise Automation Frameworks?
Without fixtures, test files often become:
test(‘create order’, async ({ page }) => {
await login(page);
await createCustomer();
await createProduct();
await configureTenant();
// Actual test…
});
The same setup appears across hundreds of tests.
With fixtures:
test(‘create order’, async ({ authenticatedPage, testData }) => {
await authenticatedPage.createOrder(testData.product);
});
The test describes what is being tested, not how the environment is constructed.
Benefits include:
- Less duplicate setup code
- Centralized authentication
- Better test isolation
- Easier parallel execution
- Reusable API setup
- Cleaner Page Object Model integration
- Better teardown
- Easier environment switching
- Improved maintainability
- More readable tests
For an Automation Architect, fixtures effectively provide controlled dependency injection.
Creating Your First Custom Fixture
Problem
Suppose every test needs an application URL and a logged-in user.
Fixture Design
Create a custom test object with test.extend().
Code
// fixtures/base.fixture.ts
import { test as base, expect } from ‘@playwright/test’;
type Fixtures = {
appUrl: string;
};
export const test = base.extend<Fixtures>({
appUrl: async ({}, use) => {
await use(process.env.BASE_URL ?? ‘http://localhost:3000’);
},
});
export { expect };
Use it in a test:
import { test, expect } from ‘../fixtures/base.fixture’;
test(‘open application’, async ({ page, appUrl }) => {
await page.goto(appUrl);
await expect(page).toHaveURL(/localhost/);
});
How It Works
test.extend<Fixtures>() adds appUrl to the test fixture object.
The use() callback controls the lifecycle:
setup
↓
await use(value)
↓
↓
teardown
Real-World Use Case
Use this pattern for:
- Base URLs
- Environment configuration
- Tenant identifiers
- Feature flags
- Service clients
Best Practice
Do not put business logic into every test fixture. Keep infrastructure concerns in fixtures and business behavior in Page Objects or service classes.
Fixture Composition and Dependent Fixtures
One of the most important Playwright Fixture Composition patterns is allowing one fixture to depend on another.
Problem
An authenticated page depends on authentication credentials.
Fixture Design
import { test as base, expect } from ‘@playwright/test’;
type Fixtures = {
username: string;
authenticatedPage: void;
};
export const test = base.extend<Fixtures>({
username: async ({}, use) => {
await use(process.env.TEST_USERNAME ?? ‘qa-user’);
},
authenticatedPage: async ({ page, username }, use) => {
await page.goto(‘/login’);
await page.getByLabel(‘Username’).fill(username);
await page.getByLabel(‘Password’).fill(
process.env.TEST_PASSWORD ?? ‘Password123’
);
await page.getByRole(‘button’, { name: ‘Login’ }).click();
await use();
},
});
export { expect };
Now:
test(‘authenticated dashboard’, async ({ authenticatedPage, page }) => {
await expect(page.getByRole(‘heading’, { name: ‘Dashboard’ }))
.toBeVisible();
});
The dependency graph is:
username
↓
authenticatedPage
↓
test
Playwright resolves the dependency order automatically.
Best Practice
Compose small fixtures instead of creating one enormous fixture such as:
enterpriseEverythingFixture
Prefer:
environment
↓
authentication
↓
API client
↓
test data
↓
Page Object
This makes fixture behavior easier to debug and reuse.
Test-Scoped vs Worker-Scoped Fixtures
Understanding scope is essential for playwright custom fixtures advanced implementation.
Test-Scoped Fixture
A test-scoped fixture is created for each test that uses it.
type Fixtures = {
testToken: string;
};
export const test = base.extend<Fixtures>({
testToken: async ({}, use) => {
const token = crypto.randomUUID();
await use(token);
},
});
Use test scope when state must be isolated.
Examples:
- Browser sessions
- Temporary records
- Authentication state
- Shopping carts
- Test-specific API clients
Worker-Scoped Fixture
A worker-scoped fixture is shared by tests running in the same worker.
type WorkerFixtures = {
workerUser: string;
};
export const test = base.extend<{}, WorkerFixtures>({
workerUser: [async ({}, use, workerInfo) => {
const username = `qa-worker-${workerInfo.workerIndex}`;
await use(username);
}, { scope: ‘worker’ }],
});
Playwright workers execute tests in separate worker processes, and workerIndex can be used to create unique worker-specific resources.
When Should You Use Each?
| Requirement | Scope |
| Unique test user | Test |
| Browser page | Test |
| API test data | Test |
| Database schema | Worker |
| Expensive shared connection | Worker |
| Worker-specific account | Worker |
| Authentication generated once per worker | Worker |
| Mutable shopping cart | Test |
Rule: Default to test scope. Use worker scope only when sharing is intentional and safe.
Automatic Fixtures and Setup/Teardown
An automatic fixture executes even when the test does not explicitly request it.
Problem
You want to capture diagnostics for every test.
Code
import { test as base } from ‘@playwright/test’;
type Fixtures = {
diagnostics: void;
};
export const test = base.extend<Fixtures>({
diagnostics: [async ({ page }, use, testInfo) => {
await use();
if (testInfo.status !== testInfo.expectedStatus) {
const screenshot = await page.screenshot();
await testInfo.attach(‘failure-screenshot’, {
body: screenshot,
contentType: ‘image/png’
});
}
}, { auto: true }],
});
Automatic fixtures use tuple syntax with { auto: true }. Playwright supports using testInfo inside fixtures to inspect status and attach diagnostic information.
Use Cases
- Failure screenshots
- Console log collection
- Network diagnostics
- Cleanup
- Metrics
- Test annotations
- Environment validation
Avoid making every fixture automatic. Automatic fixtures add work to tests that may not need the feature.
Playwright Authentication Fixture
Authentication is one of the most useful advanced Playwright fixtures.
Playwright supports reusing authenticated browser state through storageState. Authentication state can be generated through UI or API and reused by browser contexts.
Authentication Fixture
import { test as base, expect } from ‘@playwright/test’;
type Fixtures = {
authenticatedPage: void;
};
export const test = base.extend<Fixtures>({
authenticatedPage: async ({ page }, use) => {
await page.goto(‘/login’);
await page.getByLabel(‘Username’).fill(
process.env.TEST_USERNAME!
);
await page.getByLabel(‘Password’).fill(
process.env.TEST_PASSWORD!
);
await page.getByRole(‘button’, { name: ‘Login’ }).click();
await page.waitForURL(‘**/dashboard’);
await use();
},
});
export { expect };
For larger suites, API authentication is often faster:
import { test as setup } from ‘@playwright/test’;
setup(‘authenticate’, async ({ request }) => {
await request.post(‘/api/login’, {
data: {
username: process.env.TEST_USERNAME,
password: process.env.TEST_PASSWORD
}
});
await request.storageState({
path: ‘playwright/.auth/user.json’
});
});
The generated state can then be configured as storageState.
Never commit authentication state files containing cookies or credentials to source control. Playwright explicitly warns that these files may contain sensitive authentication information.
API Request Fixtures for Test Data Setup
UI setup can be slow.
If an order requires five API calls before the UI test can begin, use an API fixture.
Problem
Creating test data through the UI increases execution time.
Fixture
import { test as base, expect, APIRequestContext } from ‘@playwright/test’;
type Fixtures = {
apiClient: APIRequestContext;
};
export const test = base.extend<Fixtures>({
apiClient: async ({ playwright }, use) => {
const client = await playwright.request.newContext({
baseURL: process.env.API_URL ?? ‘http://localhost:4000’
});
await use(client);
await client.dispose();
}
});
export { expect };
Test:
test(‘verify newly created customer’, async ({ apiClient, page }) => {
const response = await apiClient.post(‘/customers’, {
data: {
name: `Customer-${Date.now()}`
}
});
const customer = await response.json();
await page.goto(`/customers/${customer.id}`);
await expect(page.getByText(customer.name)).toBeVisible();
});
Playwright’s built-in request fixture provides an isolated APIRequestContext, making API-driven setup a natural part of the fixture architecture.
Page Object Model With Custom Fixtures
Fixtures and Page Object Model should complement each other.
Page Object
import { Page } from ‘@playwright/test’;
export class DashboardPage {
constructor(private readonly page: Page) {}
async open() {
await this.page.goto(‘/dashboard’);
}
async getWelcomeMessage() {
return this.page.getByRole(‘heading’, {
name: /welcome/i
});
}
}
Fixture
import { test as base } from ‘@playwright/test’;
import { DashboardPage } from ‘../pages/dashboard.page’;
type Fixtures = {
dashboardPage: DashboardPage;
};
export const test = base.extend<Fixtures>({
dashboardPage: async ({ page }, use) => {
await use(new DashboardPage(page));
}
});
Test:
test(‘dashboard loads’, async ({ dashboardPage }) => {
await dashboardPage.open();
await expect(await dashboardPage.getWelcomeMessage())
.toBeVisible();
});
This gives a clean architecture:
Test
↓
Fixture
↓
Page Object
↓
Playwright Page
Fixtures should construct Page Objects; Page Objects should contain UI behavior.
Test Data and Database-Related Fixture Patterns
A senior framework should separate test data generation from UI actions.
For example:
type TestUser = {
id: string;
email: string;
};
type Fixtures = {
testUser: TestUser;
};
export const test = base.extend<Fixtures>({
testUser: async ({ request }, use) => {
const email = `qa-crypto.randomUUID()@example.com`;constresponse=awaitrequest.post(‘/api/users’,data:email);constuser=awaitresponse.json();awaituse(user);awaitrequest.delete(`/api/users/{user.id}`);
}
});
This provides:
Create data
↓
↓
Delete data
For database testing, the same pattern can wrap a database client:
const connection = await createDbConnection();
await use(connection);
await connection.close();
Keep database credentials in environment variables or secret managers rather than source code.
Multi-User and Role-Based Fixtures
Enterprise applications frequently require admin, manager, and standard-user scenarios.
type Fixtures = {
adminPage: Page;
customerPage: Page;
};
export const test = base.extend<Fixtures>({
adminPage: async ({ browser }, use) => {
const context = await browser.newContext({
storageState: ‘playwright/.auth/admin.json’
});
const page = await context.newPage();
await use(page);
await context.close();
},
customerPage: async ({ browser }, use) => {
const context = await browser.newContext({
storageState: ‘playwright/.auth/customer.json’
});
const page = await context.newPage();
await use(page);
await context.close();
}
});
Test:
test(‘admin can approve customer request’,
async ({ adminPage, customerPage }) => {
// Multi-user workflow
});
This is useful for:
- RBAC testing
- Approval workflows
- Chat systems
- Collaboration applications
- Multi-tenant applications
Custom Fixtures for Parallel Execution and Test Isolation
Parallel execution changes fixture design.
Never create shared mutable state such as:
let currentUser = ‘test-user’;
when multiple workers can modify it.
Instead, generate worker-specific or test-specific data.
type WorkerFixtures = {
workerUser: string;
};
export const test = base.extend<{}, WorkerFixtures>({
workerUser: [async ({}, use, workerInfo) => {
const user = `automation-worker-${workerInfo.workerIndex}`;
await use(user);
}, { scope: ‘worker’ }]
});
Playwright documents workerIndex specifically as a mechanism for isolating resources between parallel workers.
For test-level resources, prefer:
const id = crypto.randomUUID();
rather than predictable identifiers.
Advanced Fixture Configuration and Environment Handling
Fixtures should not hard-code environments.
Use configuration:
export const config = {
baseUrl: process.env.BASE_URL ?? ‘http://localhost:3000’,
apiUrl: process.env.API_URL ?? ‘http://localhost:4000’,
username: process.env.TEST_USERNAME ?? ”,
password: process.env.TEST_PASSWORD ?? ”
};
Then:
type Fixtures = {
environment: typeof config;
};
export const test = base.extend<Fixtures>({
environment: async ({}, use) => {
await use(config);
}
});
A CI pipeline can then provide:
BASE_URL=https://staging.example.com
API_URL=https://api-staging.example.com
TEST_USERNAME=qa-user
Avoid mixing environment discovery, secrets, business logic, and browser operations in one fixture.
Debugging, Tracing, Screenshots, and Reporting With Fixtures
Fixtures are excellent places to centralize diagnostics.
const test = base.extend({
diagnostics: [async ({ page }, use, testInfo) => {
await use();
if (testInfo.status !== testInfo.expectedStatus) {
await testInfo.attach(‘screenshot’, {
body: await page.screenshot(),
contentType: ‘image/png’
});
}
}, { auto: true }]
});
testInfo.attach() makes screenshots, logs, JSON, and other artifacts available to reporters. testInfo.outputPath() also creates paths isolated for the current test, which is useful when tests run concurrently.
For deeper investigation, enable tracing in configuration:
use: {
trace: ‘retain-on-failure’,
screenshot: ‘only-on-failure’,
video: ‘retain-on-failure’
}
Fixtures can also add structured diagnostic steps using Playwright’s test-step APIs.
A mature framework should make failures self-diagnosing instead of requiring engineers to reproduce every failure locally.
Using Custom Fixtures in CI/CD Pipelines
A CI-friendly fixture architecture should:
- Read configuration from environment variables.
- Avoid shared mutable state.
- Generate unique test data.
- Clean up resources.
- Attach diagnostics.
- Support retries.
- Work with multiple workers.
- Avoid local-machine assumptions.
Example:
– name: Install dependencies
run: npm ci
– name: Install Playwright browsers
run: npx playwright install –with-deps
– name: Run tests
run: npx playwright test
env:
BASE_URL: ${{ secrets.BASE_URL }}
TEST_USERNAME: ${{ secrets.TEST_USERNAME }}
TEST_PASSWORD: ${{ secrets.TEST_PASSWORD }}
– name: Upload report
if: always()
uses: actions/upload-artifact@v4
with:
name: playwright-report
path: playwright-report/
Playwright configuration supports worker counts, projects, reporters, retries, web servers, and other CI-relevant settings.
Real-World Enterprise Playwright Fixture Architecture
A scalable project might use:
playwright-framework/
│
├── fixtures/
│ ├── base.fixture.ts
│ ├── auth.fixture.ts
│ ├── api.fixture.ts
│ ├── data.fixture.ts
│ ├── user.fixture.ts
│ └── diagnostics.fixture.ts
│
├── pages/
│ ├── LoginPage.ts
│ ├── DashboardPage.ts
│ └── OrdersPage.ts
│
├── services/
│ ├── UserApi.ts
│ └── OrderApi.ts
│
├── data/
│ └── factories/
│
├── tests/
│ ├── smoke/
│ ├── regression/
│ └── api/
│
├── playwright.config.ts
└── package.json
The dependency flow becomes:
Environment
↓
Authentication
↓
↓
Test Data
↓
Page Objects
↓
Tests
↓
Diagnostics / Reporting
This is a strong foundation for an enterprise Playwright Testing Framework.
Common Custom Fixture Mistakes and Solutions
1. Making everything worker-scoped
Problem: Tests accidentally share mutable state.
Solution: Default to test scope.
2. Creating one giant fixture
Problem: Changes become risky.
Solution: Compose small fixtures.
3. Putting assertions inside fixtures
Problem: Fixtures become difficult to reuse.
Solution: Let fixtures prepare state; let tests assert behavior.
4. Forgetting teardown
Problem: Test data accumulates.
Solution:
await use(resource);
await cleanup(resource);
5. Hard-coding credentials
Problem: Security and portability issues.
Solution: Use environment variables or CI secrets.
6. Shared test data
Problem: Parallel tests interfere with each other.
Solution: Generate unique data per test or worker.
7. Overusing automatic fixtures
Problem: Every test pays the setup cost.
Solution: Use auto: true only for genuinely global concerns.
8. Ignoring fixture timeout
Fixture setup and teardown contribute to test execution time; Playwright also supports dedicated fixture timeouts for slow fixtures.
Playwright Custom Fixtures Best Practices
Use this checklist when designing advanced fixtures:
- Keep fixtures small and composable.
- Prefer test scope unless sharing is intentional.
- Use worker scope for expensive, safely shareable resources.
- Keep authentication reusable.
- Use APIs for fast data setup.
- Generate unique test data.
- Always clean up created resources.
- Keep secrets outside source control.
- Integrate Page Objects through fixtures.
- Add failure diagnostics centrally.
- Make fixtures environment-aware.
- Design for parallel execution from day one.
- Avoid hidden global state.
- Document fixture dependencies.
- Give slow fixtures appropriate timeouts.
- Keep business assertions in tests.
- Keep UI behavior in Page Objects.
- Keep API behavior in service classes.
Advanced Playwright Fixture Interview Questions With Answers
1. What are Playwright custom fixtures?
Custom fixtures are user-defined reusable setup and teardown components created with test.extend().
2. Why use fixtures instead of beforeEach()?
Fixtures provide dependency-based setup and can be composed. A test explicitly requests the fixture it needs.
3. What is fixture composition?
Fixture composition means one fixture depends on another fixture.
Example:
apiClient → testData → orderPage
4. What is a worker-scoped fixture?
A worker-scoped fixture is initialized once for a worker and reused by tests running in that worker.
5. When should worker scope be avoided?
Avoid it when tests mutate shared state or require complete isolation.
6. What does { auto: true } do?
It makes the fixture run automatically even when the test does not explicitly request it.
7. How would you design authentication?
Generate reusable authenticated state and load it into isolated contexts, or authenticate through an API when possible.
8. How do fixtures support parallel testing?
Test-scoped fixtures naturally align with isolated browser contexts. Worker-specific resources can use workerIndex to avoid collisions.
9. How do you add screenshots to reports?
Use testInfo.attach() or an automatic diagnostic fixture.
10. How would you design fixtures for 1,000+ tests?
Separate infrastructure, authentication, API services, test data, Page Objects, and diagnostics. Keep dependencies explicit and make all mutable resources parallel-safe.
Learning Roadmap for Mastering Playwright Fixtures
Follow this progression:
Level 1 — Fundamentals
Learn:
- test
- expect
- page
- context
- Locators
- Assertions
Level 2 — Custom Fixtures
Learn:
- test.extend()
- use()
- Fixture dependencies
- Setup and teardown
- Fixture types
Level 3 — Advanced Fixtures
Learn:
- Worker scope
- Automatic fixtures
- Authentication
- API fixtures
- Test data factories
- Multi-user fixtures
Level 4 — Framework Architecture
Learn:
- Page Object Model
- Service classes
- Environment management
- Parallel execution
- Test isolation
- Reporting
- Tracing
Level 5 — Enterprise Engineering
Learn:
- CI/CD
- Docker
- Sharding
- Secrets management
- Multi-browser projects
- Framework governance
- Failure diagnostics
- Large-suite optimization
For related learning, connect this topic with Advanced Playwright Automation Techniques, Playwright Fixtures Tutorial, Playwright Page Object Model, Playwright Authentication Tutorial, Playwright API Testing, Playwright Data Driven Testing, Playwright Parallel Execution, Playwright Test Isolation, Playwright Reporting Tutorial, Playwright CI/CD Tutorial, Playwright GitHub Actions Tutorial, Playwright Docker Tutorial, Playwright Network Interception, Playwright Framework Design, Playwright TypeScript Tutorial, Playwright Best Practices, and Playwright Interview Questions.
FAQs: Playwright Custom Fixtures Advanced
What are advanced Playwright fixtures?
Advanced Playwright fixtures are reusable, composable test dependencies that handle authentication, APIs, test data, Page Objects, users, environment setup, cleanup, diagnostics, and other framework-level concerns.
How do I create a custom fixture in Playwright?
Use test.extend():
const test = base.extend<{
myFixture: string;
}>({
myFixture: async ({}, use) => {
await use(‘value’);
}
});
What is Playwright fixture composition?
Fixture composition is the practice of creating fixtures that depend on other fixtures, allowing complex test environments to be assembled from small reusable components.
Should Playwright fixtures be test-scoped or worker-scoped?
Use test scope for isolated mutable resources. Use worker scope for expensive resources that can safely be shared by tests in one worker.
Can Playwright fixtures handle authentication?
Yes. Authentication fixtures can log in through the UI, authenticate through an API, or reuse saved storageState.
Can fixtures create test data?
Yes. A fixture can create data before use() and remove it afterward.
Can custom fixtures work with Page Object Model?
Yes. A fixture can construct and provide a Page Object:
dashboardPage: async ({ page }, use) => {
await use(new DashboardPage(page));
}
Are Playwright custom fixtures useful for CI/CD?
Yes. They centralize authentication, environment configuration, test data, diagnostics, and cleanup, making automation more reliable in CI environments.
How do Playwright fixtures improve automation framework design?
They separate test intent from environment setup. This reduces duplication, improves maintainability, and provides a consistent dependency model for large test suites.
