Back to Blog

Playwright Multi-Tab and Multi-User Testing: Test Collaboration Features Other Frameworks Ignore

Real applications involve multiple characters — a sender and a receiver, an admin and a customer, browser tabs that communicate. Learn how Playwright's multi-context architecture lets you test these scenarios accurately and repeatably.

Published

9 min read

Reading time

Playwright Multi-Tab and Multi-User Testing: Test Collaboration Features Other Frameworks Ignore

There is a class of bug that traditional single-user test automation simply cannot find: the bugs that only appear when two users interact with the same resource simultaneously.

  • A collaborator edits a document while the owner deletes it
  • User A accepts an invitation that User B already cancelled
  • An admin revokes a permission while the user is mid-session
  • A buyer completes a purchase for the last item in stock that another buyer is also adding to cart

These are multi-party state conflicts, and they are among the most impactful bugs in modern web applications — especially SaaS products where concurrent users are the norm, not the exception.

Playwright is uniquely well-suited to test them. Its browser context architecture makes it straightforward to simulate multiple independent users operating within the same test, coordinating their actions to exercise race conditions, shared state, and real-time update flows.

This guide covers the full spectrum: multiple tabs, multiple users, and real-time scenarios.


Playwright's Context Model: The Foundation

Every Playwright test works within a hierarchy:

Browser
  └── BrowserContext (isolated "session")
        └── Page (tab)

A BrowserContext is the equivalent of an incognito window or a fresh browser profile — it has its own cookies, localStorage, and session state. Multiple contexts within the same browser instance do not share any state.

This means you can simulate two completely different logged-in users in the same test by creating two separate contexts:

test('owner and collaborator see matching state', async ({ browser }) => {
  // Create two isolated user sessions
  const ownerContext = await browser.newContext({
    storageState: 'playwright/.auth/owner.json',
  });
  const collaboratorContext = await browser.newContext({
    storageState: 'playwright/.auth/collaborator.json',
  });

  const ownerPage = await ownerContext.newPage();
  const collaboratorPage = await collaboratorContext.newPage();

  // Both navigate to the same shared project
  await ownerPage.goto('/projects/shared-123');
  await collaboratorPage.goto('/projects/shared-123');

  // Owner creates a scan
  await ownerPage.getByRole('button', { name: 'Run Scan' }).click();
  await ownerPage.waitForResponse('/api/scans');

  // Collaborator should see the scan appear in real-time
  await expect(collaboratorPage.getByText('Scan Running')).toBeVisible({ timeout: 5000 });

  await ownerContext.close();
  await collaboratorContext.close();
});

Setting Up Multi-User Authentication States

The prerequisite for multi-user testing is having separate authenticated storage states for each user type. Your global setup should provision all the states you need:

// tests/setup/global-setup.ts
import { chromium } from '@playwright/test';

type UserCredentials = {
  email: string;
  password: string;
  storageStatePath: string;
};

const users: UserCredentials[] = [
  {
    email: process.env.OWNER_EMAIL!,
    password: process.env.OWNER_PASSWORD!,
    storageStatePath: 'playwright/.auth/owner.json',
  },
  {
    email: process.env.MEMBER_EMAIL!,
    password: process.env.MEMBER_PASSWORD!,
    storageStatePath: 'playwright/.auth/member.json',
  },
  {
    email: process.env.ADMIN_EMAIL!,
    password: process.env.ADMIN_PASSWORD!,
    storageStatePath: 'playwright/.auth/admin.json',
  },
];

async function globalSetup() {
  const browser = await chromium.launch();

  for (const user of users) {
    const page = await browser.newPage();
    await page.goto(process.env.BASE_URL + '/login');
    await page.getByLabel('Email').fill(user.email);
    await page.getByLabel('Password').fill(user.password);
    await page.getByRole('button', { name: 'Sign In' }).click();
    await page.waitForURL('/dashboard');
    await page.context().storageState({ path: user.storageStatePath });
    await page.close();
    console.log(`✅ Auth state saved for ${user.email}`);
  }

  await browser.close();
}

export default globalSetup;

Multi-Tab Testing: When One User Needs Multiple Windows

Before jumping to multi-user, consider a common single-user, multi-tab scenario: a user opens a settings page in a new tab while still viewing the dashboard in the original tab. Changes in one tab should reflect in the other.

