# Multi-User and Collaboration Testing

> **When to use**: When your application involves real-time collaboration, multi-user workflows, or any scenario where two or more users interact with the same resource simultaneously -- chat apps, shared documents, multiplayer features, admin-and-user flows.
> **Prerequisites**: [core/fixtures-and-hooks.md](fixtures-and-hooks.md), [core/configuration.md](configuration.md)

## Quick Reference

```typescript
// Two independent browser contexts in one test = two users
const alice = await browser.newContext({ storageState: "auth/alice.json" })
const bob = await browser.newContext({ storageState: "auth/bob.json" })
const alicePage = await alice.newPage()
const bobPage = await bob.newPage()

// Each operates independently — different cookies, sessions, localStorage
await alicePage.goto("/chat/room-1")
await bobPage.goto("/chat/room-1")
```

## Patterns

### Two Users in One Test via Browser Contexts

**Use when**: You need to verify that actions by one user are visible to another in real time.
**Avoid when**: You only need to test a single user's flow. One context per test is the default.

Each `browser.newContext()` creates a fully isolated session -- separate cookies, localStorage, and network state. This is how you simulate two logged-in users in a single test without launching a second browser.

**TypeScript**

```typescript
import { test, expect } from "@playwright/test"

test("alice sends a message and bob sees it", async ({ browser }) => {
  // Create two isolated contexts
  const aliceContext = await browser.newContext({ storageState: "auth/alice.json" })
  const bobContext = await browser.newContext({ storageState: "auth/bob.json" })

  const alicePage = await aliceContext.newPage()
  const bobPage = await bobContext.newPage()

  // Both navigate to the same chat room
  await alicePage.goto("/chat/general")
  await bobPage.goto("/chat/general")

  // Alice sends a message
  await alicePage.getByRole("textbox", { name: "Message" }).fill("Hello Bob!")
  await alicePage.getByRole("button", { name: "Send" }).click()

  // Bob sees it in real time
  await expect(bobPage.getByText("Hello Bob!")).toBeVisible()

  // Alice also sees her own message
  await expect(alicePage.getByText("Hello Bob!")).toBeVisible()

  // Cleanup
  await aliceContext.close()
  await bobContext.close()
})
```

**JavaScript**

```javascript
const { test, expect } = require("@playwright/test")

test("alice sends a message and bob sees it", async ({ browser }) => {
  const aliceContext = await browser.newContext({ storageState: "auth/alice.json" })
  const bobContext = await browser.newContext({ storageState: "auth/bob.json" })

  const alicePage = await aliceContext.newPage()
  const bobPage = await bobContext.newPage()

  await alicePage.goto("/chat/general")
  await bobPage.goto("/chat/general")

  await alicePage.getByRole("textbox", { name: "Message" }).fill("Hello Bob!")
  await alicePage.getByRole("button", { name: "Send" }).click()

  await expect(bobPage.getByText("Hello Bob!")).toBeVisible()
  await expect(alicePage.getByText("Hello Bob!")).toBeVisible()

  await aliceContext.close()
  await bobContext.close()
})
```

### Multi-User Fixture for Reusability

**Use when**: Multiple tests need two-user setups. Wrap context creation in a fixture to avoid boilerplate.
**Avoid when**: Only one test needs multi-user logic.

**TypeScript**

```typescript
// fixtures.ts
import { test as base, expect, BrowserContext, Page } from "@playwright/test"

type MultiUserFixtures = {
  aliceContext: BrowserContext
  alicePage: Page
  bobContext: BrowserContext
  bobPage: Page
}

export const test = base.extend<MultiUserFixtures>({
  aliceContext: async ({ browser }, use) => {
    const context = await browser.newContext({ storageState: "auth/alice.json" })
    await use(context)
    await context.close()
  },
  alicePage: async ({ aliceContext }, use) => {
    const page = await aliceContext.newPage()
    await use(page)
  },
  bobContext: async ({ browser }, use) => {
    const context = await browser.newContext({ storageState: "auth/bob.json" })
    await use(context)
    await context.close()
  },
  bobPage: async ({ bobContext }, use) => {
    const page = await bobContext.newPage()
    await use(page)
  },
})

export { expect }
```

```typescript
// collaboration.spec.ts
import { test, expect } from "./fixtures"

test("both users see shared document title", async ({ alicePage, bobPage }) => {
  await alicePage.goto("/docs/shared-doc")
  await bobPage.goto("/docs/shared-doc")

  await alicePage.getByRole("textbox", { name: "Title" }).fill("Project Plan")

  await expect(bobPage.getByRole("textbox", { name: "Title" })).toHaveValue("Project Plan")
})
```

**JavaScript**

