Fixtures and Auth State Reuse in Playwright: Advanced Patterns
Every time you run a test that needs a logged-in user, logging in from scratch takes 500ms–2s. For a test suite of 50 tests, that's 25–100 seconds wasted just logging in. Playwright fixtures are reusable test setup modules that run once and are cached across tests, letting you log in once and reuse the authenticated state for multiple tests. This article covers fixtures, authentication state persistence, and advanced patterns that can cut your test suite runtime in half.
What Are Playwright Fixtures?
A fixture is a piece of setup code that runs before a test and is torn down afterward. Playwright provides built-in fixtures like page (a browser tab) and context (a browser session with cookies/storage). You can create custom fixtures for your application-specific needs: authenticated users, database seeding, API clients, etc.
Fixtures are declared in the test file and injected as parameters:
import { test, expect } from '@playwright/test';
// This is a fixture declaration
test.extend({
authenticatedPage: async ({ page }, use) => {
// Setup: log in
await page.goto('/login');
await page.locator('[data-testid="email"]').fill('[email protected]');
await page.locator('[data-testid="password"]').fill('TestPassword123');
await page.locator('role=button[name="Sign In"]').click();
await expect(page).toHaveURL('/dashboard');
// Yield the page to the test
await use(page);
// Teardown: (logout if needed)
// await page.locator('[data-testid="logout"]').click();
},
});
// Use the fixture
test('user can view dashboard', async ({ authenticatedPage }) => {
// authenticatedPage is already logged in
await expect(authenticatedPage.locator('h1')).toContainText('Dashboard');
});
This test skips the login overhead because the fixture handles it.
Saving and Reusing Authentication State
Logging in is the slowest part of many tests. Playwright can save the browser's cookies and localStorage after login, then restore them for subsequent tests, skipping the login entirely. This reduces login overhead from ~1s per test to ~10ms:
import { test, expect, devices } from '@playwright/test';
test.describe('authenticated user tests', () => {
// Once-per-file setup: log in and save auth state
test.beforeAll(async ({ browser }) => {
const context = await browser.newContext();
const page = context.createPage();
// Log in
await page.goto('http://localhost:5173/login');
await page.locator('[data-testid="email"]').fill('[email protected]');
await page.locator('[data-testid="password"]').fill('TestPassword123');
await page.locator('role=button[name="Sign In"]').click();
// Wait for login to complete
await expect(page).toHaveURL('http://localhost:5173/dashboard');
// Save auth state (cookies, localStorage, sessionStorage)
await context.storageState({ path: 'auth.json' });
await context.close();
});
// For each test, restore the saved auth state
test.use({ storageState: 'auth.json' });
test('user can view dashboard', async ({ page }) => {
await page.goto('/dashboard');
await expect(page.locator('h1')).toContainText('Dashboard');
});
test('user can view profile', async ({ page }) => {
await page.goto('/profile');
await expect(page.locator('h1')).toContainText('Profile');
});
test('user can view settings', async ({ page }) => {
await page.goto('/settings');
await expect(page.locator('h1')).toContainText('Settings');
});
});
This pattern saves auth state once before all tests, then restores it for each test—reducing login overhead by 99%.
Creating Reusable Global Fixtures
For production test suites, define global fixtures in a setup file that all tests share. Create tests/fixtures.ts:
import { test as base, expect } from '@playwright/test';
type Fixtures = {
authenticatedPage: Page;
authenticatedContext: BrowserContext;
};
export const test = base.extend<Fixtures>({
authenticatedPage: async ({ page }, use) => {
// Log in
await page.goto('/login');
await page.locator('[data-testid="email"]').fill('[email protected]');
await page.locator('[data-testid="password"]').fill('TestPassword123');
await page.locator('role=button[name="Sign In"]').click();
// Wait for login
await expect(page).toHaveURL('/dashboard');
// Provide the authenticated page to the test
await use(page);
// Cleanup (optional)
// Tests are isolated, so cleanup isn't strictly necessary
},
authenticatedContext: async ({ browser }, use) => {
// Create a new context and log in
const context = await browser.newContext();
const page = context.createPage();
await page.goto('/login');
await page.locator('[data-testid="email"]').fill('[email protected]');
await page.locator('[data-testid="password"]').fill('TestPassword123');
await page.locator('role=button[name="Sign In"]').click();
await expect(page).toHaveURL('/dashboard');
// Save auth state so subsequent context creations reuse it
await context.storageState({ path: 'auth.json' });
await use(context);
await context.close();
},
});
export { expect };
Then, in your tests, import from fixtures instead of @playwright/test:
import { test, expect } from './fixtures';
test('user can view dashboard', async ({ authenticatedPage }) => {
await expect(authenticatedPage.locator('h1')).toContainText('Dashboard');
});
test('user can update profile', async ({ authenticatedPage }) => {
await authenticatedPage.goto('/profile');
await authenticatedPage.locator('[data-testid="name"]').fill('New Name');
await authenticatedPage.locator('role=button[name="Save"]').click();
await expect(authenticatedPage.locator('.success-message')).toContainText('Profile updated');
});
All tests using authenticatedPage share the same login session, eliminating redundant authentication.
Testing Multiple User Roles with Fixtures
Your application likely has different user roles (admin, user, guest). Test each with dedicated fixtures:
import { test as base, expect } from '@playwright/test';
type Fixtures = {
adminPage: Page;
userPage: Page;
guestPage: Page;
};
export const test = base.extend<Fixtures>({
adminPage: async ({ page }, use) => {
await page.goto('/login');
await page.locator('[data-testid="email"]').fill('[email protected]');
await page.locator('[data-testid="password"]').fill('AdminPass123');
await page.locator('role=button[name="Sign In"]').click();
await expect(page).toHaveURL('/admin-dashboard');
await use(page);
},
userPage: async ({ page }, use) => {
await page.goto('/login');
await page.locator('[data-testid="email"]').fill('[email protected]');
await page.locator('[data-testid="password"]').fill('UserPass123');
await page.locator('role=button[name="Sign In"]').click();
await expect(page).toHaveURL('/dashboard');
await use(page);
},
guestPage: async ({ page }, use) => {
// Guest is unauthenticated, just load the home page
await page.goto('/');
await use(page);
},
});
export { expect };
Then test role-based access control:
import { test, expect } from './fixtures';
test('admin can access admin panel', async ({ adminPage }) => {
await adminPage.goto('/admin');
await expect(adminPage.locator('h1')).toContainText('Admin Panel');
});
test('user cannot access admin panel', async ({ userPage }) => {
await userPage.goto('/admin');
// Should redirect or show error
await expect(userPage.locator('text=Access Denied')).toBeVisible();
});
test('guest sees login button in navbar', async ({ guestPage }) => {
await expect(guestPage.locator('role=button[name="Login"]')).toBeVisible();
});
This pattern makes it easy to test permissions and multi-user workflows.
Combining Fixtures with Database Seeding
For complex tests, you might need to seed the database with test data (users, products, orders). Combine fixtures with your test API:
import { test as base, expect } from '@playwright/test';
type Fixtures = {
seedUser: any;
authenticatedPageWithData: Page;
};
export const test = base.extend<Fixtures>({
seedUser: async ({}, use) => {
// Create a test user via API
const response = await fetch('http://localhost:3000/api/test/create-user', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
email: `test-${Date.now()}@example.com`,
password: 'TestPassword123',
role: 'user',
}),
});
const user = await response.json();
// Provide user data to the test
await use(user);
// Cleanup: delete the test user
await fetch(`http://localhost:3000/api/test/delete-user/${user.id}`, {
method: 'DELETE',
});
},
authenticatedPageWithData: async ({ page, seedUser }, use) => {
// Log in with the seeded user
await page.goto('/login');
await page.locator('[data-testid="email"]').fill(seedUser.email);
await page.locator('[data-testid="password"]').fill('TestPassword123');
await page.locator('role=button[name="Sign In"]').click();
await expect(page).toHaveURL('/dashboard');
await use(page);
},
});
export { expect };
Now tests automatically get a unique user with isolated data:
test('user can view personalized dashboard', async ({ authenticatedPageWithData }) => {
await expect(authenticatedPageWithData.locator('h1')).toContainText('Welcome');
});
Performance Tips for Fixture-Based Tests
- Cache auth state across test runs: Save
auth.jsonin your.gitignoreand use it across test runs to avoid re-logging in:
import fs from 'fs';
const authFile = 'auth.json';
test.beforeAll(async ({ browser }) => {
// Check if auth state already exists
if (fs.existsSync(authFile)) {
return; // Reuse cached auth
}
// Otherwise, create it
const context = await browser.newContext();
// ... log in and save ...
});
-
Use lightweight authentication for fast tests: API-based login (returning a token you inject into cookies) is faster than UI-based login.
-
Isolate fixtures by scope: Use
test.beforeAll()for setup that runs once per file,test.beforeEach()for per-test setup. -
Monitor fixture overhead: If a fixture takes >1s, investigate optimizing the setup (e.g., API-based auth instead of UI login).
Key Takeaways
- Fixtures are reusable setup modules that cache state across tests, eliminating redundant login steps.
- Save and restore authentication state with
storageStateto skip login for subsequent tests. - Create role-based fixtures (
adminPage,userPage) to test access control and multi-role workflows. - Combine fixtures with API-based database seeding for complex tests with isolated data.
- Monitor fixture performance—if setup takes >1s per test, optimize to keep suites fast.
Frequently Asked Questions
What's the difference between test.beforeEach() and a fixture?
test.beforeEach() runs before each test in sequence. Fixtures are injectable and can be cached/reused. For auth state, fixtures are superior because they allow Playwright to parallelize tests while reusing the same auth session.
Can I use the same auth state for multiple browsers?
Yes, but be cautious. Different browsers (Chromium, Firefox, WebKit) might store cookies differently. For cross-browser tests, save separate auth states per browser or use API-based authentication that doesn't rely on cookies.
How do I handle token expiration in fixtures?
If your app's auth tokens expire quickly, set a long session timeout for test users or use a long-lived test token. Alternatively, inject tokens via API before tests run instead of logging in via the UI.
Can I nest fixtures (one fixture depending on another)?
Yes, pass fixtures as parameters. Example:
authenticatedPageWithData: async ({ page, seedUser }, use) => {
// seedUser is another fixture
// ...
}
How do I test logout with fixtures?
Logout tests should bypass fixtures and test logout directly:
test('user can log out', async ({ page }) => {
// Log in manually (don't use fixture)
await page.goto('/login');
await page.locator('[data-testid="email"]').fill('[email protected]');
await page.locator('[data-testid="password"]').fill('TestPassword123');
await page.locator('role=button[name="Sign In"]').click();
// Now test logout
await page.locator('[data-testid="logout"]').click();
// Verify redirect
await expect(page).toHaveURL('/login');
});