名称:playwright-page-object-model 用户可调用:false 描述:在创建页面对象或重构Playwright测试时使用,以提高通过页面对象模型模式的可维护性。 允许工具:
- Bash
- Read
- Write
- Edit
Playwright 页面对象模型
掌握页面对象模型模式,以创建可维护、可重用和可扩展的测试自动化代码。这个技能涵盖了现代Playwright模式,包括组件化架构、定位器策略和应用程序动作。
核心POM原则
单一职责
每个页面对象应该代表一个页面或组件,具有单一、明确定义的职责。
封装
隐藏实现细节,只暴露有意义的操作和断言。
可重用性
创建可重用的组件,可以组合成更大的页面对象。
可维护性
当UI变化时,在一个地方更新页面对象,而不是跨多个测试。
基本页面对象模式
简单页面对象
// pages/login-page.ts
import { Page, Locator } from '@playwright/test';
export class LoginPage {
readonly page: Page;
readonly emailInput: Locator;
readonly passwordInput: Locator;
readonly loginButton: Locator;
readonly errorMessage: Locator;
constructor(page: Page) {
this.page = page;
this.emailInput = page.getByLabel('Email');
this.passwordInput = page.getByLabel('Password');
this.loginButton = page.getByRole('button', { name: 'Login' });
this.errorMessage = page.getByRole('alert');
}
async goto() {
await this.page.goto('/login');
}
async login(email: string, password: string) {
await this.emailInput.fill(email);
await this.passwordInput.fill(password);
await this.loginButton.click();
}
async getErrorMessage() {
return await this.errorMessage.textContent();
}
}
在测试中使用页面对象
// tests/login.spec.ts
import { test, expect } from '@playwright/test';
import { LoginPage } from '../pages/login-page';
test.describe('Login', () => {
test('应该成功登录', async ({ page }) => {
const loginPage = new LoginPage(page);
await loginPage.goto();
await loginPage.login('user@example.com', 'password123');
await expect(page).toHaveURL('/dashboard');
});
test('应该在无效凭据时显示错误', async ({ page }) => {
const loginPage = new LoginPage(page);
await loginPage.goto();
await loginPage.login('user@example.com', 'wrongpassword');
const error = await loginPage.getErrorMessage();
expect(error).toContain('Invalid credentials');
});
});
定位器策略
推荐定位器优先级
- 用户可见的定位器(getByRole、getByText、getByLabel)
- 测试ID(getByTestId)
- CSS/XPath(仅作为最后手段)
用户可见的定位器
export class HomePage {
readonly page: Page;
constructor(page: Page) {
this.page = page;
}
// 通过角色(ARIA角色)
get searchButton() {
return this.page.getByRole('button', { name: 'Search' });
}
// 通过标签(表单输入)
get searchInput() {
return this.page.getByLabel('Search products');
}
// 通过文本
get welcomeMessage() {
return this.page.getByText('Welcome back');
}
// 通过占位符
get emailInput() {
return this.page.getByPlaceholder('Enter your email');
}
// 通过替代文本(图像)
get logo() {
return this.page.getByAltText('Company Logo');
}
// 通过标题
get helpIcon() {
return this.page.getByTitle('Help');
}
}
测试ID定位器
// 带有测试ID的组件
// <button data-testid="submit-button">Submit</button>
export class FormPage {
readonly page: Page;
constructor(page: Page) {
this.page = page;
}
get submitButton() {
return this.page.getByTestId('submit-button');
}
get formContainer() {
return this.page.getByTestId('form-container');
}
}
定位器链式调用
export class ProductPage {
readonly page: Page;
constructor(page: Page) {
this.page = page;
}
// 链式定位器以提高特异性
get priceInCart() {
return this.page
.getByTestId('shopping-cart')
.getByRole('cell', { name: 'Price' });
}
// 过滤定位器
getProductByName(name: string) {
return this.page
.getByRole('listitem')
.filter({ hasText: name });
}
// 第n个元素
get firstProduct() {
return this.page.getByRole('article').nth(0);
}
}
组件化架构
可重用组件对象
// components/navigation.ts
export class Navigation {
readonly page: Page;
readonly homeLink: Locator;
readonly productsLink: Locator;
readonly cartLink: Locator;
readonly profileMenu: Locator;
constructor(page: Page) {
this.page = page;
this.homeLink = page.getByRole('link', { name: 'Home' });
this.productsLink = page.getByRole('link', { name: 'Products' });
this.cartLink = page.getByRole('link', { name: 'Cart' });
this.profileMenu = page.getByRole('button', { name: 'Profile' });
}
async navigateToHome() {
await this.homeLink.click();
}
async navigateToProducts() {
await this.productsLink.click();
}
async navigateToCart() {
await this.cartLink.click();
}
async openProfileMenu() {
await this.profileMenu.click();
}
}
使用组件组合页面对象
// pages/base-page.ts
import { Page } from '@playwright/test';
import { Navigation } from '../components/navigation';
import { Footer } from '../components/footer';
export class BasePage {
readonly page: Page;
readonly navigation: Navigation;
readonly footer: Footer;
constructor(page: Page) {
this.page = page;
this.navigation = new Navigation(page);
this.footer = new Footer(page);
}
}
// pages/product-page.ts
import { BasePage } from './base-page';
import { Page } from '@playwright/test';
export class ProductPage extends BasePage {
readonly addToCartButton: Locator;
readonly productTitle: Locator;
readonly productPrice: Locator;
constructor(page: Page) {
super(page);
this.addToCartButton = page.getByRole('button', { name: 'Add to Cart' });
this.productTitle = page.getByRole('heading', { level: 1 });
this.productPrice = page.getByTestId('product-price');
}
async goto(productId: string) {
await this.page.goto(`/products/${productId}`);
}
async addToCart() {
await this.addToCartButton.click();
// 等待购物车更新
await this.page.waitForResponse(
(response) => response.url().includes('/api/cart')
);
}
async getProductTitle() {
return await this.productTitle.textContent();
}
async getProductPrice() {
const text = await this.productPrice.textContent();
return parseFloat(text?.replace('$', '') || '0');
}
}
模态和对话框组件
// components/modal.ts
export class Modal {
readonly page: Page;
readonly container: Locator;
readonly closeButton: Locator;
readonly title: Locator;
constructor(page: Page) {
this.page = page;
this.container = page.getByRole('dialog');
this.closeButton = this.container.getByRole('button', { name: 'Close' });
this.title = this.container.getByRole('heading');
}
async isVisible() {
return await this.container.isVisible();
}
async getTitle() {
return await this.title.textContent();
}
async close() {
await this.closeButton.click();
await this.container.waitFor({ state: 'hidden' });
}
}
// components/confirmation-modal.ts
import { Modal } from './modal';
import { Page } from '@playwright/test';
export class ConfirmationModal extends Modal {
readonly confirmButton: Locator;
readonly cancelButton: Locator;
readonly message: Locator;
constructor(page: Page) {
super(page);
this.confirmButton = this.container.getByRole('button', {
name: 'Confirm',
});
this.cancelButton = this.container.getByRole('button', {
name: 'Cancel',
});
this.message = this.container.getByTestId('modal-message');
}
async confirm() {
await this.confirmButton.click();
await this.container.waitFor({ state: 'hidden' });
}
async cancel() {
await this.cancelButton.click();
await this.container.waitFor({ state: 'hidden' });
}
async getMessage() {
return await this.message.textContent();
}
}
应用程序动作模式
高级动作
// pages/app-actions.ts
import { Page } from '@playwright/test';
import { LoginPage } from './login-page';
import { ProductPage } from './product-page';
export class AppActions {
readonly page: Page;
constructor(page: Page) {
this.page = page;
}
async login(email: string, password: string) {
const loginPage = new LoginPage(this.page);
await loginPage.goto();
await loginPage.login(email, password);
await this.page.waitForURL('/dashboard');
}
async addProductToCart(productId: string) {
const productPage = new ProductPage(this.page);
await productPage.goto(productId);
await productPage.addToCart();
}
async completeCheckout(paymentDetails: PaymentDetails) {
await this.page.goto('/checkout');
await this.fillShippingInfo(paymentDetails.shipping);
await this.fillPaymentInfo(paymentDetails.payment);
await this.page.getByRole('button', { name: 'Place Order' }).click();
await this.page.waitForURL('/order-confirmation');
}
private async fillShippingInfo(shipping: ShippingInfo) {
await this.page.getByLabel('Full Name').fill(shipping.name);
await this.page.getByLabel('Address').fill(shipping.address);
await this.page.getByLabel('City').fill(shipping.city);
await this.page.getByLabel('Postal Code').fill(shipping.postalCode);
}
private async fillPaymentInfo(payment: PaymentInfo) {
await this.page.getByLabel('Card Number').fill(payment.cardNumber);
await this.page.getByLabel('Expiry Date').fill(payment.expiry);
await this.page.getByLabel('CVV').fill(payment.cvv);
}
}
interface PaymentDetails {
shipping: ShippingInfo;
payment: PaymentInfo;
}
interface ShippingInfo {
name: string;
address: string;
city: string;
postalCode: string;
}
interface PaymentInfo {
cardNumber: string;
expiry: string;
cvv: string;
}
在测试中使用应用程序动作
// tests/checkout.spec.ts
import { test, expect } from '@playwright/test';
import { AppActions } from '../pages/app-actions';
test('应该完成结账流程', async ({ page }) => {
const app = new AppActions(page);
await app.login('user@example.com', 'password123');
await app.addProductToCart('product-123');
await app.completeCheckout({
shipping: {
name: 'John Doe',
address: '123 Main St',
city: 'New York',
postalCode: '10001',
},
payment: {
cardNumber: '4111111111111111',
expiry: '12/25',
cvv: '123',
},
});
await expect(page.getByText('Order confirmed')).toBeVisible();
});
高级模式
通用表格组件
// components/table.ts
export class Table {
readonly page: Page;
readonly container: Locator;
constructor(page: Page, testId?: string) {
this.page = page;
this.container = testId
? page.getByTestId(testId)
: page.getByRole('table');
}
async getHeaders() {
const headers = await this.container
.getByRole('columnheader')
.allTextContents();
return headers;
}
async getRowCount() {
return await this.container.getByRole('row').count() - 1; // 排除表头
}
async getRow(index: number) {
return this.container.getByRole('row').nth(index + 1); // 跳过表头
}
async getCellValue(row: number, column: number) {
const rowLocator = await this.getRow(row);
const cell = rowLocator.getByRole('cell').nth(column);
return await cell.textContent();
}
async getCellByColumnName(row: number, columnName: string) {
const headers = await this.getHeaders();
const columnIndex = headers.indexOf(columnName);
if (columnIndex === -1) {
throw new Error(`列“${columnName}”未找到`);
}
return await this.getCellValue(row, columnIndex);
}
async findRowByValue(columnName: string, value: string) {
const headers = await this.getHeaders();
const columnIndex = headers.indexOf(columnName);
const rowCount = await this.getRowCount();
for (let i = 0; i < rowCount; i++) {
const cellValue = await this.getCellValue(i, columnIndex);
if (cellValue === value) {
return await this.getRow(i);
}
}
return null;
}
}
带有验证的表单组件
// components/form.ts
export class Form {
readonly page: Page;
readonly container: Locator;
readonly submitButton: Locator;
constructor(page: Page, formTestId: string) {
this.page = page;
this.container = page.getByTestId(formTestId);
this.submitButton = this.container.getByRole('button', {
name: /submit|save|create/i,
});
}
async fillField(label: string, value: string) {
await this.container.getByLabel(label).fill(value);
}
async selectOption(label: string, value: string) {
await this.container.getByLabel(label).selectOption(value);
}
async checkCheckbox(label: string) {
await this.container.getByLabel(label).check();
}
async uncheckCheckbox(label: string) {
await this.container.getByLabel(label).uncheck();
}
async submit() {
await this.submitButton.click();
}
async getFieldError(label: string) {
const field = this.container.getByLabel(label);
const fieldId = await field.getAttribute('id');
const error = this.container.locator(`[aria-describedby="${fieldId}"]`);
return await error.textContent();
}
async hasError(label: string) {
const error = await this.getFieldError(label);
return error !== null && error.trim() !== '';
}
async getFormErrors() {
const errors = await this.container
.locator('[role="alert"]')
.allTextContents();
return errors.filter((e) => e.trim() !== '');
}
}
页面对象中的等待策略
export class DashboardPage {
readonly page: Page;
readonly loadingSpinner: Locator;
readonly dataTable: Locator;
constructor(page: Page) {
this.page = page;
this.loadingSpinner = page.getByTestId('loading-spinner');
this.dataTable = page.getByRole('table');
}
async goto() {
await this.page.goto('/dashboard');
await this.waitForPageLoad();
}
async waitForPageLoad() {
// 等待加载旋转器消失
await this.loadingSpinner.waitFor({ state: 'hidden' });
// 等待数据加载
await this.dataTable.waitFor({ state: 'visible' });
// 等待网络空闲
await this.page.waitForLoadState('networkidle');
}
async refreshData() {
const refreshButton = this.page.getByRole('button', { name: 'Refresh' });
await refreshButton.click();
// 等待API响应
await this.page.waitForResponse(
(response) =>
response.url().includes('/api/dashboard') && response.status() === 200
);
await this.waitForPageLoad();
}
}
处理动态内容
列表和集合
export class ProductListPage {
readonly page: Page;
readonly productCards: Locator;
constructor(page: Page) {
this.page = page;
this.productCards = page.getByTestId('product-card');
}
async goto() {
await this.page.goto('/products');
}
async getProductCount() {
return await this.productCards.count();
}
async getProductCard(index: number) {
return this.productCards.nth(index);
}
async getProductCardByName(name: string) {
return this.productCards.filter({ hasText: name }).first();
}
async getAllProductNames() {
const names = await this.productCards
.locator('h3')
.allTextContents();
return names;
}
async clickProduct(name: string) {
const card = await this.getProductCardByName(name);
await card.click();
}
async addToCartByName(name: string) {
const card = await this.getProductCardByName(name);
await card.getByRole('button', { name: 'Add to Cart' }).click();
}
}
搜索和过滤
export class SearchPage {
readonly page: Page;
readonly searchInput: Locator;
readonly searchButton: Locator;
readonly results: Locator;
readonly filters: Locator;
constructor(page: Page) {
this.page = page;
this.searchInput = page.getByRole('searchbox');
this.searchButton = page.getByRole('button', { name: 'Search' });
this.results = page.getByTestId('search-results');
this.filters = page.getByTestId('filters');
}
async search(query: string) {
await this.searchInput.fill(query);
await this.searchButton.click();
await this.waitForResults();
}
async applyFilter(filterName: string, value: string) {
await this.filters
.getByRole('button', { name: filterName })
.click();
await this.page
.getByRole('checkbox', { name: value })
.check();
await this.waitForResults();
}
async getResultCount() {
const countText = await this.results
.getByTestId('result-count')
.textContent();
return parseInt(countText?.match(/\d+/)?.[0] || '0');
}
private async waitForResults() {
await this.page.waitForResponse(
(response) => response.url().includes('/api/search')
);
await this.results.getByTestId('result-item').first().waitFor();
}
}
类型安全的页面对象
使用TypeScript接口
// types/user.ts
export interface User {
email: string;
password: string;
firstName?: string;
lastName?: string;
}
export interface Product {
id: string;
name: string;
price: number;
description?: string;
}
// pages/registration-page.ts
import { User } from '../types/user';
export class RegistrationPage {
readonly page: Page;
constructor(page: Page) {
this.page = page;
}
async goto() {
await this.page.goto('/register');
}
async register(user: User) {
await this.page.getByLabel('Email').fill(user.email);
await this.page.getByLabel('Password').fill(user.password);
if (user.firstName) {
await this.page.getByLabel('First Name').fill(user.firstName);
}
if (user.lastName) {
await this.page.getByLabel('Last Name').fill(user.lastName);
}
await this.page.getByRole('button', { name: 'Register' }).click();
}
}
测试数据的构建器模式
// builders/user-builder.ts
import { User } from '../types/user';
export class UserBuilder {
private user: Partial<User> = {};
withEmail(email: string): this {
this.user.email = email;
return this;
}
withPassword(password: string): this {
this.user.password = password;
return this;
}
withName(firstName: string, lastName: string): this {
this.user.firstName = firstName;
this.user.lastName = lastName;
return this;
}
build(): User {
if (!this.user.email || !this.user.password) {
throw new Error('Email和密码是必需的');
}
return this.user as User;
}
}
// tests/registration.spec.ts
import { UserBuilder } from '../builders/user-builder';
import { RegistrationPage } from '../pages/registration-page';
test('应该注册新用户', async ({ page }) => {
const user = new UserBuilder()
.withEmail('newuser@example.com')
.withPassword('SecurePass123!')
.withName('John', 'Doe')
.build();
const registrationPage = new RegistrationPage(page);
await registrationPage.goto();
await registrationPage.register(user);
await expect(page).toHaveURL('/welcome');
});
何时使用此技能
- 创建新的页面对象用于测试自动化
- 重构现有测试以使用页面对象模型
- 为测试构建可重用组件库
- 为复杂用户流程实现应用程序动作
- 标准化测试套件中的定位器策略
- 使用TypeScript创建类型安全的页面对象
- 设计可维护的测试架构
- 处理动态内容和复杂UI交互
- 构建表单和表格抽象
- 为团队建立页面对象模式
资源
- Playwright定位器:https://playwright.dev/docs/locators
- Playwright页面对象模型:https://playwright.dev/docs/pom
- Playwright最佳实践:https://playwright.dev/docs/best-practices