```javascript
// fixtures.js
const { test: base, expect } = require("@playwright/test")

const test = base.extend({
  aliceContext: async ({ browser }, use) => {
    const context = await browser.newContext({ storageState: "auth/alice.json" })
    await use(context)
    await context.close()
  },
  alicePage: async ({ aliceContext }, use) => {
    const page = await aliceContext.newPage()
    await use(page)
  },
  bobContext: async ({ browser }, use) => {
    const context = await browser.newContext({ storageState: "auth/bob.json" })
    await use(context)
    await context.close()
  },
  bobPage: async ({ bobContext }, use) => {
    const page = await bobContext.newPage()
    await use(page)
  },
})

module.exports = { test, expect }
```

### Collaborative Editing with Conflict Detection

**Use when**: Testing real-time collaborative editors (Google Docs-style) where both users edit the same content.
**Avoid when**: Your app does not support concurrent editing.

**TypeScript**

```typescript
import { test, expect } from "./fixtures"

test("concurrent edits merge without data loss", async ({ alicePage, bobPage }) => {
  await alicePage.goto("/docs/shared-doc")
  await bobPage.goto("/docs/shared-doc")

  // Wait for both to be connected
  await expect(alicePage.getByTestId("connection-status")).toHaveText("Connected")
  await expect(bobPage.getByTestId("connection-status")).toHaveText("Connected")

  // Alice types at the beginning
  const aliceEditor = alicePage.getByRole("textbox", { name: "Editor" })
  await aliceEditor.pressSequentially("Alice was here. ")

  // Bob types at the end (simultaneously)
  const bobEditor = bobPage.getByRole("textbox", { name: "Editor" })
  await bobEditor.press("End")
  await bobEditor.pressSequentially("Bob was here.")

  // Both edits should be visible to both users
  await expect(aliceEditor).toContainText("Alice was here.")
  await expect(aliceEditor).toContainText("Bob was here.")
  await expect(bobEditor).toContainText("Alice was here.")
  await expect(bobEditor).toContainText("Bob was here.")
})
```

**JavaScript**

```javascript
const { test, expect } = require("./fixtures")

test("concurrent edits merge without data loss", async ({ alicePage, bobPage }) => {
  await alicePage.goto("/docs/shared-doc")
  await bobPage.goto("/docs/shared-doc")

  await expect(alicePage.getByTestId("connection-status")).toHaveText("Connected")
  await expect(bobPage.getByTestId("connection-status")).toHaveText("Connected")

  const aliceEditor = alicePage.getByRole("textbox", { name: "Editor" })
  await aliceEditor.pressSequentially("Alice was here. ")

  const bobEditor = bobPage.getByRole("textbox", { name: "Editor" })
  await bobEditor.press("End")
  await bobEditor.pressSequentially("Bob was here.")

  await expect(aliceEditor).toContainText("Alice was here.")
  await expect(aliceEditor).toContainText("Bob was here.")
  await expect(bobEditor).toContainText("Alice was here.")
  await expect(bobEditor).toContainText("Bob was here.")
})
```

### Shared State Verification (Presence, Cursors, Indicators)

**Use when**: Verifying that one user's presence or activity is reflected in the other user's UI -- online indicators, typing indicators, cursor positions.
**Avoid when**: Presence is not a feature of your app.

**TypeScript**

```typescript
import { test, expect } from "./fixtures"

test("bob sees alice typing indicator", async ({ alicePage, bobPage }) => {
  await alicePage.goto("/chat/general")
  await bobPage.goto("/chat/general")

  // Alice starts typing
  await alicePage.getByRole("textbox", { name: "Message" }).pressSequentially("Hel", { delay: 100 })

  // Bob sees the typing indicator
  await expect(bobPage.getByText("Alice is typing...")).toBeVisible()

  // Alice stops typing — indicator disappears after debounce
  await expect(bobPage.getByText("Alice is typing...")).toBeHidden({ timeout: 5000 })
})

test("online users list updates when user joins", async ({ alicePage, bobPage }) => {
  await alicePage.goto("/chat/general")

  // Alice sees only herself
  await expect(alicePage.getByTestId("online-users").getByText("Alice")).toBeVisible()
  await expect(alicePage.getByTestId("online-users").getByText("Bob")).toBeHidden()

  // Bob joins
  await bobPage.goto("/chat/general")

  // Alice now sees Bob in the online list
  await expect(alicePage.getByTestId("online-users").getByText("Bob")).toBeVisible()
})
```

**JavaScript**

