Playwright Page Object 模式 #

Page Object 模式概述 #

Page Object 模式(POM)是一种设计模式,将页面元素和操作封装成对象,使测试代码更加清晰、可维护。

优势 #

text
┌─────────────────────────────────────────────────────────────┐
│                  Page Object 模式优势                         │
├─────────────────────────────────────────────────────────────┤
│                                                             │
│  ✅ 代码复用 - 页面操作封装,多处使用                          │
│  ✅ 易于维护 - 元素变化只需修改一处                            │
│  ✅ 可读性强 - 测试代码描述业务逻辑                            │
│  ✅ 解耦合 - 测试与页面实现分离                                │
│  ✅ 团队协作 - 分工明确,互不干扰                              │
│                                                             │
└─────────────────────────────────────────────────────────────┘

项目结构 #

text
project/
├── pages/
│   ├── BasePage.ts
│   ├── LoginPage.ts
│   ├── DashboardPage.ts
│   └── ProductPage.ts
├── tests/
│   ├── login.spec.ts
│   ├── dashboard.spec.ts
│   └── product.spec.ts
└── playwright.config.ts

基础 Page Object #

创建 Page Object 类 #

typescript
// pages/LoginPage.ts
import { Page, Locator, expect } 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: 'Sign In' });
    this.errorMessage = page.locator('.error-message');
  }

  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 expectErrorMessage(message: string) {
    await expect(this.errorMessage).toHaveText(message);
  }
}

使用 Page Object #

typescript
// tests/login.spec.ts
import { test, expect } from '@playwright/test';
import { LoginPage } from '../pages/LoginPage';

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('wrong@example.com', 'wrongpassword');
  
  await loginPage.expectErrorMessage('Invalid credentials');
});

基类设计 #

创建 BasePage #

typescript
// pages/BasePage.ts
import { Page, Locator, expect } from '@playwright/test';

export abstract class BasePage {
  constructor(protected page: Page) {}

  // 导航方法
  async goto(path: string) {
    await this.page.goto(path);
  }

  // 等待页面加载
  async waitForPageLoad() {
    await this.page.waitForLoadState('networkidle');
  }

  // 截图
  async screenshot(name: string) {
    await this.page.screenshot({ path: `screenshots/${name}.png` });
  }

  // 等待元素
  async waitForElement(locator: Locator) {
    await locator.waitFor({ state: 'visible' });
  }

  // 点击并等待导航
  async clickAndWaitForNavigation(locator: Locator) {
    await Promise.all([
      this.page.waitForNavigation(),
      locator.click(),
    ]);
  }

  // 获取元素文本
  async getElementText(locator: Locator): Promise<string | null> {
    return await locator.textContent();
  }

  // 检查元素是否存在
  async isElementVisible(locator: Locator): Promise<boolean> {
    return await locator.isVisible();
  }
}

继承 BasePage #

typescript
// pages/DashboardPage.ts
import { Locator, expect } from '@playwright/test';
import { BasePage } from './BasePage';

export class DashboardPage extends BasePage {
  readonly welcomeMessage: Locator;
  readonly userMenu: Locator;
  readonly logoutButton: Locator;
  readonly notifications: Locator;

  constructor(page: Page) {
    super(page);
    this.welcomeMessage = page.locator('.welcome-message');
    this.userMenu = page.getByRole('button', { name: 'User Menu' });
    this.logoutButton = page.getByRole('button', { name: 'Logout' });
    this.notifications = page.locator('.notification-item');
  }

  async goto() {
    await super.goto('/dashboard');
    await this.waitForPageLoad();
  }

  async logout() {
    await this.userMenu.click();
    await this.logoutButton.click();
  }

  async expectWelcomeMessage(userName: string) {
    await expect(this.welcomeMessage).toContainText(`Welcome, ${userName}`);
  }

  async getNotificationCount(): Promise<number> {
    return await this.notifications.count();
  }
}

复杂 Page Object #

带组件的 Page Object #

typescript
// pages/components/Navigation.ts
import { Page, Locator } from '@playwright/test';

export class Navigation {
  readonly page: Page;
  readonly homeLink: Locator;
  readonly productsLink: Locator;
  readonly cartLink: Locator;
  readonly profileLink: 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.profileLink = page.getByRole('link', { name: 'Profile' });
  }

  async goToHome() {
    await this.homeLink.click();
  }

  async goToProducts() {
    await this.productsLink.click();
  }

  async goToCart() {
    await this.cartLink.click();
  }

  async goToProfile() {
    await this.profileLink.click();
  }
}

// pages/ProductPage.ts
import { Page, Locator, expect } from '@playwright/test';
import { BasePage } from './BasePage';
import { Navigation } from './components/Navigation';

