# 多用户与协作测试 > **使用时机**:当应用涉及实时协作、多用户工作流,或任何两个及以上用户同时与同一资源交互的场景时使用——聊天应用、共享文档、多人游戏功能、管理员与用户流程。 > **前置要求**:[core/fixtures-and-hooks.md](fixtures-and-hooks.md),[core/configuration.md](configuration.md) ## 快速参考 ```typescript // 一个测试中两个独立的浏览器上下文 = 两个用户 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() // 各自独立操作——不同的 cookie、会话、localStorage await alicePage.goto("/chat/room-1") await bobPage.goto("/chat/room-1") ``` ## 模式 ### 通过浏览器上下文实现一个测试中的两个用户 **适用场景**:需要验证一个用户的操作对另一个用户是实时可见的。 **避免场景**:只需测试单个用户的流程。默认每个测试一个上下文即可。 每个 `browser.newContext()` 创建一个完全隔离的会话——独立的 cookie、localStorage 和网络状态。这就是在不启动第二个浏览器的情况下,在单个测试中模拟两个已登录用户的方法。 **TypeScript** ```typescript import { test, expect } from "@playwright/test" test("alice 发送消息,bob 能看到", 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") // Alice 发送一条消息 await alicePage.getByRole("textbox", { name: "Message" }).fill("Hello Bob!") await alicePage.getByRole("button", { name: "Send" }).click() // Bob 实时看到它 await expect(bobPage.getByText("Hello Bob!")).toBeVisible() // Alice 也能看到自己的消息 await expect(alicePage.getByText("Hello Bob!")).toBeVisible() // 清理 await aliceContext.close() await bobContext.close() }) ``` **JavaScript** ```javascript const { test, expect } = require("@playwright/test") test("alice 发送消息,bob 能看到", 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() }) ``` ### 可复用的多用户 Fixture **适用场景**:多个测试都需要双用户设置。将上下文创建封装到 fixture 中以避免样板代码。 **避免场景**:只有一个测试需要多用户逻辑。 **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({ 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("两个用户都能看到共享文档标题", 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 } ``` ### 带有冲突检测的协作编辑 **适用场景**:测试实时协作编辑器(Google Docs 风格),两个用户同时编辑同一内容。 **避免场景**:应用不支持并发编辑。 **TypeScript** ```typescript import { test, expect } from "./fixtures" test("并发编辑无数据丢失地合并", 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") // Alice 在开头输入 const aliceEditor = alicePage.getByRole("textbox", { name: "Editor" }) await aliceEditor.pressSequentially("Alice was here. ") // Bob 在结尾输入(同时进行) 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.") }) ``` **JavaScript** ```javascript const { test, expect } = require("./fixtures") test("并发编辑无数据丢失地合并", 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.") }) ``` ### 共享状态验证(在线状态、光标、指示器) **适用场景**:验证一个用户的在线状态或活动是否反映在另一个用户的界面中——在线指示器、输入指示器、光标位置。 **避免场景**:在线状态不是应用的功能特性。 **TypeScript** ```typescript import { test, expect } from "./fixtures" test("bob 看到 alice 的输入指示器", async ({ alicePage, bobPage }) => { await alicePage.goto("/chat/general") await bobPage.goto("/chat/general") // Alice 开始输入 await alicePage.getByRole("textbox", { name: "Message" }).pressSequentially("Hel", { delay: 100 }) // Bob 看到输入指示器 await expect(bobPage.getByText("Alice is typing...")).toBeVisible() // Alice 停止输入——防抖后指示器消失 await expect(bobPage.getByText("Alice is typing...")).toBeHidden({ timeout: 5000 }) }) test("在线用户列表在用户加入时更新", async ({ alicePage, bobPage }) => { await alicePage.goto("/chat/general") // Alice 只看到自己 await expect(alicePage.getByTestId("online-users").getByText("Alice")).toBeVisible() await expect(alicePage.getByTestId("online-users").getByText("Bob")).toBeHidden() // Bob 加入 await bobPage.goto("/chat/general") // Alice 现在在在线列表中看到 Bob await expect(alicePage.getByTestId("online-users").getByText("Bob")).toBeVisible() }) ``` **JavaScript** ```javascript const { test, expect } = require("./fixtures") test("bob 看到 alice 的输入指示器", 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("在线用户列表在用户加入时更新", 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() }) ``` ### 用户间的竞态条件测试 **适用场景**:需要验证同时操作(两个用户点击最后一个商品上的"购买",或两个用户编辑同一个字段)是否被正确处理。 **避免场景**:应用没有共享的可变资源。 **TypeScript** ```typescript import { test, expect } from "./fixtures" test("只有一个用户可以认领最后一个商品", 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") }) test("同时提交表单不会创建重复数据", async ({ alicePage, bobPage }) => { await alicePage.goto("/admin/settings") await bobPage.goto("/admin/settings") // 双方修改同一个设置 await alicePage.getByLabel("Company name").fill("Alice Corp") await bobPage.getByLabel("Company name").fill("Bob Inc") // 同时提交 await Promise.all([ alicePage.getByRole("button", { name: "Save" }).click(), bobPage.getByRole("button", { name: "Save" }).click(), ]) // 重新加载两个页面——只有一个值应该胜出,没有数据损坏 await alicePage.reload() const finalValue = await alicePage.getByLabel("Company name").inputValue() expect(["Alice Corp", "Bob Inc"]).toContain(finalValue) // "失败方"应该看到冲突通知或更新后的值 await bobPage.reload() await expect(bobPage.getByLabel("Company name")).toHaveValue(finalValue) }) ``` **JavaScript** ```javascript const { test, expect } = require("./fixtures") test("只有一个用户可以认领最后一个商品", 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 个用户与动态上下文创建 **适用场景**:需要两个以上的用户,或用户数量是可变的情况(负载型协作测试)。 **避免场景**:两个用户已足够。保持简单。 **TypeScript** ```typescript import { test, expect, Browser, Page } from "@playwright/test" async function createUser(browser: Browser, name: string, room: string): Promise { const context = await browser.newContext({ storageState: `auth/${name}.json` }) const page = await context.newPage() await page.goto(`/chat/${room}`) return page } test("聊天室中五个用户都能看到彼此", 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() } }) ``` **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("聊天室中五个用户都能看到彼此", 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() } }) ``` ## 决策指南 | 场景 | 方案 | 原因 | | ---------------------------------------- | ---------------------------------------- | ------------------------------------------------- | | 两个用户实时交互 | 一个测试中两个 `browser.newContext()` | 完全隔离的会话,同一测试时间线 | | 多个测试中共用同一多用户设置 | 每个用户上下文的自定义 fixture | 消除样板代码,保证清理 | | 测试在线状态/输入指示器 | 顺序操作,在另一页面上断言 | 顺序很重要——在一个页面上操作,在另一个页面上断言 | | 竞态条件(同时点击) | `Promise.all([action1, action2])` | 尽可能同时触发两个操作 | | 管理员执行操作,用户看到结果 | 两个具有不同 `storageState` 的上下文 | 不同的认证角色,同一浏览器实例 | | 一个测试中 3 个以上用户 | 循环,每个用户使用 `browser.newContext()` | 每个上下文都很轻量;浏览器是共享的 | | 跨浏览器多用户(Chrome + Firefox) | 在 `beforeAll` 中分别启动浏览器 | 很少需要;同一浏览器内的上下文通常已足够 | ## 反模式 | 不要这样做 | 问题 | 应该这样做 | | ------------------------------------------- | ----------------------------------------- | ---------------------------------------------- | | 对两个用户使用一个上下文中的两个页面 | 同一上下文中的页面共享 cookie 和 localStorage | 每个用户使用 `browser.newContext()` | | 为第二个用户打开第二个浏览器 | 浪费内存和启动时间 | 使用第二个 `browser.newContext()`——上下文很轻量 | | 使用 `await page.waitForTimeout(2000)` 实现实时同步 | 任意延迟;CI 中不稳定,开发中慢 | 使用 `await expect(bobPage.getByText('msg')).toBeVisible()` | | 通过 `let` 变量在上下文之间共享可变状态 | 测试顺序和时机成为隐式依赖 | 每个上下文独立;通过 UI 断言 | | 在 `beforeAll` 中放置多用户设置 | `beforeAll` 无法访问 `page` 或 `context` | 使用测试级 fixture 或内联 `browser.newContext()` | | 忘记关闭额外上下文 | 内存泄漏,可能导致端口耗尽 | 始终在 fixture 清理或 `finally` 中关闭上下文 | | 不等待就在两个页面上断言 | 第二个页面可能尚未收到更新 | 在接收页面上使用带自动重试的 `expect` | ## 故障排查 | 症状 | 原因 | 修复 | | --------------------------------------- | ----------------------------------------- | ------------------------------------------- | | 第二个用户看到过期数据 | 上下文在服务端状态就绪之前创建 | 在第一个用户操作完成后导航第二个页面 | | `storageState` 文件未找到 | 认证设置项目尚未运行,或路径错误 | 通过 `playwright.config` 中的 `dependencies` 先运行认证设置 | | 两个用户有相同的会话 | 为两者复用了同一个 `storageState` 文件 | 为每个用户角色创建不同的认证状态文件 | | 实时更新从未到达第二个页面 | 在断言之前 WebSocket/SSE 连接尚未建立 | 在断言前等待连接指示器:`await expect(page.getByTestId('connected')).toBeVisible()` | | 测试有太多上下文时变慢 | 单个测试中上下文过多(10 个以上) | 限制每个测试 3-5 个上下文;使用 API 设置减少页面交互 | | `Promise.all` 竞态测试总是同一个胜出者 | 网络/事件循环使一个总是更快 | 这在测试中是预期的——断言应用能处理两种结果,而不是哪个用户胜出 | ## 相关文档 - [core/fixtures-and-hooks.md](fixtures-and-hooks.md)——将多用户上下文封装到 fixture 中 - [core/websockets-and-realtime.md](websockets-and-realtime.md)——测试协作功能底层的传输层 - [core/test-data-management.md](test-data-management.md)——在多用户测试前设置共享资源(房间、文档) - [core/configuration.md](configuration.md)——为不同用户角色按项目配置 `storageState`