skillhub-147-playwright-skill
941 行
29 KiB
Markdown
941 行
29 KiB
Markdown
# 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) —— 更广泛测试套件的文件和文件夹约定
|