export class ProductPage extends BasePage {
  readonly navigation: Navigation;
  readonly productCards: Locator;
  readonly searchInput: Locator;
  readonly searchButton: Locator;
  readonly filterDropdown: Locator;

  constructor(page: Page) {
    super(page);
    this.navigation = new Navigation(page);
    this.productCards = page.locator('.product-card');
    this.searchInput = page.getByPlaceholder('Search products');
    this.searchButton = page.getByRole('button', { name: 'Search' });
    this.filterDropdown = page.getByRole('combobox', { name: 'Filter' });
  }

  async goto() {
    await super.goto('/products');
  }

  async searchProduct(query: string) {
    await this.searchInput.fill(query);
    await this.searchButton.click();
    await this.page.waitForLoadState('networkidle');
  }

  async filterByCategory(category: string) {
    await this.filterDropdown.selectOption(category);
    await this.page.waitForLoadState('networkidle');
  }

  async getProductCount(): Promise<number> {
    return await this.productCards.count();
  }

  async selectProduct(index: number) {
    await this.productCards.nth(index).click();
  }

  async expectProductCount(count: number) {
    await expect(this.productCards).toHaveCount(count);
  }
}

使用组件 #

typescript
// tests/product.spec.ts
import { test, expect } from '@playwright/test';
import { ProductPage } from '../pages/ProductPage';

test('产品搜索测试', async ({ page }) => {
  const productPage = new ProductPage(page);
  
  await productPage.goto();
  await productPage.searchProduct('iPhone');
  
  await productPage.expectProductCount(5);
});

test('导航测试', async ({ page }) => {
  const productPage = new ProductPage(page);
  
  await productPage.goto();
  await productPage.navigation.goToCart();
  
  await expect(page).toHaveURL('/cart');
});

Page Object 与 Fixtures 结合 #

创建 Page Object Fixtures #

typescript
// fixtures.ts
import { test as base } from '@playwright/test';
import { LoginPage } from './pages/LoginPage';
import { DashboardPage } from './pages/DashboardPage';
import { ProductPage } from './pages/ProductPage';

type MyFixtures = {
  loginPage: LoginPage;
  dashboardPage: DashboardPage;
  productPage: ProductPage;
};

export const test = base.extend<MyFixtures>({
  loginPage: async ({ page }, use) => {
    const loginPage = new LoginPage(page);
    await use(loginPage);
  },
  
  dashboardPage: async ({ page }, use) => {
    const dashboardPage = new DashboardPage(page);
    await use(dashboardPage);
  },
  
  productPage: async ({ page }, use) => {
    const productPage = new ProductPage(page);
    await use(productPage);
  },
});

export { expect } from '@playwright/test';

使用 Fixtures #

typescript
// tests/dashboard.spec.ts
import { test, expect } from '../fixtures';

test('仪表板测试', async ({ loginPage, dashboardPage }) => {
  await loginPage.goto();
  await loginPage.login('user@example.com', 'password');
  
  await expect(dashboardPage.welcomeMessage).toBeVisible();
});

test('产品页面测试', async ({ productPage }) => {
  await productPage.goto();
  await productPage.searchProduct('Laptop');
  
  await expect(productPage.productCards.first()).toBeVisible();
});

认证状态复用 #

保存认证状态 #

typescript
// auth.setup.ts
import { test as setup, expect } from '@playwright/test';
import { LoginPage } from './pages/LoginPage';

const authFile = 'playwright/.auth/user.json';

setup('认证', async ({ page }) => {
  const loginPage = new LoginPage(page);
  
  await loginPage.goto();
  await loginPage.login('user@example.com', 'password');
  
  await expect(page).toHaveURL('/dashboard');
  
  // 保存认证状态
  await page.context().storageState({ path: authFile });
});

使用认证状态 #

typescript
// playwright.config.ts
import { defineConfig, devices } from '@playwright/test';

export default defineConfig({
  projects: [
    // 认证设置项目
    { name: 'setup', testMatch: /.*\.setup\.ts/ },
    
    // 使用认证状态的项目
    {
      name: 'chromium',
      use: { 
        ...devices['Desktop Chrome'],
        storageState: 'playwright/.auth/user.json',
      },
      dependencies: ['setup'],
    },
  ],
});

需要/不需要认证的 Page Object #

typescript
// pages/SecurePage.ts
import { Page, Locator, expect } from '@playwright/test';
import { BasePage } from './BasePage';

export class SecurePage extends BasePage {
  readonly profileSection: Locator;
  readonly settingsLink: Locator;

  constructor(page: Page) {
    super(page);
    this.profileSection = page.locator('.profile-section');
    this.settingsLink = page.getByRole('link', { name: 'Settings' });
  }

  async goto() {
    await super.goto('/secure/dashboard');
    // 假设已经通过 storageState 认证
  }