```javascript
const { test, expect } = require("./fixtures")

test("bob sees alice typing indicator", async ({ alicePage, bobPage }) => {
  await alicePage.goto("/chat/general")
  await bobPage.goto("/chat/general")

  await alicePage.getByRole("textbox", { name: "Message" }).pressSequentially("Hel", { delay: 100 })
  await expect(bobPage.getByText("Alice is typing...")).toBeVisible()
  await expect(bobPage.getByText("Alice is typing...")).toBeHidden({ timeout: 5000 })
})

test("online users list updates when user joins", async ({ alicePage, bobPage }) => {
  await alicePage.goto("/chat/general")
  await expect(alicePage.getByTestId("online-users").getByText("Alice")).toBeVisible()

  await bobPage.goto("/chat/general")
  await expect(alicePage.getByTestId("online-users").getByText("Bob")).toBeVisible()
})
```

### Race Condition Testing Between Users

**Use when**: You need to verify that simultaneous actions (two users clicking "buy" on the last item, or two users editing the same field) are handled correctly.
**Avoid when**: Your app has no shared mutable resources.

**TypeScript**

```typescript
import { test, expect } from "./fixtures"

test("only one user can claim the last item", async ({ alicePage, bobPage }) => {
  await alicePage.goto("/store/limited-item")
  await bobPage.goto("/store/limited-item")

  // Both see the item is available
  await expect(alicePage.getByText("1 remaining")).toBeVisible()
  await expect(bobPage.getByText("1 remaining")).toBeVisible()

  // Both click buy at approximately the same time
  const aliceBuy = alicePage.getByRole("button", { name: "Buy now" }).click()
  const bobBuy = bobPage.getByRole("button", { name: "Buy now" }).click()
  await Promise.all([aliceBuy, bobBuy])

  // One succeeds, one fails — verify the app handles the race
  const aliceResult = await alicePage.getByTestId("purchase-result").textContent()
  const bobResult = await bobPage.getByTestId("purchase-result").textContent()

  const results = [aliceResult, bobResult]
  expect(results).toContain("Purchase successful")
  expect(results).toContain("Item no longer available")
})

test("simultaneous form submissions do not create duplicates", async ({ alicePage, bobPage }) => {
  await alicePage.goto("/admin/settings")
  await bobPage.goto("/admin/settings")

  // Both change the same setting
  await alicePage.getByLabel("Company name").fill("Alice Corp")
  await bobPage.getByLabel("Company name").fill("Bob Inc")

  // Submit simultaneously
  await Promise.all([
    alicePage.getByRole("button", { name: "Save" }).click(),
    bobPage.getByRole("button", { name: "Save" }).click(),
  ])

  // Reload both pages — one value should win, no corruption
  await alicePage.reload()
  const finalValue = await alicePage.getByLabel("Company name").inputValue()
  expect(["Alice Corp", "Bob Inc"]).toContain(finalValue)

  // The "loser" should see a conflict notification or the updated value
  await bobPage.reload()
  await expect(bobPage.getByLabel("Company name")).toHaveValue(finalValue)
})
```

**JavaScript**

```javascript
const { test, expect } = require("./fixtures")

test("only one user can claim the last item", async ({ alicePage, bobPage }) => {
  await alicePage.goto("/store/limited-item")
  await bobPage.goto("/store/limited-item")

  await expect(alicePage.getByText("1 remaining")).toBeVisible()
  await expect(bobPage.getByText("1 remaining")).toBeVisible()

  const aliceBuy = alicePage.getByRole("button", { name: "Buy now" }).click()
  const bobBuy = bobPage.getByRole("button", { name: "Buy now" }).click()
  await Promise.all([aliceBuy, bobBuy])

  const aliceResult = await alicePage.getByTestId("purchase-result").textContent()
  const bobResult = await bobPage.getByTestId("purchase-result").textContent()

  const results = [aliceResult, bobResult]
  expect(results).toContain("Purchase successful")
  expect(results).toContain("Item no longer available")
})
```

### N Users with Dynamic Context Creation

**Use when**: You need more than two users, or the number of users is variable (load-like collaboration tests).
**Avoid when**: Two users suffice. Keep it simple.

**TypeScript**

```typescript
import { test, expect, Browser, Page } from "@playwright/test"

async function createUser(browser: Browser, name: string, room: string): Promise<Page> {
  const context = await browser.newContext({ storageState: `auth/${name}.json` })
  const page = await context.newPage()
  await page.goto(`/chat/${room}`)
  return page
}

test("five users in a chat room all see each other", async ({ browser }) => {
  const names = ["alice", "bob", "charlie", "diana", "eve"]
  const pages = await Promise.all(names.map((name) => createUser(browser, name, "team-room")))

  // First user sends a message
  await pages[0].getByRole("textbox", { name: "Message" }).fill("Hello everyone!")
  await pages[0].getByRole("button", { name: "Send" }).click()

  // All users see the message
  for (const page of pages) {
    await expect(page.getByText("Hello everyone!")).toBeVisible()
  }

  // Cleanup
  for (const page of pages) {
    await page.context().close()
  }
})
```

