20 KiB
20 KiB
多用户与协作测试
使用时机:当应用涉及实时协作、多用户工作流,或任何两个及以上用户同时与同一资源交互的场景时使用——聊天应用、共享文档、多人游戏功能、管理员与用户流程。 前置要求:core/fixtures-and-hooks.md,core/configuration.md
快速参考
// 一个测试中两个独立的浏览器上下文 = 两个用户
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
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
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
// 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 }
// 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
// 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
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
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
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
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
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
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
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("聊天室中五个用户都能看到彼此", 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
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——将多用户上下文封装到 fixture 中
- core/websockets-and-realtime.md——测试协作功能底层的传输层
- core/test-data-management.md——在多用户测试前设置共享资源(房间、文档)
- core/configuration.md——为不同用户角色按项目配置
storageState