Playwright Fixtures Mastery: Build Test Frameworks That Scale to 1,000+ Tests
If you have written more than fifty Playwright tests, you have almost certainly hit the same wall: every test needs a logged-in user. Every test needs a fresh project created. Every test needs the database seeded with specific data. And before you know it, 30% of your test file is beforeEach boilerplate.
Playwright fixtures are the architecture pattern that eliminates this problem entirely. They let you define reusable, composable test context — once — and inject it into any test that needs it. They are simultaneously the most underused and most impactful feature in the Playwright ecosystem.
This guide goes from the basics to production-grade fixture patterns used by teams with thousands of tests.
What Are Playwright Fixtures?
A fixture in Playwright is a piece of test state that is set up before a test runs and torn down after it completes. The key insight is that fixtures are dependency-injected — tests declare what they need, and Playwright handles the setup/teardown lifecycle automatically.
The built-in fixtures you already use every day are: page, browser, context, and request. When you write a test:
test('loads the homepage', async ({ page }) => {
await page.goto('/');
await expect(page).toHaveTitle('ScanlyApp');
});
page is a fixture. Playwright creates a fresh page for this test and disposes it after. You never call browser.newPage() or page.close().
Custom fixtures work exactly the same way.
Your First Custom Fixture: The Authenticated User
The most common custom fixture is an authenticated page — a page object that is already logged in. Without a fixture, every test that requires authentication repeats the same login steps.
Here is how to build it:
// tests/fixtures/auth.ts
import { test as base, expect } from '@playwright/test';
type AuthFixtures = {
authenticatedPage: Page;
adminPage: Page;
};
export const test = base.extend<AuthFixtures>({
// Standard authenticated user
authenticatedPage: async ({ page }, use) => {
// Perform login
await page.goto('/login');
await page.getByLabel('Email').fill(process.env.TEST_USER_EMAIL!);
await page.getByLabel('Password').fill(process.env.TEST_USER_PASSWORD!);
await page.getByRole('button', { name: 'Sign In' }).click();
await page.waitForURL('/dashboard');
// Yield the authenticated page to the test
await use(page);
// Teardown: logout after the test (optional)
// await page.goto('/logout');
},
// Admin user with elevated privileges
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();
},
});
export { expect };
Now every test that needs authentication simply uses authenticatedPage:
// tests/dashboard.spec.ts
import { test, expect } from './fixtures/auth';
test('dashboard shows user projects', async ({ authenticatedPage }) => {
await authenticatedPage.goto('/dashboard');
await expect(authenticatedPage.getByText('My Projects')).toBeVisible();
});
Performance Optimization: Reusing Authentication State
The authentication fixture above logs in for every test. For test suites with hundreds of tests, this is slow. Playwright's storageState feature lets you save an authenticated browser state to disk and reuse it across tests:
// tests/setup/global-setup.ts
import { chromium, FullConfig } from '@playwright/test';
async function globalSetup(config: FullConfig) {
const browser = await chromium.launch();
const page = await browser.newPage();
// Authenticate once
await page.goto('http://localhost:3000/login');
await page.getByLabel('Email').fill(process.env.TEST_USER_EMAIL!);
await page.getByLabel('Password').fill(process.env.TEST_USER_PASSWORD!);
await page.getByRole('button', { name: 'Sign In' }).click();
await page.waitForURL('/dashboard');
// Save storage state (cookies + localStorage)
await page.context().storageState({ path: 'playwright/.auth/user.json' });
await browser.close();
}
export default globalSetup;
// playwright.config.ts
export default defineConfig({
globalSetup: require.resolve('./tests/setup/global-setup'),
use: {
storageState: 'playwright/.auth/user.json', // applied globally
},
});
With this pattern, login happens once per test run, not once per test. On a 300-test suite, this alone can cut run time by 40%.
Composing Fixtures: Building Complex Test Contexts
Fixtures can depend on other fixtures. This composability is what makes them so powerful for realistic test scenarios:
// tests/fixtures/project.ts
import { test as authTest } from './auth';
type ProjectFixtures = {
testProject: { id: string; name: string };
};
export const test = authTest.extend<ProjectFixtures>({
// Creates a fresh project before the test, deletes it after
testProject: async ({ request }, use) => {
// Setup: create a project via API
const response = await request.post('/api/projects', {
data: { name: `Test Project ${Date.now()}` },
});
const project = await response.json();
// Yield the created project to the test
await use(project);
// Teardown: delete the project
await request.delete(`/api/projects/${project.id}`);
},
});
Now tests get both an authenticated page AND a freshly created project:
// tests/project-management.spec.ts
import { test, expect } from './fixtures/project';
test('can add a member to a project', async ({ authenticatedPage, testProject }) => {
await authenticatedPage.goto(`/projects/${testProject.id}/settings/members`);
await authenticatedPage.getByRole('button', { name: 'Invite Member' }).click();
// ...
});
Each test gets a fresh, isolated project. No test pollution. No cleanup logic in the test body.
Fixture Scope: Controlling Lifecycle
Playwright fixtures support four scope settings that control how often setup/teardown runs:
| Scope | Lifecycle | Best For |
|---|---|---|
test (default) |
Once per test | Fresh state per test (most isolated) |
worker |
Once per worker process | Shared auth state, shared browser |
file |
Once per test file | File-level setup (rarely used) |
global |
Once per test run | Database seeding, external service setup |
// Worker-scoped fixture: re-uses the same authenticated context across
// all tests in a worker (faster, less isolation)
export const test = base.extend<{}, { workerAuthContext: BrowserContext }>({
workerAuthContext: [
async ({ browser }, use) => {
const context = await browser.newContext({
storageState: 'playwright/.auth/user.json',
});
await use(context);
await context.close();
},
{ scope: 'worker' },
],
});
The right scope depends on your isolation requirements versus your performance needs. For most teams: test scope for data fixtures, worker scope for authentication.
The Page Object Model + Fixtures: A Winning Combination
Fixtures pair naturally with the Page Object Model (POM). Instead of exposing raw page objects, your fixture can inject fully encapsulated page objects:
// tests/fixtures/pages.ts
import { DashboardPage } from '../pages/DashboardPage';
import { BillingPage } from '../pages/BillingPage';
export const test = base.extend<{
dashboardPage: DashboardPage;
billingPage: BillingPage;
}>({
dashboardPage: async ({ page }, use) => {
await use(new DashboardPage(page));
},
billingPage: async ({ page }, use) => {
await use(new BillingPage(page));
},
});
Tests become beautifully readable:
test('user can view billing history', async ({ billingPage }) => {
await billingPage.navigate();
await billingPage.expectInvoiceCount(3);
await billingPage.downloadFirstInvoice();
await billingPage.expectDownloadSuccess();
});
No setup noise. No teardown noise. Pure behavior description.
For more on the Page Object Model pattern and other structural approaches, see our guide on test automation design patterns.
Debugging Fixtures
When a fixture fails, Playwright wraps the error with context about which fixture caused it. But here are practical debugging tips:
// Add verbose logging to fixtures during development
export const test = base.extend<{ trackedPage: Page }>({
trackedPage: async ({ page }, use, testInfo) => {
console.log(`[FIXTURE] Setting up trackedPage for "${testInfo.title}"`);
await use(page);
console.log(`[FIXTURE] Tearing down trackedPage for "${testInfo.title}"`);
},
});
You can also attach fixture state to test reports:
testProject: async ({ request }, use, testInfo) => {
const project = await createProject(request);
// Attach for debugging in reports
await testInfo.attach('created-project', {
body: JSON.stringify(project, null, 2),
contentType: 'application/json'
});
await use(project);
await deleteProject(request, project.id);
},
A Realistic Fixture Architecture for a SaaS Product
Here is the full fixture hierarchy for a typical SaaS QA setup:
fixtures/
├── base.ts ← Extends @playwright/test with global config
├── auth.ts ← authenticatedPage, adminPage
├── data/
│ ├── project.ts ← testProject (creates + cleans up)
│ ├── scan.ts ← testScan (creates + cleans up)
│ └── team.ts ← testTeam (creates + cleans up)
├── pages/
│ ├── dashboard.ts ← DashboardPage POM fixture
│ └── billing.ts ← BillingPage POM fixture
└── index.ts ← Barrel export of composed test object
The index.ts barrel creates a single test object that composes all fixtures:
// tests/fixtures/index.ts
import { test as withAuth } from './auth';
import { test as withProject } from './data/project';
import { test as withPages } from './pages';
// Final composed test — imports this everywhere
export const test = withPages.extend({
...withProject.fixtures,
...withAuth.fixtures,
});
export { expect } from '@playwright/test';
Now every test file imports from a single consistent source:
import { test, expect } from '../fixtures';
Connecting to Continuous Monitoring
Well-architected fixtures make it dramatically easier to run your tests in CI/CD and against production monitoring pipelines. When your test setup is encapsulated in fixtures:
- Tests are easier to run as post-deploy smoke checks
- Fixtures can be parameterized to point at different environments (staging vs. production)
- Test isolation prevents interference when multiple environments are scanned simultaneously
This connects directly to ScanlyApp's continuous scan model: your most important user journey tests, built on solid fixtures, become the foundation for scheduled post-deployment verification.
From local tests to production monitoring: Try ScanlyApp free and extend the value of your Playwright investment to production-level monitoring.
Summary: The Fixture Hierarchy to Aim For
graph TD
A[Raw Playwright page] --> B[Auth Fixture\nauthenticatedPage]
B --> C[Data Fixtures\ntestProject, testScan]
C --> D[Page Object Fixtures\ndashboardPage, billingPage]
D --> E[Test File\nclean, readable tests]
The payoff for investing in a clean fixture architecture is enormous:
- Onboarding time drops — new engineers understand the test suite faster
- Test quality improves — developers add tests because it is easy, not painful
- Maintenance cost falls — fixes to common flows happen in one place (the fixture), not across 50 files
- Execution speed increases — worker-scoped fixtures eliminate repeated setup overhead
Start with authentication. It is the most impactful fixture in almost every web application. Then build data fixtures for your most common test resources. In a few sprints, you will have a test architecture that scales from 50 tests to 500 without entropy.
Further Reading
- Playwright Fixtures — Official Documentation: Deep dive into built-in fixture scopes (test, worker), dependency injection, and fixture composition patterns
- Page Object Models in Playwright: The official guide to structuring large test suites with reusable page objects
- Playwright Test Isolation: How Playwright enforces isolation between tests using browser contexts
- Playwright Configuration Reference: All
playwright.config.tsoptions including timeout, retries, and project setup
Related articles: Also see how fixtures enable clean multi-user and multi-tab scenarios, combining fixtures with network mocking for isolated tests, and applying fixture architecture inside BDD step definitions.
Building a Playwright suite? Try ScanlyApp to extend your test coverage to production monitoring — schedule scans that run your most critical flows against your live application.
