Playwright页面对象模型Skill playwright-page-object-model

这个技能教授如何使用Playwright框架的页面对象模型模式,用于创建可维护、可重用和可扩展的测试自动化代码。它涵盖了组件化架构、定位器策略和应用程序动作等现代测试技术,适用于前端Web测试自动化,帮助提高代码的维护性、可扩展性和可重用性。关键词:Playwright、页面对象模型、测试自动化、前端测试、组件化、可维护性、可重用性、定位器策略。

测试 0 次安装 0 次浏览 更新于 3/25/2026

名称: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');
  });
});

定位器策略

推荐定位器优先级

  1. 用户可见的定位器(getByRole、getByText、getByLabel)
  2. 测试ID(getByTestId)
  3. 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交互
  • 构建表单和表格抽象
  • 为团队建立页面对象模式

资源