Files
skillhub-147-playwright-skill/core/multi-user-and-collaboration.md
2026-07-13 21:36:47 +08:00

20 KiB
Raw Permalink Blame History

多用户与协作测试

使用时机:当应用涉及实时协作、多用户工作流,或任何两个及以上用户同时与同一资源交互的场景时使用——聊天应用、共享文档、多人游戏功能、管理员与用户流程。 前置要求core/fixtures-and-hooks.mdcore/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 无法访问 pagecontext 使用测试级 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 竞态测试总是同一个胜出者 网络/事件循环使一个总是更快 这在测试中是预期的——断言应用能处理两种结果,而不是哪个用户胜出

相关文档