项目文件夹

文件
2026-07-13 21:36:47 +08:00

941 行
29 KiB
Markdown

此文件含有模棱两可的 Unicode 字符
此文件含有可能会与其他字符混淆的 Unicode 字符。 如果您是想特意这样的,可以安全地忽略该警告。 使用 Escape 按钮显示他们。
# 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**
```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**
```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()
}
}
```
**测试使用**
```typescript
// 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**
```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**
```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**
```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**
```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()
}
}
```
**展示导航链的测试用法**
```typescript
// 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**
```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**
```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"
```
**测试用法**
```typescript
// 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**
```typescript
// 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**
```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**
```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("/")
},
}
}
```
**测试用法**
```typescript
// 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**
```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**
```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**
```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**
```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 使用**
```typescript
// 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 个以上涉及不相关功能的方法。
```typescript
// 错误:一个类管理所有内容
class AppPage {
async login() {
/* ... */
}
async addToCart() {
/* ... */
}
async checkout() {
/* ... */
}
async changePassword() {
/* ... */
}
async viewAnalytics() {
/* ... */
}
// 还有 40 多个方法...
}
```
修复:按页面或功能拆分。每个逻辑页面一个类。将共享 UI 组合为组件对象。
### 在 Page Object 内部放置断言
```typescript
// 错误: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()` 调用,用于确保导航完成,这是可以接受的——这些属于同步,而非断言。
### 深层嵌套继承
```typescript
// 错误:继承链
class BasePage {
/* ... */
}
class AuthenticatedPage extends BasePage {
/* ... */
}
class AdminPage extends AuthenticatedPage {
/* ... */
}
class SuperAdminPage extends AdminPage {
/* ... */
}
```
修复:使用组合。为共享 UI(导航栏、侧边栏)创建组件对象,并将其组合到 page object 中。优先使用"有一个"has-a)而非"是一个"is-a)。
```typescript
// 正确:组合
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 中存储状态
```typescript
// 错误:跟踪状态
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 变量。
```typescript
// 正确:从 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
```typescript
// 错误:测试直接操作定位器
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](../core/fixtures-and-hooks.md) —— `test.extend()` 深入工作原理、fixture 作用域、清理
- [core/locators.md](../core/locators.md) —— 为 POM 属性选择合适的定位器策略
- [core/pom-vs-fixtures-vs-helpers.md](../core/pom-vs-fixtures-vs-helpers.md) —— 更完整的决策框架,说明何时使用每种方案
- [core/test-organization.md](../core/test-organization.md) —— 更广泛测试套件的文件和文件夹约定