  async expectAuthenticated() {
    await expect(this.profileSection).toBeVisible();
  }
}

数据驱动的 Page Object #

表单 Page Object #

typescript
// pages/RegistrationPage.ts
import { Page, Locator, expect } from '@playwright/test';
import { BasePage } from './BasePage';

interface RegistrationData {
  firstName: string;
  lastName: string;
  email: string;
  password: string;
  confirmPassword: string;
}

export class RegistrationPage extends BasePage {
  readonly firstNameInput: Locator;
  readonly lastNameInput: Locator;
  readonly emailInput: Locator;
  readonly passwordInput: Locator;
  readonly confirmPasswordInput: Locator;
  readonly submitButton: Locator;
  readonly successMessage: Locator;

  constructor(page: Page) {
    super(page);
    this.firstNameInput = page.getByLabel('First Name');
    this.lastNameInput = page.getByLabel('Last Name');
    this.emailInput = page.getByLabel('Email');
    this.passwordInput = page.getByLabel('Password');
    this.confirmPasswordInput = page.getByLabel('Confirm Password');
    this.submitButton = page.getByRole('button', { name: 'Register' });
    this.successMessage = page.locator('.success-message');
  }

  async goto() {
    await super.goto('/register');
  }

  async fillForm(data: RegistrationData) {
    await this.firstNameInput.fill(data.firstName);
    await this.lastNameInput.fill(data.lastName);
    await this.emailInput.fill(data.email);
    await this.passwordInput.fill(data.password);
    await this.confirmPasswordInput.fill(data.confirmPassword);
  }

  async submit() {
    await this.submitButton.click();
  }

  async register(data: RegistrationData) {
    await this.fillForm(data);
    await this.submit();
  }

  async expectSuccess() {
    await expect(this.successMessage).toBeVisible();
  }
}

使用数据驱动 #

typescript
// tests/registration.spec.ts
import { test, expect } from '@playwright/test';
import { RegistrationPage } from '../pages/RegistrationPage';

const testCases = [
  {
    name: '有效注册数据',
    data: {
      firstName: 'John',
      lastName: 'Doe',
      email: 'john@example.com',
      password: 'Password123!',
      confirmPassword: 'Password123!',
    },
    expectedSuccess: true,
  },
  {
    name: '密码不匹配',
    data: {
      firstName: 'Jane',
      lastName: 'Doe',
      email: 'jane@example.com',
      password: 'Password123!',
      confirmPassword: 'DifferentPassword',
    },
    expectedSuccess: false,
  },
];

for (const { name, data, expectedSuccess } of testCases) {
  test(name, async ({ page }) => {
    const registrationPage = new RegistrationPage(page);
    
    await registrationPage.goto();
    await registrationPage.register(data);
    
    if (expectedSuccess) {
      await registrationPage.expectSuccess();
    }
  });
}

最佳实践 #

1. 单一职责原则 #

typescript
// ✅ 推荐 - 每个方法只做一件事
async fillEmail(email: string) {
  await this.emailInput.fill(email);
}

async fillPassword(password: string) {
  await this.passwordInput.fill(password);
}

async submit() {
  await this.submitButton.click();
}

// 组合方法
async login(email: string, password: string) {
  await this.fillEmail(email);
  await this.fillPassword(password);
  await this.submit();
}

2. 使用语义化 Locator #

typescript
// ✅ 推荐
constructor(page: Page) {
  this.emailInput = page.getByLabel('Email');
  this.submitButton = page.getByRole('button', { name: 'Submit' });
}

// ❌ 不推荐
constructor(page: Page) {
  this.emailInput = page.locator('#email-input');
  this.submitButton = page.locator('.btn-submit');
}

3. 封装断言 #

typescript
// ✅ 推荐 - 在 Page Object 中封装断言
async expectLoginSuccess() {
  await expect(this.page).toHaveURL('/dashboard');
  await expect(this.welcomeMessage).toBeVisible();
}

// 测试中使用
await loginPage.login('user@example.com', 'password');
await dashboardPage.expectLoginSuccess();

4. 处理动态内容 #

typescript
// ✅ 推荐 - 等待动态内容加载
async waitForProductsLoaded() {
  await this.page.waitForResponse('**/api/products');
  await expect(this.productCards.first()).toBeVisible();
}

5. 使用 TypeScript 类型 #

typescript
// ✅ 推荐 - 定义接口
interface Product {
  name: string;
  price: number;
  quantity: number;
}

async addToCart(product: Product) {
  await this.searchProduct(product.name);
  await this.selectProduct(0);
  await this.setQuantity(product.quantity);
  await this.clickAddToCart();
}

下一步 #

现在你已经掌握了 Page Object 模式,接下来学习 测试夹具 了解更多代码组织技巧!

最后更新:2026-03-28