DEV Community

Cover image for Scaling Your Playwright Tests: A Fixture for Multi-User, Multi-Context Worlds
Gustavo Meilus
Gustavo Meilus

Posted on • Edited on

Scaling Your Playwright Tests: A Fixture for Multi-User, Multi-Context Worlds

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:

  1. Isolate Users Per Worker: Each parallel worker should manage its own set of users to avoid collisions.
  2. Select User by Tag: Automatically pick the correct user (e.g., @admin, @viewOnly) based on the test's tags.
  3. Log In Once: Perform the expensive login and user-creation logic once per user, not once per test.
  4. Reuse Auth State: Save the authenticated storage state and reuse it.
  5. Provide Test-Level Isolation: Ensure every single test runs in a pristine, isolated context to prevent state-bleeding between tests.
  6. Manage Both UI and API: Provide both a BrowserContext (for UI tests) and an APIRequestContext (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);
    },
  };
};
Enter fullscreen mode Exit fullscreen mode

The getOrCreateUser Function

The factory function passed to createLazyContext is our getOrCreateUser logic. This function is now far more robust:

  1. It checks if a valid auth file (.auth/...json) already exists.
  2. 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.
  3. 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 storageState to 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' },
],
Enter fullscreen mode Exit fullscreen mode

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);
    }
  },
});
Enter fullscreen mode Exit fullscreen mode

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:

  1. A test test('my ui test', async ({ app }) => { ... }) starts.
  2. Playwright sees app depends on context.
  3. The context fixture runs. It calls usersContext.initializeBrowserContext().
  4. Inside createLazyContext, it sees no instance for this testId. It runs the expensive getOrCreateUser factory.
  5. A brand new BrowserContext is created. It's stored in the instances map, and its referenceCount is set to 1.
  6. The app fixture runs, creates a new page, and the test body executes.
  7. The test finishes.
  8. The finally block in the context fixture runs. It calls usersContext.cleanupBrowserContext(testInfo.testId).
  9. Inside createLazyContext, it finds the referenceCount is 1. It decrements it to 0.
  10. Because the count is 0, it calls the real cleanup function: await context.close().
  11. The BrowserContext is 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 BrowserContext and APIRequestContext.
  • Excellent Developer Experience: A test just needs to ask for app or api to 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)