Fixtures & Test Isolation
Use fixtures to set up and share test context efficiently
Fixtures & Test Isolation
In the previous tutorial, we learned how to structure tests with hooks and groups. Now let's level up with Playwright's secret weapon: fixtures.
Fixtures are like dependency injection for your tests — instead of setting things up manually, you declare what you need and Playwright provides it. Clean, reusable, and automatic cleanup included.
What Are Fixtures?
"What's a fixture? Like a light fixture?"
Think of fixtures as "things your test needs." Instead of creating them yourself, you ask for them:
// The { page } part? That's a fixture being injected
test('example', async ({ page }) => {
await page.goto('/');
});
You didn't create page. Playwright did. And when the test ends, Playwright cleans it up. That's the beauty of fixtures.
Built-in Fixtures
Playwright comes with several fixtures ready to use.
page
A fresh browser page for each test. This is what you use 99% of the time.
test('uses page fixture', async ({ page }) => {
await page.goto('https://example.com');
await expect(page).toHaveTitle('Example Domain');
});
Each test gets a new page — cookies, localStorage, everything is fresh. No test can mess up another test. How cool is that?
context
The browser context that owns the page. Use it when you need multiple pages or custom context settings.
test('multiple tabs', async ({ context }) => {
// Create two pages in the same context (share cookies, etc.)
const page1 = await context.newPage();
const page2 = await context.newPage();
await page1.goto('/login');
await page1.getByLabel('Email').fill('user@test.com');
await page1.getByRole('button', { name: 'Login' }).click();
// page2 shares the login session
await page2.goto('/dashboard');
await expect(page2.getByText('Welcome')).toBeVisible();
});
browser
The browser instance itself. Rarely needed, but useful for creating multiple isolated contexts.
test('two users chatting', async ({ browser }) => {
// Create two isolated contexts (different users)
const userAContext = await browser.newContext();
const userBContext = await browser.newContext();
const userAPage = await userAContext.newPage();
const userBPage = await userBContext.newPage();
// Login as different users
await loginAs(userAPage, 'alice@test.com');
await loginAs(userBPage, 'bob@test.com');
// Test chat between them
await userAPage.getByLabel('Message').fill('Hello Bob!');
await userAPage.getByRole('button', { name: 'Send' }).click();
await expect(userBPage.getByText('Hello Bob!')).toBeVisible();
// Clean up
await userAContext.close();
await userBContext.close();
});
request
API request context for making HTTP calls without a browser.
test('API test', async ({ request }) => {
const response = await request.get('/api/users');
expect(response.ok()).toBeTruthy();
const users = await response.json();
expect(users.length).toBeGreaterThan(0);
});
test('create via API, verify via UI', async ({ page, request }) => {
// Create data via API
await request.post('/api/products', {
data: { name: 'Test Product', price: 99 }
});
// Verify in UI
await page.goto('/products');
await expect(page.getByText('Test Product')).toBeVisible();
});
browserName
The current browser being used. Great for conditional logic.
test('browser-specific test', async ({ page, browserName }) => {
await page.goto('/');
if (browserName === 'webkit') {
// Safari-specific behavior
await expect(page.locator('.safari-notice')).toBeVisible();
}
test.skip(browserName === 'firefox', 'Feature not supported in Firefox');
});
Creating Custom Fixtures
"The built-in ones are great, but what if I need something specific?"
Here's where fixtures get really powerful. Define your own setup and Playwright handles the rest.
Basic Fixture
Create a file called fixtures.ts:
// fixtures.ts
import { test as base } from '@playwright/test';
// Define a simple fixture
export const test = base.extend<{
homePage: string;
}>({
homePage: async ({}, use) => {
// Provide the fixture value
await use('https://myapp.com');
},
});
export { expect } from '@playwright/test';
Use it in tests:
// my-test.spec.ts
import { test, expect } from './fixtures';
test('uses custom fixture', async ({ page, homePage }) => {
await page.goto(homePage);
// ...
});
Fixture with Setup and Teardown
The real power — setup before, cleanup after:
// fixtures.ts
import { test as base } from '@playwright/test';
type TestUser = {
email: string;
password: string;
cleanup: () => Promise<void>;
};
export const test = base.extend<{
testUser: TestUser;
}>({
testUser: async ({ request }, use) => {
// SETUP: Create a user via API
const response = await request.post('/api/test-users', {
data: { email: `test-${Date.now()}@example.com` }
});
const user = await response.json();
// PROVIDE: Give the user to the test
await use({
email: user.email,
password: user.password,
cleanup: async () => {
await request.delete(`/api/test-users/${user.id}`);
}
});
// TEARDOWN: Clean up after test
await request.delete(`/api/test-users/${user.id}`);
},
});
Use it:
import { test, expect } from './fixtures';
test('new user can access dashboard', async ({ page, testUser }) => {
await page.goto('/login');
await page.getByLabel('Email').fill(testUser.email);
await page.getByLabel('Password').fill(testUser.password);
await page.getByRole('button', { name: 'Sign in' }).click();
await expect(page).toHaveURL('/dashboard');
// User is automatically deleted after this test!
});
Logged-In User Fixture
The most common custom fixture — starting tests already logged in:
// fixtures.ts
import { test as base, Page } from '@playwright/test';
export const test = base.extend<{
authenticatedPage: Page;
}>({
authenticatedPage: async ({ page }, use) => {
// Login
await page.goto('/login');
await page.getByLabel('Email').fill('testuser@example.com');
await page.getByLabel('Password').fill('password123');
await page.getByRole('button', { name: 'Sign in' }).click();
await page.waitForURL('/dashboard');
// Provide the logged-in page
await use(page);
// Logout after test (optional cleanup)
await page.goto('/logout');
},
});
Use it:
import { test, expect } from './fixtures';
test('logged-in user can see profile', async ({ authenticatedPage }) => {
await authenticatedPage.goto('/profile');
await expect(authenticatedPage.getByText('Your Profile')).toBeVisible();
});
Storage State Fixture (Faster Login)
Instead of logging in every test, save the session once:
// fixtures.ts
import { test as base } from '@playwright/test';
import path from 'path';
const authFile = path.join(__dirname, '../.auth/user.json');
export const test = base.extend({
// Override the storageState fixture
storageState: async ({}, use) => {
await use(authFile);
},
});
First, create the auth file with a setup project:
// playwright.config.ts
export default defineConfig({
projects: [
{
name: 'setup',
testMatch: /.*\.setup\.ts/,
},
{
name: 'tests',
dependencies: ['setup'],
use: {
storageState: '.auth/user.json',
},
},
],
});
// auth.setup.ts
import { test as setup } from '@playwright/test';
setup('authenticate', async ({ page }) => {
await page.goto('/login');
await page.getByLabel('Email').fill('user@example.com');
await page.getByLabel('Password').fill('password123');
await page.getByRole('button', { name: 'Sign in' }).click();
await page.waitForURL('/dashboard');
// Save session
await page.context().storageState({ path: '.auth/user.json' });
});
Now all tests start logged in, without logging in each time! Boom!
Fixture Scopes
Test Scope (Default)
Creates a new fixture instance for each test:
export const test = base.extend<{
randomId: string;
}>({
randomId: async ({}, use) => {
const id = `id-${Math.random().toString(36).slice(2)}`;
console.log('Creating:', id); // Runs for EACH test
await use(id);
},
});
Worker Scope
Creates one fixture instance per worker (shared across tests in that worker):
export const test = base.extend<
{}, // test-scoped fixtures (empty)
{ expensiveResource: DatabaseConnection } // worker-scoped fixtures
>({
expensiveResource: [async ({}, use) => {
// This runs ONCE per worker, not per test
console.log('Setting up database connection...');
const db = await connectToDatabase();
await use(db);
console.log('Closing database connection...');
await db.close();
}, { scope: 'worker' }],
});
Use worker scope for:
- Database connections
- Expensive setup
- Shared resources that are read-only
Automatic Fixtures
"Can I make a fixture run for every test without requesting it?"
Yep! Some fixtures should always run, even if the test doesn't ask for them:
export const test = base.extend<{
autoLogger: void;
}>({
autoLogger: [async ({ page }, use) => {
// Log all console messages
page.on('console', msg => console.log('PAGE LOG:', msg.text()));
await use();
}, { auto: true }], // Runs automatically
});
Now every test gets console logging without explicitly requesting it.
Combining Fixtures
Fixtures can use other fixtures:
export const test = base.extend<{
testUser: { email: string; password: string };
loggedInPage: Page;
}>({
testUser: async ({ request }, use) => {
const response = await request.post('/api/test-users');
const user = await response.json();
await use(user);
await request.delete(`/api/users/${user.id}`);
},
// This fixture uses testUser
loggedInPage: async ({ page, testUser }, use) => {
await page.goto('/login');
await page.getByLabel('Email').fill(testUser.email);
await page.getByLabel('Password').fill(testUser.password);
await page.getByRole('button', { name: 'Sign in' }).click();
await page.waitForURL('/dashboard');
await use(page);
},
});
Use it:
test('dashboard shows user email', async ({ loggedInPage, testUser }) => {
// loggedInPage is logged in as testUser
await expect(loggedInPage.getByText(testUser.email)).toBeVisible();
});
Overriding Fixtures
Override built-in fixtures for all tests:
// Use mobile viewport by default
export const test = base.extend({
page: async ({ page }, use) => {
await page.setViewportSize({ width: 375, height: 667 });
await use(page);
},
});
Or override per-describe:
test.describe('mobile', () => {
test.use({ viewport: { width: 375, height: 667 } });
test('mobile layout works', async ({ page }) => { /* ... */ });
});
Fixture Options
Make fixtures configurable:
type MyOptions = {
defaultUser: { email: string; password: string };
};
export const test = base.extend<MyOptions & { loggedInPage: Page }>({
// Define the option with a default
defaultUser: [{ email: 'default@test.com', password: 'pass123' }, { option: true }],
// Use the option in a fixture
loggedInPage: async ({ page, defaultUser }, use) => {
await page.goto('/login');
await page.getByLabel('Email').fill(defaultUser.email);
await page.getByLabel('Password').fill(defaultUser.password);
await page.getByRole('button', { name: 'Sign in' }).click();
await use(page);
},
});
Override in config:
// playwright.config.ts
export default defineConfig({
use: {
defaultUser: { email: 'custom@test.com', password: 'custom123' },
},
});
Or per-test:
test.use({ defaultUser: { email: 'admin@test.com', password: 'admin' } });
test('admin can see users list', async ({ loggedInPage }) => {
// Logged in as admin
});
Real-World Fixture File
Here's a complete, practical fixtures file:
// fixtures.ts
import { test as base, Page } from '@playwright/test';
type TestUser = {
id: string;
email: string;
password: string;
};
type MyFixtures = {
testUser: TestUser;
authenticatedPage: Page;
adminPage: Page;
};
export const test = base.extend<MyFixtures>({
// Create a test user
testUser: async ({ request }, use) => {
const response = await request.post('/api/test/users', {
data: { role: 'user' }
});
const user = await response.json();
await use(user);
// Cleanup
await request.delete(`/api/test/users/${user.id}`);
},
// Logged-in page
authenticatedPage: async ({ page, testUser }, use) => {
await page.goto('/login');
await page.getByLabel('Email').fill(testUser.email);
await page.getByLabel('Password').fill(testUser.password);
await page.getByRole('button', { name: 'Sign in' }).click();
await page.waitForURL('/dashboard');
await use(page);
},
// Admin page
adminPage: async ({ browser, request }, use) => {
// Create admin via API
const response = await request.post('/api/test/users', {
data: { role: 'admin' }
});
const admin = await response.json();
// Login in new context
const context = await browser.newContext();
const page = await context.newPage();
await page.goto('/login');
await page.getByLabel('Email').fill(admin.email);
await page.getByLabel('Password').fill(admin.password);
await page.getByRole('button', { name: 'Sign in' }).click();
await page.waitForURL('/admin');
await use(page);
// Cleanup
await context.close();
await request.delete(`/api/test/users/${admin.id}`);
},
});
export { expect } from '@playwright/test';
What's Next?
Fixtures are now your superpower:
- Built-in:
page,context,browser,request,browserName - Custom fixtures handle setup and teardown automatically
- Test scope (per-test) vs worker scope (shared)
auto: truefor fixtures that always run- Fixtures can depend on other fixtures
- Override fixtures per-describe or in config
Fixtures handle setup. But as your test suite grows, you'll want to organize your page interactions too. Enter the Page Object Model. Let's go!