29 KiB
Page Object Model
使用时机:当任意页面或组件被超过一个测试文件引用,或单个测试文件与超过两个不同 UI 区域交互时。
Page Object 封装的是行为,而非定位器。测试代码读起来应像用户故事:await loginPage.login('admin', 'secret'),绝不出现 await loginPage.usernameInput.fill('admin')。POM 是"用户做了什么"与"UI 如何结构化"之间的边界。断言保留在测试中,永远不要放在 page object 内部。
快速参考
| 概念 | 规则 |
|---|---|
| 定位器 | 定义为构造函数中的 readonly 属性或 getter。绝不向测试暴露原始定位器。 |
| 行为方法 | 公开方法,执行用户可见的行为。返回 Promise<void> 或下一个 page object。 |
| 断言 | 绝不放在 page object 内部。所有 expect() 调用由测试负责。 |
| 导航 | 执行导航的方法返回目标 page object,而非 void。 |
| 状态 | Page object 是无状态的。不缓存定位器文本,不跟踪"当前步骤"。 |
| 构造函数 | 仅接收 Page(组件则接收 Locator)。不接受其他参数。不传 URL、不传测试数据。 |
| 命名 | LoginPage、DashboardPage、NavbarComponent。文件名:login.page.ts、navbar.component.ts。 |
模式
1. 基础 POM 类
使用时机:一个页面在多个测试中有 3 个以上交互时。 避免时机:页面仅在单个测试文件中使用且交互很简单——此时应使用辅助函数。
TypeScript
// tests/pages/login.page.ts
import { type Page, type Locator } from "@playwright/test"
export class LoginPage {
readonly usernameInput: Locator
readonly passwordInput: Locator
readonly submitButton: Locator
readonly errorMessage: Locator
constructor(private readonly page: Page) {
this.usernameInput = page.getByLabel("Username")
this.passwordInput = page.getByLabel("Password")
this.submitButton = page.getByRole("button", { name: "Sign in" })
this.errorMessage = page.getByRole("alert")
}
async goto() {
await this.page.goto("/login")
}
async login(username: string, password: string) {
await this.usernameInput.fill(username)
await this.passwordInput.fill(password)
await this.submitButton.click()
}
}
JavaScript
// tests/pages/login.page.js
// @ts-check
export class LoginPage {
/** @param {import('@playwright/test').Page} page */
constructor(page) {
this.page = page
this.usernameInput = page.getByLabel("Username")
this.passwordInput = page.getByLabel("Password")
this.submitButton = page.getByRole("button", { name: "Sign in" })
this.errorMessage = page.getByRole("alert")
}
async goto() {
await this.page.goto("/login")
}
async login(username, password) {
await this.usernameInput.fill(username)
await this.passwordInput.fill(password)
await this.submitButton.click()
}
}
测试使用
// tests/auth.spec.ts
import { test, expect } from "@playwright/test"
import { LoginPage } from "./pages/login.page"
test("successful login redirects to dashboard", async ({ page }) => {
const loginPage = new LoginPage(page)
await loginPage.goto()
await loginPage.login("admin", "password123")
await expect(page).toHaveURL("/dashboard")
})
test("invalid credentials show error", async ({ page }) => {
const loginPage = new LoginPage(page)
await loginPage.goto()
await loginPage.login("admin", "wrong")
await expect(loginPage.errorMessage).toBeVisible()
await expect(loginPage.errorMessage).toHaveText("Invalid credentials")
})
2. 组件对象
使用时机:某个 UI 元素(导航栏、侧边栏、弹窗、表单、表格)出现在多个页面中时。 避免时机:组件是页面特有的且不会被复用时。
组件接收 Locator(其根容器),而非 Page。这样可以将所有查询限定在组件的 DOM 子树范围内,并允许将组件组合到 page object 中。
TypeScript
// tests/components/navbar.component.ts
import { type Locator } from "@playwright/test"
export class NavbarComponent {
readonly profileMenu: Locator
readonly searchInput: Locator
readonly notificationBell: Locator
constructor(private readonly root: Locator) {
this.profileMenu = root.getByRole("button", { name: "Profile" })
this.searchInput = root.getByRole("searchbox")
this.notificationBell = root.getByRole("button", { name: "Notifications" })
}
async search(query: string) {
await this.searchInput.fill(query)
await this.searchInput.press("Enter")
}
async openProfile() {
await this.profileMenu.click()
}
async openNotifications() {
await this.notificationBell.click()
}
}
// tests/components/modal.component.ts
import { type Locator } from "@playwright/test"
export class ModalComponent {
readonly title: Locator
readonly closeButton: Locator
readonly confirmButton: Locator
readonly cancelButton: Locator
constructor(private readonly root: Locator) {
this.title = root.getByRole("heading")
this.closeButton = root.getByRole("button", { name: "Close" })
this.confirmButton = root.getByRole("button", { name: "Confirm" })
this.cancelButton = root.getByRole("button", { name: "Cancel" })
}
async confirm() {
await this.confirmButton.click()
}
async cancel() {
await this.cancelButton.click()
}
async close() {
await this.closeButton.click()
}
}
// tests/pages/dashboard.page.ts
import { type Page } from "@playwright/test"
import { NavbarComponent } from "../components/navbar.component"
import { ModalComponent } from "../components/modal.component"
export class DashboardPage {
readonly navbar: NavbarComponent
readonly deleteModal: ModalComponent
constructor(private readonly page: Page) {
this.navbar = new NavbarComponent(page.getByRole("navigation"))
this.deleteModal = new ModalComponent(page.getByRole("dialog"))
}
async goto() {
await this.page.goto("/dashboard")
}
async deleteItem(name: string) {
await this.page.getByRole("row", { name }).getByRole("button", { name: "Delete" }).click()
await this.deleteModal.confirm()
}
}
JavaScript
// tests/components/navbar.component.js
// @ts-check
export class NavbarComponent {
/** @param {import('@playwright/test').Locator} root */
constructor(root) {
this.root = root
this.profileMenu = root.getByRole("button", { name: "Profile" })
this.searchInput = root.getByRole("searchbox")
this.notificationBell = root.getByRole("button", { name: "Notifications" })
}
async search(query) {
await this.searchInput.fill(query)
await this.searchInput.press("Enter")
}
async openProfile() {
await this.profileMenu.click()
}
async openNotifications() {
await this.notificationBell.click()
}
}
// tests/pages/dashboard.page.js
// @ts-check
import { NavbarComponent } from "../components/navbar.component.js"
import { ModalComponent } from "../components/modal.component.js"
export class DashboardPage {
/** @param {import('@playwright/test').Page} page */
constructor(page) {
this.page = page
this.navbar = new NavbarComponent(page.getByRole("navigation"))
this.deleteModal = new ModalComponent(page.getByRole("dialog"))
}
async goto() {
await this.page.goto("/dashboard")
}
async deleteItem(name) {
await this.page.getByRole("row", { name }).getByRole("button", { name: "Delete" }).click()
await this.deleteModal.confirm()
}
}
3. 带导航的 Page Object
使用时机:一个页面上的某个行为会导航到另一个页面时(表单提交、链接点击、重定向)。 避免时机:导航结果不确定时(可能验证失败而停留在同一页面)。
当方法引起导航时,返回目标 page object。这创建了一个类型化的链式调用,反映了用户流程,并在调用处捕获已过期的 page object 使用。
TypeScript
// tests/pages/login.page.ts
import { type Page } from "@playwright/test"
import { DashboardPage } from "./dashboard.page"
export class LoginPage {
constructor(private readonly page: Page) {}
async goto() {
await this.page.goto("/login")
}
/** 成功时返回 DashboardPage。仅在凭据有效时调用。 */
async loginAs(username: string, password: string): Promise<DashboardPage> {
await this.page.getByLabel("Username").fill(username)
await this.page.getByLabel("Password").fill(password)
await this.page.getByRole("button", { name: "Sign in" }).click()
await this.page.waitForURL("/dashboard")
return new DashboardPage(this.page)
}
/** 用于无效凭据测试——停留在登录页面。 */
async loginExpectingError(username: string, password: string) {
await this.page.getByLabel("Username").fill(username)
await this.page.getByLabel("Password").fill(password)
await this.page.getByRole("button", { name: "Sign in" }).click()
}
}
// tests/pages/dashboard.page.ts
import { type Page } from "@playwright/test"
import { SettingsPage } from "./settings.page"
export class DashboardPage {
constructor(private readonly page: Page) {}
async gotoSettings(): Promise<SettingsPage> {
await this.page.getByRole("link", { name: "Settings" }).click()
await this.page.waitForURL("/settings")
return new SettingsPage(this.page)
}
}
JavaScript
// tests/pages/login.page.js
// @ts-check
import { DashboardPage } from "./dashboard.page.js"
export class LoginPage {
/** @param {import('@playwright/test').Page} page */
constructor(page) {
this.page = page
}
async goto() {
await this.page.goto("/login")
}
/** @returns {Promise<DashboardPage>} */
async loginAs(username, password) {
await this.page.getByLabel("Username").fill(username)
await this.page.getByLabel("Password").fill(password)
await this.page.getByRole("button", { name: "Sign in" }).click()
await this.page.waitForURL("/dashboard")
return new DashboardPage(this.page)
}
async loginExpectingError(username, password) {
await this.page.getByLabel("Username").fill(username)
await this.page.getByLabel("Password").fill(password)
await this.page.getByRole("button", { name: "Sign in" }).click()
}
}
展示导航链的测试用法
// tests/settings.spec.ts
import { test, expect } from "@playwright/test"
import { LoginPage } from "./pages/login.page"
test("user can navigate from login to settings", async ({ page }) => {
const loginPage = new LoginPage(page)
await loginPage.goto()
const dashboard = await loginPage.loginAs("admin", "password123")
const settings = await dashboard.gotoSettings()
await expect(page).toHaveURL("/settings")
})
4. 作为 Fixture 的 Page Object
使用时机:Page object 在 3 个以上测试文件中使用时。这是成熟测试套件的推荐做法。 避免时机:早期原型开发,页面频繁变化时。
Fixtures 消除了样板化的实例化代码,提供自动清理,并通过解构使 POM 可用——与内置的 page 和 request fixtures 一致。
TypeScript
// tests/fixtures.ts
import { test as base } from "@playwright/test"
import { LoginPage } from "./pages/login.page"
import { DashboardPage } from "./pages/dashboard.page"
import { SettingsPage } from "./pages/settings.page"
type PageObjects = {
loginPage: LoginPage
dashboardPage: DashboardPage
settingsPage: SettingsPage
}
export const test = base.extend<PageObjects>({
loginPage: async ({ page }, use) => {
await use(new LoginPage(page))
},
dashboardPage: async ({ page }, use) => {
await use(new DashboardPage(page))
},
settingsPage: async ({ page }, use) => {
await use(new SettingsPage(page))
},
})
export { expect } from "@playwright/test"
JavaScript
// tests/fixtures.js
// @ts-check
import { test as base } from "@playwright/test"
import { LoginPage } from "./pages/login.page.js"
import { DashboardPage } from "./pages/dashboard.page.js"
import { SettingsPage } from "./pages/settings.page.js"
export const test = base.extend({
loginPage: async ({ page }, use) => {
await use(new LoginPage(page))
},
dashboardPage: async ({ page }, use) => {
await use(new DashboardPage(page))
},
settingsPage: async ({ page }, use) => {
await use(new SettingsPage(page))
},
})
export { expect } from "@playwright/test"
测试用法
// tests/auth.spec.ts
import { test, expect } from "./fixtures"
test("successful login redirects to dashboard", async ({ loginPage, page }) => {
await loginPage.goto()
await loginPage.login("admin", "password123")
await expect(page).toHaveURL("/dashboard")
})
高级:执行设置的 fixture
// tests/fixtures.ts
import { test as base } from "@playwright/test"
import { DashboardPage } from "./pages/dashboard.page"
export const test = base.extend<{ authenticatedDashboard: DashboardPage }>({
authenticatedDashboard: async ({ page }, use) => {
// 设置:导航并认证
await page.goto("/login")
await page.getByLabel("Username").fill("admin")
await page.getByLabel("Password").fill("password123")
await page.getByRole("button", { name: "Sign in" }).click()
await page.waitForURL("/dashboard")
await use(new DashboardPage(page))
// 清理(可选):注销、清理测试数据
await page.goto("/logout")
},
})
export { expect } from "@playwright/test"
5. 工厂函数
使用时机:一个页面的行为方法较少且没有共享状态时。比类更简单,但仍然封装了行为。 避免时机:页面有 5 个以上方法或需要组件组合时。
工厂函数是简单页面的轻量替代方案。它们返回一个包含行为方法的对象,避免了类的形式开销。
TypeScript
// tests/pages/error.page.ts
import { type Page } from "@playwright/test"
export function createErrorPage(page: Page) {
return {
errorHeading: page.getByRole("heading", { name: "Something went wrong" }),
retryButton: page.getByRole("button", { name: "Retry" }),
async goto() {
await page.goto("/error")
},
async retry() {
await page.getByRole("button", { name: "Retry" }).click()
},
async goHome() {
await page.getByRole("link", { name: "Go home" }).click()
await page.waitForURL("/")
},
}
}
JavaScript
// tests/pages/error.page.js
// @ts-check
/** @param {import('@playwright/test').Page} page */
export function createErrorPage(page) {
return {
errorHeading: page.getByRole("heading", { name: "Something went wrong" }),
retryButton: page.getByRole("button", { name: "Retry" }),
async goto() {
await page.goto("/error")
},
async retry() {
await page.getByRole("button", { name: "Retry" }).click()
},
async goHome() {
await page.getByRole("link", { name: "Go home" }).click()
await page.waitForURL("/")
},
}
}
测试用法
// tests/error.spec.ts
import { test, expect } from "@playwright/test"
import { createErrorPage } from "./pages/error.page"
test("retry button reloads the page", async ({ page }) => {
const errorPage = createErrorPage(page)
await errorPage.goto()
await errorPage.retry()
await expect(page).not.toHaveURL("/error")
})
6. 动态定位器的 Getter 模式
使用时机:定位器依赖于动态内容(参数化的行、第 N 个元素、页面加载后变化的内容)时。 避免时机:所有定位器都是静态的且在构造时已知——此时应使用构造函数赋值。
Getter 在每次访问时重新创建定位器。这避免了 DOM 在 page object 构造完成后发生变化时出现过期引用。
TypeScript
// tests/pages/users.page.ts
import { type Page, type Locator } from "@playwright/test"
export class UsersPage {
constructor(private readonly page: Page) {}
/** 静态定位器——放在构造函数或 getter 中均可。 */
get addUserButton(): Locator {
return this.page.getByRole("button", { name: "Add user" })
}
get userRows(): Locator {
return this.page.getByRole("row").filter({ hasNot: this.page.getByRole("columnheader") })
}
/** 动态定位器——必须为方法,因为需要接收参数。 */
userRow(name: string): Locator {
return this.page.getByRole("row", { name })
}
userDeleteButton(name: string): Locator {
return this.userRow(name).getByRole("button", { name: "Delete" })
}
async goto() {
await this.page.goto("/admin/users")
}
async deleteUser(name: string) {
await this.userDeleteButton(name).click()
// 等待行消失,确认删除完成
await this.userRow(name).waitFor({ state: "hidden" })
}
async addUser() {
await this.addUserButton.click()
}
}
JavaScript
// tests/pages/users.page.js
// @ts-check
export class UsersPage {
/** @param {import('@playwright/test').Page} page */
constructor(page) {
this.page = page
}
get addUserButton() {
return this.page.getByRole("button", { name: "Add user" })
}
get userRows() {
return this.page.getByRole("row").filter({ hasNot: this.page.getByRole("columnheader") })
}
userRow(name) {
return this.page.getByRole("row", { name })
}
userDeleteButton(name) {
return this.userRow(name).getByRole("button", { name: "Delete" })
}
async goto() {
await this.page.goto("/admin/users")
}
async deleteUser(name) {
await this.userDeleteButton(name).click()
await this.userRow(name).waitFor({ state: "hidden" })
}
async addUser() {
await this.addUserButton.click()
}
}
7. 目录结构
使用以下布局。根据你的约定调整文件夹名称,但保持页面、组件和 fixtures 之间的分离。
tests/
fixtures.ts # 包含 POM fixtures 的自定义 test
auth.spec.ts # 测试文件放在顶层或功能文件夹中
dashboard.spec.ts
pages/ # 每个页面一个文件
login.page.ts
dashboard.page.ts
settings.page.ts
users.page.ts
components/ # 可复用的 UI 组件
navbar.component.ts
modal.component.ts
sidebar.component.ts
data-table.component.ts
规则:
- 每个文件一个类。 文件名与类名匹配:
LoginPage位于login.page.ts中。 - 页面 vs 组件:页面拥有一个完整路由(
/login、/dashboard)。组件嵌入在页面中(NavbarComponent、ModalComponent)。 - 不使用 barrel 文件(
index.ts重新导出所有内容)。直接从文件导入。Barrel 文件会产生循环依赖陷阱并拖慢 IDE 工具。 fixtures.ts位于tests/根目录。 每个 spec 从./fixtures导入test和expect,而不是从@playwright/test。
8. 处理异步初始化
使用时机:page object 在可用之前需要异步设置(等待 API 数据加载、等待动画完成、等待特定元素出现)时。
避免时机:页面在 goto() 之后立即可用时。
永远不要在构造函数中使用 await。应使用静态工厂方法代替。
TypeScript
// tests/pages/analytics.page.ts
import { type Page, type Locator } from "@playwright/test"
export class AnalyticsPage {
readonly chart: Locator
readonly dateRange: Locator
private constructor(private readonly page: Page) {
this.chart = page.locator('[data-testid="analytics-chart"]')
this.dateRange = page.getByLabel("Date range")
}
/** 使用此方法代替 `new AnalyticsPage()`。等待图表数据加载完成。 */
static async create(page: Page): Promise<AnalyticsPage> {
const analyticsPage = new AnalyticsPage(page)
await page.goto("/analytics")
// 等待使该页面"就绪"的异步内容
await analyticsPage.chart.waitFor({ state: "visible" })
return analyticsPage
}
async selectDateRange(range: string) {
await this.dateRange.click()
await this.page.getByRole("option", { name: range }).click()
// 等待日期更改后图表更新
await this.chart.waitFor({ state: "visible" })
}
}
JavaScript
// tests/pages/analytics.page.js
// @ts-check
export class AnalyticsPage {
/** @param {import('@playwright/test').Page} page */
constructor(page) {
this.page = page
this.chart = page.locator('[data-testid="analytics-chart"]')
this.dateRange = page.getByLabel("Date range")
}
/** @param {import('@playwright/test').Page} page */
static async create(page) {
const analyticsPage = new AnalyticsPage(page)
await page.goto("/analytics")
await analyticsPage.chart.waitFor({ state: "visible" })
return analyticsPage
}
async selectDateRange(range) {
await this.dateRange.click()
await this.page.getByRole("option", { name: range }).click()
await this.chart.waitFor({ state: "visible" })
}
}
作为 Fixture 使用
// tests/fixtures.ts
import { test as base } from "@playwright/test"
import { AnalyticsPage } from "./pages/analytics.page"
export const test = base.extend<{ analyticsPage: AnalyticsPage }>({
analyticsPage: async ({ page }, use) => {
const analyticsPage = await AnalyticsPage.create(page)
await use(analyticsPage)
},
})
export { expect } from "@playwright/test"
决策指南
页面的复杂程度如何?
│
├── 1-2 个交互,单个测试文件
│ └── 不需要 POM。直接在测试中使用内联定位器即可。
│
├── 3-5 个交互,或被 2 个以上测试文件使用
│ ├── 方法较少,无需组合
│ │ └── 工厂函数(模式 5)
│ └── 方法较多,需要组件组合
│ └── 完整的 POM 类(模式 1 + 2)
│
├── 在 3 个以上测试文件中使用
│ └── POM 类 + fixture 注入(模式 4)
│
└── 页面在使用前需要异步设置
└── 静态工厂方法(模式 8)+ fixture
| 因素 | 简单辅助函数 | 工厂函数 | POM 类 | POM + fixture |
|---|---|---|---|---|
| 页面复杂度 | 1-2 个行为 | 3-5 个行为 | 5 个以上行为 | 5 个以上行为 |
| 跨文件复用 | 1 个文件 | 1-2 个文件 | 2 个以上文件 | 3 个以上文件 |
| 组件组合 | 不支持 | 不支持 | 支持 | 支持 |
| 团队规模 | 单人 | 小团队 | 任意 | 任意 |
| 设置开销 | 无 | 低 | 中等 | 中等(一次编写) |
| 推荐用途 | 快速原型 | 简单页面 | 标准选择 | 成熟套件 |
反模式
上帝对象
整个应用程序只有一个 page object,或者一个 page object 包含 30 个以上涉及不相关功能的方法。
// 错误:一个类管理所有内容
class AppPage {
async login() {
/* ... */
}
async addToCart() {
/* ... */
}
async checkout() {
/* ... */
}
async changePassword() {
/* ... */
}
async viewAnalytics() {
/* ... */
}
// 还有 40 多个方法...
}
修复:按页面或功能拆分。每个逻辑页面一个类。将共享 UI 组合为组件对象。
在 Page Object 内部放置断言
// 错误:page object 拥有断言
class LoginPage {
async loginAndVerify(username: string, password: string) {
await this.usernameInput.fill(username)
await this.passwordInput.fill(password)
await this.submitButton.click()
// 此断言应属于测试,而不是这里
await expect(this.page).toHaveURL("/dashboard")
}
}
// 正确:page object 执行行为,测试进行断言
class LoginPage {
async login(username: string, password: string) {
await this.usernameInput.fill(username)
await this.passwordInput.fill(password)
await this.submitButton.click()
}
}
// 在测试中:
await loginPage.login("admin", "pass")
await expect(page).toHaveURL("/dashboard")
例外:POM 方法内部的 waitForURL() 或 waitFor() 调用,用于确保导航完成,这是可以接受的——这些属于同步,而非断言。
深层嵌套继承
// 错误:继承链
class BasePage {
/* ... */
}
class AuthenticatedPage extends BasePage {
/* ... */
}
class AdminPage extends AuthenticatedPage {
/* ... */
}
class SuperAdminPage extends AdminPage {
/* ... */
}
修复:使用组合。为共享 UI(导航栏、侧边栏)创建组件对象,并将其组合到 page object 中。优先使用"有一个"(has-a)而非"是一个"(is-a)。
// 正确:组合
class AdminPage {
readonly navbar: NavbarComponent
readonly sidebar: AdminSidebarComponent
constructor(private readonly page: Page) {
this.navbar = new NavbarComponent(page.getByRole("navigation"))
this.sidebar = new AdminSidebarComponent(page.getByRole("complementary"))
}
}
在 Page Object 中存储状态
// 错误:跟踪状态
class CartPage {
private itemCount = 0
async addItem(name: string) {
await this.page.getByRole("button", { name: `Add ${name}` }).click()
this.itemCount++ // 如果 UI 独立变化,状态会过期
}
getCount() {
return this.itemCount // 如果其他标签页或 API 调用修改了购物车,返回值就是错误的
}
}
修复:在需要时从 DOM 读取状态。页面是事实来源,而非你的 JavaScript 变量。
// 正确:从 DOM 读取
class CartPage {
async addItem(name: string) {
await this.page.getByRole("button", { name: `Add ${name}` }).click()
}
get itemCount(): Locator {
return this.page.getByTestId("cart-count")
}
}
// 在测试中:
await cartPage.addItem("Widget")
await expect(cartPage.itemCount).toHaveText("1")
将原始定位器暴露为主要 API
// 错误:测试直接操作定位器
const loginPage = new LoginPage(page)
await loginPage.usernameInput.fill("admin")
await loginPage.passwordInput.fill("secret")
await loginPage.submitButton.click()
// 正确:测试调用行为方法
const loginPage = new LoginPage(page)
await loginPage.login("admin", "secret")
将定位器暴露为 readonly 属性用于断言是可以接受的(expect(loginPage.errorMessage).toBeVisible())。反模式是要求测试通过原始定位器编排多步交互。
故障排查
| 问题 | 原因 | 修复 |
|---|---|---|
| 定位器超时但元素存在 | POM 在导航之前构造;定位器绑定到了错误的页面状态 | 将 goto() 移到 POM 构造之前,或使用 getter 模式(模式 6) |
| Page object 之间循环导入 | LoginPage 导入 DashboardPage,后者又导入 LoginPage |
打破循环:在导航方法中使用懒 import(),或让测试创建目标 POM |
| Fixture 在测试中不可用 | 测试从 @playwright/test 而非 ./fixtures 导入 test |
始终从自定义 fixtures 文件导入 test 和 expect |
| TypeScript 错误:fixture 上不存在属性 | Fixture 类型未在 test.extend<>() 泛型中声明 |
将 POM 类型添加到泛型参数中:test.extend<{ loginPage: LoginPage }>() |
| POM 方法在不同相似页面中感觉重复 | 多个页面共享相同的表单结构 | 为共享表单提取组件对象,组合到每个页面中 |
构造函数中的 page.goto() 导致"cannot use await"错误 |
构造函数不能是异步的 | 使用静态工厂方法(模式 8) |
相关文档
- core/fixtures-and-hooks.md ——
test.extend()深入工作原理、fixture 作用域、清理 - core/locators.md —— 为 POM 属性选择合适的定位器策略
- core/pom-vs-fixtures-vs-helpers.md —— 更完整的决策框架,说明何时使用每种方案
- core/test-organization.md —— 更广泛测试套件的文件和文件夹约定