Working on a large-scale, multi-tenant SaaS platform presents unique challenges for UI and API test automation. Our application has different user roles (admin, view-only, limited-access), and we need to validate features from the perspective of each.
The Problem
The traditional approach of logging in a specific user in a beforeEach hook for every single test is a notorious bottleneck. It's slow, adding seconds or even minutes to every test file. It's flaky, as login flows can be complex and prone to intermittent failures. And it's repetitive, violating the D.R.Y. (Don't Repeat Yourself) principle.
When you're running thousands of tests in parallel, this waste adds up, turning a 10-minute CI run into a 30-minute one.
The Goal
Our objective was to create a robust, scalable fixture system in Playwright that could:
- Isolate Users Per Worker: Each parallel worker should manage its own set of users to avoid collisions.
- Select User by Tag: Automatically pick the correct user (e.g.,
@admin,@viewOnly) based on the test's tags. - Log In Once: Perform the expensive login and user-creation logic once per user, not once per test.
- Reuse Auth State: Save the authenticated storage state and reuse it.
- Provide Test-Level Isolation: Ensure every single test runs in a pristine, isolated context to prevent state-bleeding between tests.
- Manage Both UI and API: Provide both a
BrowserContext(for UI tests) and anAPIRequestContext(for API calls) for the selected user.
The Implementation (Worker Fixture: usersContext)
The heart of our solution is a worker-scoped fixture named usersContext. This fixture is initialized once for each parallel worker.
A previous version of this fixture created a single BrowserContext and APIRequestContext that were shared by all tests running on that worker. This was fast, but it had a major flaw: tests were not isolated. A popup left open in one test could cause the next test to fail.
Our new implementation solves this by making the usersContext a manager for test-scoped contexts. It's responsible for creating and destroying contexts on behalf of individual tests, ensuring 100% isolation.
The new secret sauce is a helper function called createLazyContext.
The createLazyContext Helper
This helper is a factory that produces context managers. It uses Map objects to track context instances and, critically, reference counts based on Playwright's testInfo.testId.
Here is a simplified view of its logic:
const createLazyContext = <T>(
factory: () => Promise<T>, // The function that creates the context
cleanup: (instance: T) => Promise<void> | void, // The function that destroys it
) => {
const instances = new Map<string, T>();
const initPromises = new Map<string, Promise<T>>();
const referenceCounts = new Map<string, number>();
return {
init: async (testId: string) => {
// If it already exists for this test, increment ref count
const existingInstance = instances.get(testId);
if (existingInstance) {
referenceCounts.set(testId, (referenceCounts.get(testId) || 0) + 1);
return existingInstance;
}
// ... (handles promises to prevent race conditions) ...
// If it's new, run the (expensive) factory
const instance = await factory();
instances.set(testId, instance);
referenceCounts.set(testId, 1); // Set initial ref count
return instance;
},
cleanup: async (testId: string) => {
const instance = instances.get(testId);
if (!instance) return; // Nothing to clean
const refCount = referenceCounts.get(testId) || 0;
if (refCount > 1) {
// If other fixtures still use this, just decrement the count
referenceCounts.set(testId, refCount - 1);
return;
}
// If ref count is 1, this is the last user.
// Time to do the *real* cleanup.
await cleanup(instance);
instances.delete(testId);
referenceCounts.delete(testId);
initPromises.delete(testId);
},
};
};
The getOrCreateUser Function
The factory function passed to createLazyContext is our getOrCreateUser logic. This function is now far more robust:
- It checks if a valid auth file (
.auth/...json) already exists. - If not, it uses a master admin API context to:
- Check if the test user exists in the application's backend.
- If not, it programmatically creates the user and their required role via the API.
- It fetches the user's temporary password (in our case, from email).
- It performs an API login and password reset if needed.
- Only after the user is guaranteed to exist and be in a valid state does it perform a final UI login. This is exclusively to save the
storageStateto disk for future use.
Using createLazyContext in the Worker
Our usersContext fixture now uses this helper to create managers for each context type. The worker itself no longer holds the contexts—it just holds the managers.
usersContext: [
async ({ uiBaseURL, apiBaseURL, browser }, use) => {
// ... (user definitions, selectUserByTags, getOrCreateUser) ...
// Create a manager for the BrowserContext
const browserContext = createLazyContext(
async () => {
const selectedUser = selectUserByTags(testInfo.tags, users);
const userContext = await getOrCreateUser(selectedUser, ...);
return userContext.userAccountContext;
},
async (context) => {
await context.close(); // The *real* cleanup
},
);
// Create a separate manager for the APIRequestContext
const apiRequest = createLazyContext(
async () => {
const selectedUser = selectUserByTags(testInfo.tags, users);
const userContext = await getOrCreateUser(selectedUser, ...);
return userContext.userAccountRequestContext;
},
async (context) => {
await context.dispose(); // The *real* cleanup
},
);
// The worker fixture provides an object with init/cleanup methods
const lazyUsersContext = {
initializeBrowserContext: () => {
const currentTestId = test.info().testId;
return browserContext.init(currentTestId);
},
cleanupBrowserContext: (testId: string) =>
browserContext.cleanup(testId),
initializeApiRequest: () => {
const currentTestId = test.info().testId;
return apiRequest.init(currentTestId);
},
cleanupApiRequest: (testId: string) =>
apiRequest.cleanup(testId),
// ... (other helpers) ...
};
await use(lazyUsersContext);
},
{ scope: 'worker' },
],
Tying it All Together: The Test-Scoped Fixtures
Now, our test-scoped fixtures (the ones our tests actually use) are responsible for triggering the creation and cleanup of their own contexts.
This is the most important change. The test fixtures now tell the worker manager when to start and stop.
export const test = base.extend<Fixtures, WorkerFixtures>({
// ... (worker fixtures) ...
// UI Context Fixture (for UI tests)
context: async ({ usersContext }, use) => {
const testInfo = test.info();
// 1. TELL THE WORKER TO INIT A CONTEXT FOR THIS TEST
const context = await usersContext.initializeBrowserContext();
try {
await use(context); // Run the test
} finally {
// 2. TELL THE WORKER TO CLEAN UP THE CONTEXT FOR THIS TEST
await usersContext.cleanupBrowserContext(testInfo.testId);
}
},
// API Request Fixture (for API tests)
apiRequest: async ({ usersContext }, use) => {
const testInfo = test.info();
// 1. TELL THE WORKER TO INIT A CONTEXT FOR THIS TEST
const apiRequest = await usersContext.initializeApiRequest();
try {
await use(apiRequest); // Run the test
} finally {
// 2. TELL THE WORKER TO CLEAN UP THE CONTEXT FOR THIS TEST
await usersContext.cleanupApiRequest(testInfo.testId);
}
},
});
The New Magic of Per-Test Cleanup
This new structure gives us the best of all worlds. Let's trace the lifecycle for a single test:
- A test
test('my ui test', async ({ app }) => { ... })starts. - Playwright sees
appdepends oncontext. - The
contextfixture runs. It callsusersContext.initializeBrowserContext(). - Inside
createLazyContext, it sees no instance for thistestId. It runs the expensivegetOrCreateUserfactory. - A brand new
BrowserContextis created. It's stored in theinstancesmap, and itsreferenceCountis set to 1. - The
appfixture runs, creates a new page, and the test body executes. - The test finishes.
- The
finallyblock in thecontextfixture runs. It callsusersContext.cleanupBrowserContext(testInfo.testId). - Inside
createLazyContext, it finds thereferenceCountis 1. It decrements it to 0. - Because the count is 0, it calls the real cleanup function:
await context.close(). - The
BrowserContextis completely destroyed. The next test will start from a perfectly clean slate.
The reference counting is key. If another fixture also depended on initializeBrowserContext, it would just increment the count to 2. The real cleanup wouldn't happen until both fixtures had run their cleanup methods, bringing the count back to 0.
Conclusion
By moving context management from a "shared worker resource" to a "test-scoped resource managed by a worker," we've eliminated test-state pollution. This usersContext fixture now provides:
- Maximum Performance: User creation and login logic is centralized and cached.
-
Maximum Isolation: Every test gets its own pristine
BrowserContextandAPIRequestContext. -
Excellent Developer Experience: A test just needs to ask for
apporapito get the right, pre-authenticated context for its tagged user role.
This pattern has been a game-changer for scaling our Playwright test suite, making it faster, more reliable, and much easier to maintain.
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)