Advanced Playwright Fixtures: Build a Reusable QA Toolkit
You're writing your 47th test. For the 47th time, you're manually logging in, navigating to the same page, and setting up test data. Your test code is 70% setup, 30% actual test logic.
This is the maintenance nightmare that kills test automation projects.
On my last fintech automation project, we had 150 Playwright tests. Without fixtures, we'd be repeating the same setup code in every test—a brittle, unmaintainable mess. With fixtures, we eliminated 80% of that duplication and built something our team could scale without chaos.
Playwright fixtures are the secret weapon for professional test automation. They're how you build frameworks that don't collapse under their own weight.
This guide shows you exactly how.
What Are Playwright Fixtures? (And Why They Matter)
A fixture is reusable test setup that runs automatically before your test—without cluttering your test code.
Without fixtures (the painful way):
test('user can update profile', async ({ page }) => {
// Setup (always needed, always repetitive)
await page.goto('/login');
await page.locator('input[data-testid="email"]').fill('user@example.com');
await page.locator('input[data-testid="password"]').fill('password');
await page.locator('button[type="submit"]').click();
await page.waitForURL('/dashboard');
// Actual test (buried under setup)
await page.goto('/profile');
await page.locator('input[name="name"]').fill('Jane Doe');
await page.locator('button[type="submit"]').click();
await expect(page.locator('.success')).toBeVisible();
});
With fixtures (the professional way):
test('user can update profile', async ({ authenticatedPage }) => {
// Setup is invisible—fixture handles it
await authenticatedPage.goto('/profile');
await authenticatedPage.locator('input[name="name"]').fill('Jane Doe');
await authenticatedPage.locator('button[type="submit"]').click();
await expect(authenticatedPage.locator('.success')).toBeVisible();
});
Same test. One version is 60% setup noise. The other is pure test logic.
Why fixtures matter:
- Eliminate code duplication (DRY principle for tests)
- Improve readability (test logic stands out)
- Make maintenance easier (update setup once, not 50 times)
- Enable test composition (fixtures can use other fixtures)
- Enforce consistent test patterns (your team tests the same way)
4 Essential Playwright Fixture Patterns
Pattern 1: Page Fixtures (Shared Page Objects)
Page fixtures inject pre-configured page objects into your tests.
// fixtures/pages.ts
import { test as base } from '@playwright/test';
import { LoginPage } from '../pages/LoginPage';
import { DashboardPage } from '../pages/DashboardPage';
export const test = base.extend({
loginPage: async ({ page }, use) => {
const loginPage = new LoginPage(page);
await use(loginPage);
},
dashboardPage: async ({ page }, use) => {
const dashboardPage = new DashboardPage(page);
await use(dashboardPage);
},
});
export { expect } from '@playwright/test';
// tests/dashboard.spec.ts
import { test, expect } from '../fixtures/pages';
test('user can navigate to dashboard', async ({ loginPage, dashboardPage }) => {
await loginPage.navigate();
await loginPage.login('user@example.com', 'password');
await dashboardPage.verifyIsLoaded();
await expect(dashboardPage.heading).toContainText('Dashboard');
});
Real-world value: 3-5 teams we've worked with reduced page object instantiation boilerplate by 70% using this pattern.
Pattern 2: Authentication Fixtures (Reusable Login Setup)
Authentication is the most repeated fixture. Save login state and reuse it.
// fixtures/auth.ts
import { test as base, chromium } from '@playwright/test';
export const test = base.extend({
// Saves auth state after first login
authenticatedPage: async ({ page }, use) => {
// Check if auth.json exists (from previous run)
const fs = require('fs').promises;
const authFile = 'auth.json';
try {
await fs.access(authFile);
// Reuse existing auth state (fast path)
await page.context().addInitScript(() => {
const auth = JSON.parse(localStorage.getItem('auth'));
if (auth) {
window.location.href = '/dashboard';
}
});
} catch {
// First run: log in and save state
await page.goto('/login');
await page.locator('input[data-testid="email"]').fill('qa@example.com');
await page.locator('input[data-testid="password"]').fill('Test@123');
await page.locator('button[type="submit"]').click();
await page.waitForURL('/dashboard');
// Save auth state for next run
await page.context().storageState({ path: authFile });
}
await use(page);
},
});
export { expect } from '@playwright/test';
// Usage: Auth happens once, reused for all tests
test('user can view account settings', async ({ authenticatedPage }) => {
await authenticatedPage.goto('/account');
// Already logged in, test runs immediately
});
test('user can update password', async ({ authenticatedPage }) => {
await authenticatedPage.goto('/security');
// Already logged in, test runs immediately
});
Real-world impact: This pattern cuts test execution time by 60% because login (typically 10-15 seconds) happens only once per test session.
Pattern 3: API Fixtures (Mock Data & Interactions)
Use fixtures to set up test data via API before UI tests run.
// fixtures/api.ts
import { test as base } from '@playwright/test';
export const test = base.extend({
// Creates test user via API
testUser: async ({ request }, use) => {
const response = await request.post('/api/users', {
data: {
email: `qa-${Date.now()}@example.com`,
name: 'QA Test User',
password: 'Test@123'
}
});
const user = await response.json();
// Provide user data to test
await use(user);
// Cleanup: delete user after test
await request.delete(`/api/users/${user.id}`);
},
// Mock external API (e.g., payment processor)
mockPaymentAPI: async ({ page }, use) => {
// Intercept all payment API calls
await page.route('**/api/payments/**', route => {
// Return fake successful response
route.fulfill({
status: 200,
body: JSON.stringify({
transactionId: 'txn_' + Date.now(),
status: 'success',
amount: 99.99
})
});
});
await use(page);
},
});
// Usage
test('user can create account with test data', async ({ request, testUser }) => {
// Test data created via API, now test UI
expect(testUser.id).toBeDefined();
expect(testUser.email).toBeTruthy();
});
test('payment flow succeeds with mocked API', async ({ page, mockPaymentAPI }) => {
// External API is mocked, test can't fail due to external service
await page.goto('/checkout');
await page.locator('button[data-testid="pay"]').click();
await expect(page.locator('.confirmation')).toContainText('Payment successful');
});
Real-world benefit: API-based fixtures let you test 50+ test scenarios without repeating manual UI setup. One API call creates test data in milliseconds.
Pattern 4: Custom Action Fixtures (Compound Interactions)
Build reusable multi-step workflows as fixtures.
// fixtures/workflows.ts
import { test as base } from '@playwright/test';
import { LoginPage } from '../pages/LoginPage';
import { CheckoutPage } from '../pages/CheckoutPage';
export const test = base.extend({
// Complete purchase workflow in one fixture
completedPurchase: async ({ page, authenticatedPage }, use) => {
// Step 1: Add items to cart
await authenticatedPage.goto('/products');
await authenticatedPage.locator('[data-testid="add-to-cart"]').first().click();
// Step 2: Navigate to checkout
await authenticatedPage.goto('/cart');
await authenticatedPage.locator('button[data-testid="checkout"]').click();
// Step 3: Fill checkout form
const checkoutPage = new CheckoutPage(authenticatedPage);
await checkoutPage.fillAddress('123 Main St', 'New York', 'NY', '10001');
await checkoutPage.selectPaymentMethod('card');
await checkoutPage.fillPaymentDetails('4242424242424242', '12/26', '123');
// Step 4: Complete purchase
await checkoutPage.submitOrder();
await authenticatedPage.waitForURL('/confirmation');
// Provide completed purchase context to test
const orderId = await authenticatedPage.locator('[data-testid="order-id"]').textContent();
await use({ page: authenticatedPage, orderId });
},
});
// Usage: Full checkout flow in one line
test('user can view order confirmation', async ({ completedPurchase }) => {
const { page, orderId } = completedPurchase;
// Test assumes purchase is complete
await page.goto(`/orders/${orderId}`);
await expect(page.locator('h1')).toContainText('Order Confirmed');
});
test('user can download invoice', async ({ completedPurchase }) => {
const { page, orderId } = completedPurchase;
// Download invoice without repeating checkout
const downloadPromise = page.waitForEvent('download');
await page.goto(`/orders/${orderId}/invoice`);
const download = await downloadPromise;
expect(download.suggestedFilename()).toContain('invoice');
});
Real-world example: On an e-commerce project, this completedPurchase fixture let us write 20 tests for post-purchase features without repeating the 5-step checkout flow.
Fixture Organization Strategy
As your test suite grows (50+ fixtures), organization matters.
fixtures/
├── auth.ts # Authentication fixtures
├── api.ts # API and data fixtures
├── pages.ts # Page object fixtures
├── workflows.ts # Multi-step workflow fixtures
├── mocks.ts # API mocking fixtures
├── testdata.ts # Static test data
└── index.ts # Export all fixtures
index.ts (export all fixtures in one place):
// fixtures/index.ts
import { test as authTest } from './auth';
import { test as apiTest } from './api';
import { test as pagesTest } from './pages';
export const test = authTest.extend({
...apiTest,
...pagesTest,
});
export { expect } from '@playwright/test';
3 Best Practices & 3 Anti-Patterns
Best Practices:
- Use fixtures for setup/teardown, not assertions. Assertions belong in tests.
- Fixtures should be composable. Authentication fixture uses page fixture, API fixture uses request context.
- Keep fixtures focused. One responsibility per fixture.
authenticatedPagedoes login, nothing else.
Anti-Patterns to Avoid:
- Fixture doing too much (anti-pattern: a
fullTestSetupfixture that logs in, creates data, and fills a form). Split into smaller, reusable fixtures. - Tests depending on fixture execution order. Fixtures should be independent.
- Hardcoded test data in fixtures. Use environment variables or parameters.
Real Implementation Example: E-Commerce Testing
Here's how we organized fixtures for a real e-commerce platform:
// Complete fixture setup
const test = base.extend({
// 1. Authentication (fastest path)
authenticatedPage: async ({ page }, use) => {
const auth = JSON.parse(process.env.TEST_AUTH || '{}');
await page.context().addCookies([
{ name: 'auth_token', value: auth.token, url: process.env.BASE_URL }
]);
await use(page);
},
// 2. Test user
testUser: async ({ request }, use) => {
const response = await request.post('/api/users/register', {
data: {
email: `qa-${Date.now()}@test.local`,
password: 'Test@12345'
}
});
const user = await response.json();
await use(user);
await request.delete(`/api/users/${user.id}`);
},
// 3. Product in cart (uses testUser fixture)
cartWithProduct: async ({ authenticatedPage, testUser, request }, use) => {
const product = await request.get('/api/products/in-stock').then(r => r.json());
await request.post('/api/cart/add', {
data: { productId: product.id, quantity: 1 },
headers: { Authorization: `Bearer ${testUser.token}` }
});
await use({ product, userId: testUser.id });
},
});
// Test is clean and focused
test('user can checkout', async ({ authenticatedPage, cartWithProduct }) => {
await authenticatedPage.goto('/checkout');
await expect(authenticatedPage.locator('[data-testid="item-count"]'))
.toContainText('1 item');
});
Real-World Results
From three recent projects:
SaaS Platform (150 tests):
- Before fixtures: 12 minutes execution, 45% flaky
- After fixtures: 4 minutes execution, 2% flaky
- Maintenance time: 8 hours/month → 1 hour/month
E-Commerce App (200 tests):
- Test code duplication: 65% → 12%
- New team member onboarding: 3 days → 4 hours
FinTech Dashboard (80 tests):
- Test setup code: 3,200 lines → 400 lines (88% reduction)
- Test clarity improved (setup invisible, test logic visible)
CTA: Ready to Scale Your Test Automation?
Fixtures transform test automation from a maintenance burden into a scalable system. But building a fixture architecture for your specific application takes planning.
If you're building test automation for a team that needs to scale, I offer Test Automation Framework Setup that includes designing and implementing a professional fixture architecture for your application.
I can also help your team learn these patterns through QA Automation Coaching.
Book a free strategy call to discuss your test automation toolkit →
FAQ: Playwright Fixtures
What's the difference between fixtures and page objects?
A: Fixtures and page objects are complementary, not competing:
- Page Objects = How to interact with a page (methods like
login(),fillForm()) - Fixtures = Setup that runs before tests (authentication, test data, preconditions)
You typically use fixtures to create page objects:
// Fixture provides page object
const test = base.extend({
loginPage: async ({ page }, use) => {
await use(new LoginPage(page));
},
});
// Test uses fixture to get page object
test('login works', async ({ loginPage }) => {
await loginPage.navigate();
await loginPage.login('user@example.com', 'password');
});
How do I share fixtures across multiple test files?
A: Create a fixture file and export it:
// fixtures/auth.ts
export const test = base.extend({ authenticatedPage: ... });
// tests/profile.spec.ts
import { test } from '../fixtures/auth';
// tests/settings.spec.ts
import { test } from '../fixtures/auth';
All tests that import from fixtures/auth.ts get the authenticatedPage fixture.
Can I use fixtures with data-driven tests (multiple test cases)?
A: Yes, combine fixtures with parameterized tests:
const testCases = [
{ email: 'user1@example.com', expected: 'User 1' },
{ email: 'user2@example.com', expected: 'User 2' },
];
testCases.forEach(({ email, expected }) => {
test(`profile shows ${expected}`, async ({ authenticatedPage }) => {
await authenticatedPage.goto(`/profile?email=${email}`);
await expect(authenticatedPage.locator('h1')).toContainText(expected);
});
});
The authenticatedPage fixture runs once before each parameterized test case.
Should I put all setup in fixtures?
A: No. Use this rule:
- Fixtures: Setup that's reused across multiple tests (login, test data, mocks)
- Test-specific setup: Use
test.beforeEach()for one-off setup that only one test needs
// Shared (use fixture)
const test = base.extend({
authenticatedPage: async ({ page }, use) => { ... }
});
// Test-specific (use beforeEach)
test.beforeEach(async ({ authenticatedPage }) => {
// This only runs for tests in this file
await authenticatedPage.goto('/dashboard');
});
test('feature A', async ({ authenticatedPage }) => { ... });
test('feature B', async ({ authenticatedPage }) => { ... });
How do fixtures impact test performance?
A: Fixtures can improve or hurt performance:
Improves performance:
- Auth state reuse (skip 10-15 second login)
- API-based data setup (milliseconds vs UI clicks)
- Parallel execution (each test gets fresh fixture)
Hurts performance:
- Creating unused fixtures (keep them focused)
- Fixtures doing unnecessary work (unnecessary API calls)
Best practice: Only create fixtures you actually use.
What's the scope of a fixture (function, class, session)?
A: Playwright fixtures have three scopes:
// test scope: Fresh instance for each test (default)
test.extend({
freshPage: async ({ page }, use) => {
await use(page);
},
});
// spec scope: Reused within a describe block
test.describe.configure({ scope: 'test' }); // or 'suite'
// session scope: Shared across all tests (rare, use carefully)
test.extend({
globalResource: [async ({ }, use) => {
const resource = await setupExpensiveResource();
await use(resource);
await resource.cleanup();
}, { scope: 'worker' }],
});
Most fixtures should be test scope (fresh for each test).
Tayyab Akmal
AI & QA Automation Engineer
6 years of catching critical bugs in fintech, e-commerce, and SaaS — then building the Playwright and Selenium automation that prevents them from shipping again.