用 Page Object、自定义 Fixture 与 Helper 组织可复用的 Playwright 测试代码——以 SurfSense E2E 测试套件为例
用 Page Object、自定义 Fixture 与 Helper 组织可复用的 Playwright 测试代码——以 SurfSense E2E 测试套件为例【免费下载链接】SurfSenseOpen-source NotebookLM alternative. Research the open web with live data(Reddit, YT, IG, TikTok, Indeed, Google Search, Maps etc) through one platform, API or MCP server. Join our Discord: https://discord.gg/ejRNvftDp9项目地址: https://gitcode.com/GitHub_Trending/su/SurfSense本文以 Playwright 测试中三种最常用的代码复用模式Page Object、自定义 Fixture、Helper 函数为主线系统讲解它们各自的适用场景、取舍标准与组合方式并结合 SurfSense 仓库中真实运行的 E2E 测试套件Next.js FastAPI Celery Postgres Redis 全栈展示落地实践。读完本文你将掌握一套选择模式 → 编写 Fixture → 组合分层 → 规避反模式的完整方法能够直接迁移到自己的项目中组织大规模、可并行、可维护的端到端测试。三种模式的本质区别与选择标准Playwright 官方文档.cursor/skills/playwright-testing/architecture/pom-vs-fixtures.md给出的第一原则是不要二选一而是三种模式混合使用。绝大多数项目受益于混合策略Page Object——用于 UI 交互页面 / 组件出现 5 次以上交互时自定义 Fixture——用于测试基础设施认证状态、数据库、API 客户端、任何有生命周期的资源Helper 函数——用于无状态工具生成数据、格式化值、简单等待。如果只能选一种官方建议选择自定义 Fixture它天然处理 setup/teardown、可组合、且 Playwright 本身就是围绕它构建的。模式对比表方面Page ObjectsCustom FixturesHelper Functions目的封装 UI 交互提供带 setup/teardown 的资源无状态工具函数生命周期手动构造函数/方法内置use()自动 teardown无可组合性构造函数注入或 fixture 装配依赖其他 fixture调用其他函数最佳场景有很多被复用的交互的页面需要 setup和teardown 的资源无副作用的简单逻辑选择流程图原文档提供了一个非常实用的决策流程可以浓缩为以下分支什么是需要复用的代码 | -- 与浏览器页面/组件交互 | | | -- 有 5 次交互fill, click, navigate, assert | | -- 是在 3 个测试文件中使用 | | | -- 是 -- PAGE OBJECT | | | -- 否 -- 内联或小型 helper | | -- 否 -- HELPER FUNCTION | | | -- 测试前需要 setup、测试后需要 cleanup | -- 是 -- CUSTOM FIXTURE | -- 否 -- PAGE OBJECT 方法或 HELPER | -- 管理有生命周期的资源create/destroy | -- 例如auth state、DB 连接、API client、测试用户 | -- 是 -- CUSTOM FIXTURE始终 | -- 无状态工具不碰浏览器、无副作用 | -- 例如随机邮箱、格式化日期、构造 URL、解析响应 | -- 是 -- HELPER FUNCTION | -- 不确定 -- 从 HELPER FUNCTION 开始 -- 交互变多时升级为 PAGE OBJECT -- 需要生命周期时升级为 FIXTUREPage Object封装 UI 交互让测试表达用户意图Page Object 适合有 5 次以上交互、且出现在 3 个以上测试文件中的页面/组件。其核心思想是把定位器locator和操作action封装进一个类测试代码只表达业务步骤不暴露 CSS 选择器等实现细节。官方推荐的结构// page-objects/booking.page.ts import { type Page, type Locator, expect } from playwright/test; export class BookingPage { readonly page: Page; readonly dateField: Locator; readonly guestCount: Locator; readonly roomType: Locator; readonly reserveBtn: Locator; readonly totalPrice: Locator; constructor(page: Page) { this.page page; this.dateField page.getByLabel(Check-in date); this.guestCount page.getByLabel(Guests); this.roomType page.getByLabel(Room type); this.reserveBtn page.getByRole(button, { name: Reserve }); this.totalPrice page.getByTestId(total-price); } async goto() { await this.page.goto(/booking); } async fillDetails(opts: { date: string; guests: number; room: string }) { await this.dateField.fill(opts.date); await this.guestCount.fill(String(opts.guests)); await this.roomType.selectOption(opts.room); } async reserve() { await this.reserveBtn.click(); await this.page.waitForURL(**/confirmation); } async expectPrice(amount: string) { await expect(this.totalPrice).toHaveText(amount); } }对应的测试文件// tests/booking/reservation.spec.ts import { test, expect } from playwright/test; import { BookingPage } from ../page-objects/booking.page; test(complete reservation with standard room, async ({ page }) { const booking new BookingPage(page); await booking.goto(); await booking.fillDetails({ date: 2026-03-15, guests: 2, room: standard }); await booking.reserve(); await expect(page.getByText(Reservation confirmed)).toBeVisible(); });Page Object 五大原则一个逻辑页面/组件一个类而不是一个 URL 一个类构造函数接收PageLocator 以readonly属性形式在构造函数中初始化方法表达用户意图reserve、fillDetails而不是底层点击导航方法goto属于 page object。SurfSense 中的实践印证在 SurfSense 仓库中UI 交互辅助被集中在 helpers/ui/ 目录下。例如 connector-popup.ts 中的expectImportConnectorAvailable(page, Google Drive)就是典型的交互封装它把在新建聊天页面上断言某个连接器可用于导入这一组 UI 操作收敛成一个带语义的函数journey spec 中一行即可调用await page.goto(/dashboard/${workspace.id}/new-chat, { waitUntil: domcontentloaded }); await expectImportConnectorAvailable(page, Google Drive);同时 tests/README.md 也明确指出纯 UI 型测试文件夹树拖拽、索引选项开关等统一放在helpers/ui/下为后续 Phase 2 工作预留空间。这印证了将 UI 交互封装、把底层选择器隔离在独立层级这一 page object 思想的实践价值。自定义 Fixture管理资源生命周期Playwright 的基石Fixture 适合需要在测试前 setup、测试后 teardown 的资源——认证状态、数据库连接、API 客户端、测试用户。Playwright 的 fixture 机制通过test.extend()定义用use()回调把 setup 与 teardown 分离即使测试失败 teardown 也保证执行。官方推荐的结构// fixtures/base.fixture.ts import { test as base, expect } from playwright/test; import { BookingPage } from ../page-objects/booking.page; import { generateMember } from ../helpers/data; type Fixtures { bookingPage: BookingPage; member: { email: string; password: string; id: string }; loggedInPage: import(playwright/test).Page; }; export const test base.extendFixtures({ bookingPage: async ({ page }, use) { await use(new BookingPage(page)); }, member: async ({ request }, use) { const data generateMember(); const res await request.post(/api/test/members, { data }); const member await res.json(); await use(member); await request.delete(/api/test/members/${member.id}); }, loggedInPage: async ({ page, member }, use) { await page.goto(/login); await page.getByLabel(Email).fill(member.email); await page.getByLabel(Password).fill(member.password); await page.getByRole(button, { name: Sign in }).click(); await expect(page).toHaveURL(/dashboard); await use(page); }, }); export { expect } from playwright/test;注意member这个 fixture 展示了完整生命周期use(member)之前是 setup创建测试用户之后是 teardown删除测试用户——即便断言失败删除依然会执行。Fixture 六大原则使用test.extend()——绝不使用模块级变量use()回调把 setup 与 teardown 分离teardown 在测试失败时也会运行Fixture 可组合一个可以依赖另一个Fixture 是惰性的只有被请求时才创建把 page object 包进 fixture 以管理其生命周期。SurfSense 的真实 Fixture 链SurfSense 的 E2E 套件是 fixture 组合的极佳范例。tests/fixtures/index.ts 用注释画出了完整的继承链base (playwright/test) └─ workspaceFixtures — apiToken, workspace ├─ composioDriveFixtures — composioDriveConnector │ └─ composioDriveWithChatTest — chatThread ├─ nativeGmailFixtures — nativeGmailConnector │ └─ nativeGmailWithChatTest — chatThread └─ ...ClickUp、Notion、Confluence、Linear、Jira、Slack 等十余个连接器基础层workspace fixtureworkspace.fixture.ts 定义了apiToken和workspace两个 fixtureapiTokenWorker使用worker 作用域{ scope: worker }整个 worker 只登录一次先从 auth.setup.ts 写入的 session cookie 缓存playwright/.auth/user.json里读取 token缓存未命中时才通过免限流的/__e2e__/auth/token接口获取workspace在use(space)之前调用createWorkspace()创建命名空间测试结束后的finally块中调用deleteWorkspace()清理。workspace: async ({ request, apiToken }, use) { const space await createWorkspace(request, apiToken, uniqueWorkspaceName(composio-drive-e2e)); try { await use(space); } finally { await deleteWorkspace(request, apiToken, space.id); } },这里可以看到官方原则teardown 用finally保证执行在真实代码中的落地——无论测试断言是否通过workspace 都会被删除不会污染后续测试。组合层连接器 聊天线程connectors/composio-drive.fixture.ts 在 workspace 之上组合出composioDriveConnector它先执行runComposioOAuth()完成 OAuth对着严格 fake不发真实网络请求再把连接器行交给测试结束后deleteConnector()清理。chat-thread.fixture.ts 则演示了一个 fixture 依赖多个 fixture的组合chatThread同时依赖request、apiToken、workspace通过 API 创建一条私有聊天线程并返回。而在 index.ts 中这些 fixture 通过test.extend()逐层叠加最终导出 20 多个具名test例如export const composioDriveWithChatTest composioDriveFixtures.extendChatThreadFixtures(chatThreadFixtures);Spec 只需从统一入口导入即可同时获得workspace、composioDriveConnector、chatThreadimport { expect, composioDriveWithChatTest as test } from ../../../fixtures; test(user connects Drive, selects a file, indexes it, and chats with the canary token, async ({ page, request, apiToken, workspace, composioDriveConnector, chatThread, }) { // ...触发索引、断言 canary token、流式聊天 });这正是官方fixtures compose: one can depend on another与wrap page objects in fixtures原则的工程化体现。整套设计的目标见 tests/README.md是从一个连接器扩展到所有连接器 手动上传而无需重写测试框架。Helper 函数无状态工具保持小而专注Helper 适合无状态的纯工具——生成测试数据、格式化值、构造 URL、解析响应。原则是纯函数、无副作用、不持有浏览器状态需要page就作为参数传入、保持小而专注。官方推荐的结构// helpers/data.ts import { randomUUID } from node:crypto; export function generateEmail(prefix user): string { return ${prefix}-${Date.now()}-${randomUUID().slice(0, 8)}test.local; } export function generateMember(overrides: PartialMember {}): Member { return { email: generateEmail(), password: SecurePass456!, name: Test Member, ...overrides, }; }// helpers/assertions.ts import { type Page, expect } from playwright/test; export async function expectNotification(page: Page, message: string): Promisevoid { const notification page.getByRole(alert).filter({ hasText: message }); await expect(notification).toBeVisible(); await expect(notification).toBeHidden({ timeout: 10000 }); }SurfSense 的 Helper 分层SurfSense 把 helper 按职责拆成四类目录tests/helpers/api/通过 API 请求封装后端操作如 workspaces.ts 的createWorkspace/deleteWorkspace、auth.ts 的authHeaders、acquireTestToken、BACKEND_URLui/UI 交互封装前述的expectImportConnectorAvailable等mocks/如 composio-oauth.ts提供对后端 fake 的模拟支持waits/轮询等待如 indexing.ts 的waitForIndexingComplete、waitForDocumentByTitle以及顶层 canary.ts集中管理所有连接器的确定性金丝雀数据。其中 canary.ts 是最典型的无状态数据工具CANARY_TOKENS常量表为每个连接器定义稳定的金丝雀字符串如SURFSENSE_E2E_CANARY_TOKEN_DRIVE_001uniqueWorkspaceName(prefix)用randomUUID()生成唯一的命名空间名。后者直接呼应官方示例中的generateEmail()模式export function uniqueWorkspaceName(prefix e2e): string { return ${prefix}-${randomUUID().slice(0, 8)}; }workspacefixture 中正是用这个函数保证每个测试的工作空间互不冲突从而支撑fullyParallel: true的并行执行playwright.config.ts。组合后的项目结构四层职责分层官方文档给出了一个理想的目录骨架tests/ -- fixtures/ | -- auth.fixture.ts | -- db.fixture.ts | -- base.fixture.ts -- page-objects/ | -- login.page.ts | -- booking.page.ts | -- components/ | -->// BAD: page object 处理 API 调用和数据库 class LoginPage { async createUser() { /* API call */ } async deleteUser() { /* API call */ } async signIn(email: string, password: string) { /* UI */ } }资源生命周期应属于 fixtureteardown 有保证page object 只保留signIn。对照 SurfSenseOAuth、workspace、连接器、聊天线程的创建与销毁全部在 fixture 中完成UI 交互封装helpers/ui/绝不持有资源。2. 只有 locator、没有方法的 page objectBAD// BAD: 没有方法只有 locator class LoginPage { emailInput this.page.getByLabel(Email); passwordInput this.page.getByLabel(Password); submitBtn this.page.getByRole(button, { name: Sign in }); constructor(private page: Page) {} }要么添加能揭示意图的方法要么干脆不建 page object。3. 巨型 fixtureBAD// BAD: 一个 fixture 做所有事 test.extend({ everything: async ({ page, request }, use) { const user await createUser(request); const products await seedProducts(request, 50); await setupPayment(request, user.id); await page.goto(/dashboard); await use({ user, products, page }); // massive teardown... }, });要拆成小而可组合的 fixture每个 fixture 只做一件事。SurfSense 正是这么做的workspace、connector、chatThread 各司其职再由 index.ts 通过extend()自由装配新增一个连接器只需新增一个 fixture 文件 一行 re-export。4. 带副作用的 helperBAD// BAD: 模块级状态 let createdUserId: string; export async function createTestUser(request: APIRequestContext) { const res await request.post(/api/users, { data: { email: testexample.com } }); const user await res.json(); createdUserId user.id; // shared across tests! return user; }模块级状态会在并行测试之间泄漏。有副作用且需要清理的代码应做成 fixture。SurfSense 的acquireTestToken、createWorkspace等纯函数式 API helper 不持有任何模块级状态需要创建 清理的场景一律交给 fixture。5. 过度抽象简单操作BAD// BAD: 为一行的操作写 helper export async function clickButton(page: Page, name: string) { await page.getByRole(button, { name }).click(); }只有存在真实重复3 次以上使用或复杂度5 次以上交互时才抽象。官方原则也提醒不确定时从 helper 开始交互增长后升级为 page object需要生命周期时升级为 fixture。在 SurfSense 中运行与验证这套模式的真实运行入口与验证方法如下详见 tests/README.md配置playwright.config.ts 定义了setup项目执行 auth.setup.ts 写入会话状态与chromium项目通过storageState: playwright/.auth/user.json复用登录态并配置了webServer自动启动 Next.js 开发服务器运行命令在surfsense_web/下pnpm test:e2e开发模式、pnpm test:e2e:prod构建后运行与 CI 完全一致、pnpm test:e2e:uiUI 模式、pnpm test:e2e:debugPlaywright Inspector确定性保障三层防线——专用 E2E 入口surfsense_backend/tests/e2e/run_backend.py、run_celery.py在导入应用前劫持sys.modules替换严格 fakefake 对未知接口抛NotImplementedErrorCI 设置HTTPS_PROXYhttp://127.0.0.1:1与哨兵 API key任何泄漏的出站请求在触网前即失败。小结组织可复用测试代码的最优解不是挑选单一模式而是让三种模式各司其职Page Object 管 UI 交互、自定义 Fixture 管资源生命周期、Helper 管无状态工具并通过test.extend()把它们组合成一条清晰的依赖链。SurfSense 的 E2E 套件以二十多个组合 fixture 支撑起十几个连接器的旅程测试同时保持确定性、可并行与可扩展性是这套方法论在真实全栈项目中的完整落地样本。当你下次面对这段测试代码该放哪的困惑时回到那张选择流程图即可有生命周期就上 fixture交互复杂就上 page object其余都交给小而纯的 helper。【免费下载链接】SurfSenseOpen-source NotebookLM alternative. Research the open web with live data(Reddit, YT, IG, TikTok, Indeed, Google Search, Maps etc) through one platform, API or MCP server. Join our Discord: https://discord.gg/ejRNvftDp9项目地址: https://gitcode.com/GitHub_Trending/su/SurfSense创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考