DEV Community

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

Posted on

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

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:

  1. Select the correct user based on the tag (e.g., @admin, @viewer).
  2. Log that user in and save their authentication state.
  3. Create a unique browser context for that user, isolated from other parallel test workers.
  4. Provide the test with a ready-to-use page object.
  5. 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);
  },
});
Enter fullscreen mode Exit fullscreen mode

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`,
  },
  // ...
];
Enter fullscreen mode Exit fullscreen mode

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

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();
  });

});
Enter fullscreen mode Exit fullscreen mode

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)