test('settings changes reflect in other open tabs', async ({ page, context }) => {
  // Open dashboard in the main tab
  await page.goto('/dashboard');
  await expect(page.getByText('Free Plan')).toBeVisible();

  // Open settings in a new tab (same context = same session)
  const settingsTab = await context.newPage();
  await settingsTab.goto('/settings/billing');

  // Simulate a plan upgrade in the settings tab
  await settingsTab.getByRole('button', { name: 'Upgrade to Pro' }).click();
  await settingsTab.getByRole('button', { name: 'Confirm Upgrade' }).click();
  await expect(settingsTab.getByText('Pro Plan Active')).toBeVisible();

  // Verify the dashboard tab reflects the upgrade (via reload or real-time)
  await page.reload();
  await expect(page.getByText('Pro Plan')).toBeVisible();
  await expect(page.getByText('Free Plan')).not.toBeVisible();
});

Key insight: pages created from the same context share cookies and session state, just like tabs in the same browser window. Pages from different contexts are completely isolated.


Testing Real-Time Collaboration Features

Real-time features — live updates, presence indicators, collaborative editing — are notoriously hard to test. Multi-context Playwright makes them tractable.

Here is a pattern for testing a notification that appears when a team member joins a session:

test('user sees notification when team member joins', async ({ browser }) => {
  const ownerContext = await browser.newContext({
    storageState: 'playwright/.auth/owner.json',
  });
  const memberContext = await browser.newContext({
    storageState: 'playwright/.auth/member.json',
  });

  const ownerPage = await ownerContext.newPage();
  await ownerPage.goto('/workspace/project-abc/live');

  // Wait for owner to establish WebSocket connection
  await ownerPage.waitForFunction(() => window.__ws_connected === true);

  // Now member joins
  const memberPage = await memberContext.newPage();
  await memberPage.goto('/workspace/project-abc/live');

  // Owner should see member's presence notification
  await expect(ownerPage.getByRole('alert')).toContainText('joined the workspace');

  // Both should see each other's cursors / presence indicators
  await expect(ownerPage.getByTestId('active-users-count')).toHaveText('2');
  await expect(memberPage.getByTestId('active-users-count')).toHaveText('2');

  await Promise.all([ownerContext.close(), memberContext.close()]);
});

Timing and Synchronization: The Hard Part

Multi-user tests introduce temporal coupling — the test must coordinate actions across multiple pages in the right order. Use these strategies to avoid timing issues:

Strategy 1: Wait for Network Responses

// After owner performs an action, wait for the API call to complete
// before checking the collaborator's UI
const scanPromise = ownerPage.waitForResponse((res) => res.url().includes('/api/scans') && res.status() === 201);
await ownerPage.getByRole('button', { name: 'Run Scan' }).click();
await scanPromise; // Ensures the scan was created on the server

// Now safe to check collaborator's UI
await expect(collaboratorPage.getByText('New scan available')).toBeVisible();

Strategy 2: Poll for Expected State

For real-time updates with eventual consistency, polling with a generous timeout is often more reliable than fixed waits:

// Wait up to 10 seconds for the real-time update to propagate
await expect(async () => {
  await collaboratorPage.reload(); // or rely on WebSocket push
  await expect(collaboratorPage.getByText('Scan Complete')).toBeVisible();
}).toPass({ timeout: 10_000, intervals: [500, 1000, 2000] });

Strategy 3: Use Event Listeners

Playwright can wait for specific events on a page — useful for WebSocket-driven updates:

// Listen for a specific console message that signals update received
const updateReceived = collaboratorPage.waitForEvent('console', (msg) => msg.text().includes('realtime:scan_updated'));
await ownerPage.getByRole('button', { name: 'Complete Scan' }).click();
await updateReceived; // Waits for the WebSocket event, then continues

Testing Concurrent Mutations: Finding Race Conditions

Some of the most damaging bugs in SaaS apps are race conditions in resource contention. Here is how to test for a "last writer wins" scenario:

test('simultaneous saves are handled without data loss', async ({ browser }) => {
  const user1Context = await browser.newContext({
    storageState: 'playwright/.auth/user1.json',
  });
  const user2Context = await browser.newContext({
    storageState: 'playwright/.auth/user2.json',
  });

  const page1 = await user1Context.newPage();
  const page2 = await user2Context.newPage();

  // Both open the same document
  await Promise.all([page1.goto('/documents/shared-doc'), page2.goto('/documents/shared-doc')]);

  // Both users start editing at the same time
  await page1.getByRole('textbox', { name: 'Title' }).fill('User 1 Title');
  await page2.getByRole('textbox', { name: 'Title' }).fill('User 2 Title');

  // Both save simultaneously
  await Promise.all([
    page1.getByRole('button', { name: 'Save' }).click(),
    page2.getByRole('button', { name: 'Save' }).click(),
  ]);

  // Verify: one of the saves succeeded, no silent data corruption
  await page1.reload();
  await page2.reload();

  const page1Title = await page1.getByRole('textbox', { name: 'Title' }).inputValue();
  const page2Title = await page2.getByRole('textbox', { name: 'Title' }).inputValue();

  // Both users should see the same value (no split-brain)
  expect(page1Title).toBe(page2Title);

  // Bonus: check for conflict notification if your app supports it
  // await expect(page1.getByRole('alert')).toContainText('Conflict detected');

  await Promise.all([user1Context.close(), user2Context.close()]);
});

Multi-User Fixtures: Encapsulating the Pattern

Creating two contexts in every multi-user test is verbose. Encapsulate it in a fixture:

// tests/fixtures/multiUser.ts
import { test as base } from '@playwright/test';

type MultiUserFixture = {
  ownerPage: Page;
  memberPage: Page;
};

export const test = base.extend<MultiUserFixture>({
  ownerPage: async ({ browser }, use) => {
    const ctx = await browser.newContext({ storageState: 'playwright/.auth/owner.json' });
    const page = await ctx.newPage();
    await use(page);
    await ctx.close();
  },
  memberPage: async ({ browser }, use) => {
    const ctx = await browser.newContext({ storageState: 'playwright/.auth/member.json' });
    const page = await ctx.newPage();
    await use(page);
    await ctx.close();
  },
});

Tests become clean and explicit:

import { test, expect } from '../fixtures/multiUser';

test('owner-initiated scans appear in member view', async ({ ownerPage, memberPage }) => {
  // ...
});

For a broader look at fixture architecture, see our guide on mastering Playwright fixtures.


Visual: Multi-Context Test Flow Diagram

sequenceDiagram
    participant Test as Test Runner
    participant Owner as Owner (Context A)
    participant Server as Application Server
    participant Member as Member (Context B)

    Test->>Owner: Navigate to /workspace
    Test->>Member: Navigate to /workspace

    Owner->>Server: POST /api/scans (create)
    Server-->>Owner: 201 Created { id: "scan-123" }

    Server-->>Member: WebSocket push: scan_created

    Test->>Member: Wait for "New Scan" notification
    Member-->>Test: ✅ Notification visible

    Test->>Owner: Expect scan in list
    Test->>Member: Expect scan in list

Production Relevance: Why This Testing Matters

Multi-user bugs are high-severity because they are hard to reproduce in isolation. They often only manifest in production under real concurrent load. If you are building any collaborative feature — shared dashboards, team workspaces, real-time notifications, shared resources — testing it with simulated concurrent users before it ships is essential.

ScanlyApp's own continuous scan pipeline exercises authenticated flows against production-like environments, validating that multi-user states remain consistent across deploys.

Monitor your collaborative features in production: Try ScanlyApp free to set up ongoing scans of your authenticated, multi-user workflows.

Related articles: Also see fixtures that simplify multi-user test setup and teardown, intercepting network calls in multi-tab collaboration tests, and verifying real-time updates when multiple users share a session.


Summary: The Multi-Context Capability Map

Scenario Playwright Approach Key API
Multiple tabs, same user context.newPage() Shared cookies/session
Multiple users browser.newContext({ storageState }) Isolated sessions
Real-time updates page.waitForResponse / event listeners Network observation
Race conditions Promise.all simultaneous actions Concurrent execution
WebSocket verification page.waitForEvent('console') Event monitoring

Start with multi-tab tests (they are simple and high-value). Graduate to multi-user tests for your collaboration features. The investment pays off on the first concurrent-user bug you catch before it ships.

Further Reading

Related Posts

Playwright vs. Selenium vs. Cypress: The 2026 Showdown
Playwright & Automation
10 min read

Playwright vs. Selenium vs. Cypress: The 2026 Showdown

Selenium, Cypress, and Playwright are the three titans of browser automation. In 2026, one has clearly pulled ahead — but the right choice still depends on your team, stack, and goals. Here's the definitive, opinionated comparison.