Files
2026-07-13 21:36:47 +08:00

29 KiB
Raw Permalink Blame History

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、不传测试数据。
命名 LoginPageDashboardPageNavbarComponent。文件名:login.page.tsnavbar.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 可用——与内置的 pagerequest 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)。组件嵌入在页面中(NavbarComponentModalComponent)。
  • 不使用 barrel 文件index.ts 重新导出所有内容)。直接从文件导入。Barrel 文件会产生循环依赖陷阱并拖慢 IDE 工具。
  • fixtures.ts 位于 tests/ 根目录。 每个 spec 从 ./fixtures 导入 testexpect,而不是从 @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 文件导入 testexpect
TypeScript 错误:fixture 上不存在属性 Fixture 类型未在 test.extend<>() 泛型中声明 将 POM 类型添加到泛型参数中:test.extend<{ loginPage: LoginPage }>()
POM 方法在不同相似页面中感觉重复 多个页面共享相同的表单结构 为共享表单提取组件对象,组合到每个页面中
构造函数中的 page.goto() 导致"cannot use await"错误 构造函数不能是异步的 使用静态工厂方法(模式 8

相关文档