**JavaScript**

```javascript
const { test, expect } = require("@playwright/test")

async function createUser(browser, name, room) {
  const context = await browser.newContext({ storageState: `auth/${name}.json` })
  const page = await context.newPage()
  await page.goto(`/chat/${room}`)
  return page
}

test("five users in a chat room all see each other", async ({ browser }) => {
  const names = ["alice", "bob", "charlie", "diana", "eve"]
  const pages = await Promise.all(names.map((name) => createUser(browser, name, "team-room")))

  await pages[0].getByRole("textbox", { name: "Message" }).fill("Hello everyone!")
  await pages[0].getByRole("button", { name: "Send" }).click()

  for (const page of pages) {
    await expect(page.getByText("Hello everyone!")).toBeVisible()
  }

  for (const page of pages) {
    await page.context().close()
  }
})
```

## Decision Guide

| Scenario                                    | Approach                                   | Why                                                |
| ------------------------------------------- | ------------------------------------------ | -------------------------------------------------- |
| Two users interact in real time             | Two `browser.newContext()` in one test     | Fully isolated sessions, same test timeline        |
| Same multi-user setup across many tests     | Custom fixture per user context            | Eliminates boilerplate, guarantees cleanup         |
| Testing presence/typing indicators          | Sequential actions, assert on other page   | Order matters -- act on one, assert on the other   |
| Race condition (simultaneous clicks)        | `Promise.all([action1, action2])`          | Fires both as close to simultaneously as possible  |
| Admin performs action, user sees result     | Two contexts with different `storageState` | Different auth roles, same browser instance        |
| 3+ users in one test                        | Loop with `browser.newContext()` per user  | Each context is cheap; browser is shared           |
| Cross-browser multi-user (Chrome + Firefox) | Separate browser launches in `beforeAll`   | Rarely needed; contexts within one browser suffice |

## Anti-Patterns

| Don't Do This                                            | Problem                                                  | Do This Instead                                                 |
| -------------------------------------------------------- | -------------------------------------------------------- | --------------------------------------------------------------- |
| Use one context with two pages for two users             | Pages in the same context share cookies and localStorage | Use `browser.newContext()` per user                             |
| Open a second browser for the second user                | Wastes memory and startup time                           | Use a second `browser.newContext()` -- contexts are lightweight |
| `await page.waitForTimeout(2000)` for real-time sync     | Arbitrary delay; flaky in CI, slow in dev                | `await expect(bobPage.getByText('msg')).toBeVisible()`          |
| Share mutable state via `let` variables between contexts | Test order and timing become implicit dependencies       | Each context is independent; assert via UI                      |
| Put multi-user setup in `beforeAll`                      | `beforeAll` cannot access `page` or `context`            | Use test-scoped fixtures or inline `browser.newContext()`       |
| Forget to close extra contexts                           | Leaks memory and may cause port exhaustion               | Always close contexts in fixture teardown or `finally`          |
| Assert on both pages without waiting                     | The second page may not have received the update yet     | Use `expect` with auto-retry on the receiving page              |

## Troubleshooting

| Symptom                                        | Cause                                                     | Fix                                                                                                           |
| ---------------------------------------------- | --------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------- |
| Second user sees stale data                    | Context created before server state was ready             | Navigate the second page after the first user's action completes                                              |
| `storageState` file not found                  | Auth setup project has not run, or path is wrong          | Run auth setup first via `dependencies` in `playwright.config`                                                |
| Both users have the same session               | Reusing the same `storageState` file for both             | Create distinct auth state files per user role                                                                |
| Real-time updates never arrive on second page  | WebSocket/SSE connection not established before assertion | Wait for a connection indicator before asserting: `await expect(page.getByTestId('connected')).toBeVisible()` |
| Test is slow with many contexts                | Too many contexts in a single test (10+)                  | Limit to 3-5 contexts per test; use API setup to reduce page interactions                                     |
| `Promise.all` race test always has same winner | Network/event loop makes one always faster                | This is expected in testing -- assert that the app handles both outcomes, not which user wins                 |

## Related

- [core/fixtures-and-hooks.md](fixtures-and-hooks.md) -- wrap multi-user contexts in fixtures
- [core/websockets-and-realtime.md](websockets-and-realtime.md) -- test the transport layer beneath collaboration features
- [core/test-data-management.md](test-data-management.md) -- set up shared resources (rooms, documents) before multi-user tests
- [core/configuration.md](configuration.md) -- configure `storageState` per project for different user roles
