In modern web applications, testing different user roles and permissions is not just a good idea—it's a necessity. How do you efficiently test an admin, an editor, and a viewer in a single test suite? How do you ensure these tests can run in parallel without stepping on each other's toes? The answer lies in building a robust and scalable testing architecture.
With Playwright, the fixtures is our best friend. It allows us to abstract away complex setup and teardown logic to a dependency injection features, providing our tests with the exact environment they need to run.
Today, we're going to build a powerful, worker-scoped Playwright fixture that manages multiple user contexts. This solution is designed for scalability, allowing you to run tests in parallel, each with its own isolated user session and browser context.
The Goal: A Multi-User, Parallel-Ready Fixture
Our objective is to create a system where we can tag a test, and Playwright will automatically:
- Select the correct user based on the tag (e.g.,
@admin
,@viewer
). - Log that user in and save their authentication state.
- Create a unique browser context for that user, isolated from other parallel test workers.
- Provide the test with a ready-to-use
page
object. - Clean up everything after the test is done.
Let's dive into the implementation.
The Complete Fixture Code
Here is the complete solution. We will break it down piece by piece in the following sections.
import {
Browser,
BrowserContext,
BrowserContextOptions,
Page,
test as base,
} from '@playwright/test';
import fs from 'fs/promises';
/**
* Creates a browser context with consistent configuration.
* @param browser Playwright browser instance
* @param baseURL Base URL for the context
* @param storageState Optional path to storage state file
* @returns Configured browser context
*/
export async function createBrowserContext(
browser: Browser,
baseURL: string,
storageState?: string,
): Promise<BrowserContext> {
const storageStatePath = storageState;
// Desktop context configuration (1440x900)
const DESKTOP_CONFIG = {
userAgent:
'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36',
viewport: { width: 1440, height: 900 },
isMobile: false,
hasTouch: false,
};
// iPhone 13 context configuration
const IPHONE_13_CONFIG = {
userAgent:
'Mozilla/5.0 (iPhone; CPU iPhone OS 17_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.1 Mobile/15E148 Safari/604.1',
viewport: { width: 390, height: 844 },
isMobile: true,
hasTouch: true,
};
// Get context configuration based on test tags
const getContextConfig = () => {
const testInfo = test.info();
return testInfo.tags.includes('@mobile')
? IPHONE_13_CONFIG
: DESKTOP_CONFIG;
};
const config = getContextConfig();
const contextOptions: BrowserContextOptions = {
baseURL,
storageState: storageStatePath,
ignoreHTTPSErrors: true,
userAgent: config.userAgent,
viewport: config.viewport,
isMobile: config.isMobile,
hasTouch: config.hasTouch,
permissions: ['geolocation'],
geolocation: { latitude: 32.169407, longitude: -110.850961 },
// Performance optimizations
reducedMotion: 'reduce',
forcedColors: 'none',
colorScheme: 'light',
// Network optimizations
offline: false,
acceptDownloads: true,
bypassCSP: true,
// Resource optimizations
javaScriptEnabled: true,
locale: 'en-US',
timezoneId: 'America/Chicago',
extraHTTPHeaders: {
'Cache-Control': 'no-cache',
Pragma: 'no-cache',
},
};
const context = await browser.newContext(contextOptions);
return context;
}
export interface UserInfo {
alias: string;
tags: string[];
userName: string;
email: string;
password: string;
storedContextPath: string;
}
type Fixtures = {
_testCleanup: void;
};
type WorkerFixtures = {
multiUserContext: {
browserContext: BrowserContext;
initializeBrowserContext(): Promise<BrowserContext>;
cleanupBrowserContext(): Promise<void>;
cleanupBrowserContextForTest(testId: string): Promise<void>;
getSelectedUser(): UserInfo;
cleanupAll(): Promise<void>;
cleanupAllForTest(testId: string): Promise<void>;
forceCleanupForCurrentTest(): Promise<void>;
};
};
export const test = base.extend<Fixtures, WorkerFixtures>({
multiUserContext: [
async (
{ browser },
use,
) => {
const testInfo = test.info();
const parallelIndex = testInfo.parallelIndex;
let users: UserInfo[] = [
{
alias: 'admin',
tags: ['@admin', '@fullAccess'],
userName: `admin`,
email: 'admin@test.io',
password: 'your_password',
storedContextPath: `.state/admin-p${parallelIndex}.json`,
},
{
alias: 'viewer',
tags: ['@viewOnly', '@viewer'],
userName: `viewer`,
email: 'viewer@test.io',
password: 'your_password',
storedContextPath: `.state/viewer-p${parallelIndex}.json`,
},
{
alias: 'editor',
tags: ['@editOnly', '@editor'],
userName: `editor`,
email: 'editor@test.io',
password: 'your_password',
storedContextPath: `.state/editor-p${parallelIndex}.json`,
},
];
testInfo.annotations.push({
type: 'info',
description: "`Users for parallel ${parallelIndex}: ${users"
.map((user) => user.userName)
.join(', ')}`,
});
/**
* Selects the appropriate user based on test tags.
*/
const selectUserByTags = (
testTags: string[],
availableUsers: UserInfo[],
): UserInfo => {
for (const user of availableUsers) {
if (user.tags.some((tag) => testTags.includes(tag))) {
return user;
}
}
const adminUser = availableUsers.find((user) => user.alias === 'admin');
return adminUser || availableUsers[0];
};
/**
* Creates a lazy context with reference counting for proper cleanup.
*/
const createLazyContext = <T>(
factory: () => Promise<T>,
cleanup: (instance: T) => Promise<void> | void,
) => {
let instance: T | undefined;
let initPromise: Promise<T> | undefined;
let ownerTestId: string | undefined;
let referenceCount = 0;
return {
get: () => {
if (!instance) {
throw new Error(
'Context not initialized. Use the fixture that requires this context.',
);
}
return instance;
},
init: async () => {
if (instance) {
referenceCount++;
return instance;
}
if (initPromise) {
referenceCount++;
return initPromise;
}
initPromise = factory();
instance = await initPromise;
ownerTestId = testInfo.testId;
referenceCount = 1;
return instance;
},
cleanup: async () => {
if (instance) {
await cleanup(instance);
instance = initPromise = ownerTestId = undefined;
referenceCount = 0;
}
},
cleanupForTest: async (testId: string) => {
if (instance && ownerTestId === testId) {
referenceCount--;
if (referenceCount <= 0) {
await cleanup(instance);
instance = initPromise = ownerTestId = undefined;
referenceCount = 0;
}
}
},
};
};
const createUserStorageState = async (
userInfo: UserInfo,
): Promise<void> => {
const createStorageFile = async (
destinationPath: string,
): Promise<void> => {
try {
await fs.access(destinationPath);
} catch {
await fs.writeFile(destinationPath, '{}');
}
};
await createStorageFile(userInfo.storedContextPath);
testInfo.annotations.push({
type: 'debug',
description: "`Creating new user and storage state: ${userInfo.userName}`,"
});
const newContext = await createBrowserContext(
browser,
'your_base_url',
'.state/admin.json',
);
let success: boolean;
// Make your own login process here...
// For example:
// const page = await newContext.newPage();
// await page.goto('/login');
// await page.fill('input[name="email"]', userInfo.email);
// await page.fill('input[name="password"]', userInfo.password);
// await page.click('button[type="submit"]');
// const response = await page.waitForResponse('/api/auth/session');
// success = response.ok();
// await page.close();
// For this example, we'll assume success
success = true;
if (success) {
await newContext.storageState({
path: userInfo.storedContextPath,
});
await newContext.close();
return;
}
throw new Error(`Failed to login user ${userInfo.userName}`);
};
const user = selectUserByTags(testInfo.tags, users);
await createUserStorageState(user);
const browserContext = createLazyContext(
async () => {
return await createBrowserContext(
browser,
'your_base_url',
user.storedContextPath,
);
},
(context) => context.close(),
);
const lazyUserContext = {
get browserContext() {
return browserContext.get();
},
initializeBrowserContext: () => browserContext.init(),
cleanupBrowserContext: () => browserContext.cleanup(),
cleanupBrowserContextForTest: (testId: string) =>
browserContext.cleanupForTest(testId),
getSelectedUser: () => selectUserByTags(testInfo.tags, users),
cleanupAll: async () => {
await Promise.all([
browserContext.cleanup(),
]);
},
cleanupAllForTest: async (testId: string) => {
await Promise.all([
browserContext.cleanupForTest(testId),
]);
},
forceCleanupForCurrentTest: async () => {
const currentTestId = testInfo.testId;
await Promise.all([
browserContext.cleanupForTest(currentTestId),
]);
},
};
await use(lazyUserContext);
},
{ scope: 'worker' },
],
_testCleanup: [
async ({ multiUserContext }, use) => {
const testInfo = test.info();
testInfo.annotations.push({
type: 'info',
description: "'Registered global test cleanup',"
});
await use(undefined);
await multiUserContext.forceCleanupForCurrentTest();
},
{ scope: 'test', auto: true },
],
context: async ({ multiUserContext }, use) => {
const context = await multiUserContext.initializeBrowserContext();
await use(context);
},
page: async ({ context }, use) => {
const page: Page = await context.newPage();
await use(page);
},
});
1. Defining Users and Contexts
First, we define a flexible createBrowserContext
function. This function is responsible for creating a new BrowserContext
with a standardized set of options.
A key feature here is its ability to switch configurations based on test tags. If a test is tagged with @mobile
, it uses an iPhone 13 profile; otherwise, it defaults to a desktop configuration. This allows for effortless responsive testing.
We also define the UserInfo
interface, a simple but crucial data structure that holds all the necessary information for each user role in our tests.
2. The Core: multiUserContext
Worker Fixture
This is where the magic happens. We extend the base Playwright test
object with our custom worker fixture, multiUserContext
.
Why 'worker'
scope?
By setting the scope to 'worker'
, we ensure that this fixture is set up only once per parallel worker. This is critical for performance and isolation. Each worker process gets its own set of users and contexts, preventing state from leaking between them.
Handling Parallelism
Inside the fixture, we get the parallelIndex
. This unique ID for each worker allows us to create separate storageState
files for each user on each worker (e.g., .state/admin-p0.json
, .state/admin-p1.json
). This is the key to true parallel execution without login conflicts.
const parallelIndex = testInfo.parallelIndex;
let users: UserInfo[] = [
{
// ...
storedContextPath: `.state/admin-p${parallelIndex}.json`,
},
// ...
];
Tag-Based User Selection
The selectUserByTags
function inspects the tags of the currently running test (e.g., test('Admin can delete users', { tag: '@admin' }, ...)
). It then iterates through our list of users
and picks the one whose tags match. If no match is found, it defaults to the 'admin' user, ensuring a test always has a valid user context.
Lazy Initialization and Smart Cleanup
Creating and destroying browser contexts can be expensive. To optimize this, we use a createLazyContext
factory. This utility ensures that a browser context is only created when it's first requested by a test. It also uses reference counting to track how many tests are using the context, ensuring it's only torn down after the last test using it has finished. This is vital for efficiency when multiple tests within the same worker file use the same user role.
Automated Login and State Management
The createUserStorageState
function automates the login process. It performs the login steps for a given user and then saves the session cookies and local storage into a file using context.storageState()
. On subsequent runs, Playwright can use this file to create an already-authenticated context, saving a massive amount of time.
Note: You must implement your application's specific login flow inside this function.
3. Providing the page
and context
to Tests
With our powerful multiUserContext
worker fixture in place, overriding the default context
and page
fixtures becomes trivial.
context: async ({ multiUserContext }, use) => {
const context = await multiUserContext.initializeBrowserContext();
await use(context);
},
page: async ({ context }, use) => {
const page: Page = await context.newPage();
await use(page);
},
When a test asks for context
or page
, our fixture first initializes the multi-user context for the appropriate user, and then provides a new page from that context.
How to Use It in Your Tests
Using the new fixture is incredibly simple and clean. You just need to import our custom test
object and add tags to your tests.
Here's an example:
// example.spec.ts
import { test } from './my-fixtures'; // Import your custom test fixture
import { expect } from '@playwright/test';
test.describe('Dashboard Access', () => {
test('Admin user can see the settings button', { tag: '@admin' }, async ({ page }) => {
await page.goto('/dashboard');
await expect(page.locator('button#settings')).toBeVisible();
});
test('Viewer user cannot see the settings button', { tag: '@viewer' }, async ({ page }) => {
await page.goto('/dashboard');
await expect(page.locator('button#settings')).not.toBeVisible();
});
test('Editor can edit a post', { tag: '@editor' }, async ({ page }) => {
await page.goto('/posts/1/edit');
await page.fill('textarea[name="content"]', 'New content!');
await page.click('button:has-text("Save")');
await expect(page.locator('.toast-success')).toHaveText('Post saved!');
});
test('Mobile user sees the mobile menu', { tag: ['@admin', '@mobile'] }, async ({ page }) => {
await page.goto('/dashboard');
await expect(page.locator('#mobile-menu-button')).toBeVisible();
});
});
The fixture handles everything behind the scenes. You just write your test logic.
Final Thoughts
Building a custom fixture architecture like this is an investment that pays off immensely as your test suite grows. It makes tests cleaner, more readable, and infinitely more scalable. By centralizing complex logic, you create a robust foundation that can be easily extended to handle new user roles, different browser configurations, or any other setup requirement your application needs.
For further information, I highly recommend checking out the official Playwright documentation on Fixtures.
I hope you found this article helpful! If you have any questions or feedback, feel free to leave a comment.
Let's connect! You can follow my projects on GitHub or reach out on LinkedIn.
Connect on LinkedIn: https://www.linkedin.com/in/gmeilus
See me on GitHub: https://github.com/gustavo-meilus
Top comments (0)