首页
/ Playwright 页面对象模型(Page Object Model)实践:封装 Page 与 Locator 构建可维护的测试套件

Playwright 页面对象模型(Page Object Model)实践:封装 Page 与 Locator 构建可维护的测试套件

2026-09-06 13:53:27作者:裘旻烁

大型 Web 测试套件如何兼顾“易编写”和“易维护”?Playwright 官方推荐的组织方式之一是页面对象模型(Page Object Model, POM):用一类对象封装一个页面的元素定位与常见操作,把分散在各处用例里的选择器收敛到一处。本文基于 Playwright 官方文档 Page object models 的核心内容展开,并结合仓库源码说明 POM 所依赖的 Page.locator() / Locator 机制在底层是如何工作的,读完后你能够为自己的项目落地一套可复用、易维护的 POM 结构。

一、为什么需要页面对象模型

大型测试套件可以通过结构化来提升编写维护效率,POM 是其中一种典型的组织方式:

  • 页面对象表示 Web 应用的一部分。以电商应用为例,它可能有首页、商品列表页、结算页,每一页都可以由一个对应的页面对象来表示;
  • 简化编写(simplify authoring):页面对象在 Playwright 原语之上创建出一层更贴近你自己应用的高层 API,用例代码读起来更像业务语言;
  • 简化维护(simplify maintenance):元素选择器集中在一个地方维护,页面 UI 变化时只需修改页面对象,而不用逐个文件全局替换;同时可以沉淀可复用代码,避免重复。

一个页面对象通常做两件事:

  1. 在构造函数里持有 Page,并预先构造好若干 Locator(懒定位,此时并不立即在 DOM 中查找元素);
  2. 提供以业务语义命名的高层方法(如 goto()getStarted()search(text)),内部组合 Locator 操作与 web-first 断言。

二、JavaScript / TypeScript 实现:PlaywrightDevPage

官方文档用一个 PlaywrightDevPage 辅助类为例,封装对 playwright.dev 站点主页的常见操作,内部使用 page 对象。完整实现如下(测试库版本,配合 @playwright/test):

// playwright-dev-page.ts
import { expect, type Locator, type Page } from '@playwright/test';

export class PlaywrightDevPage {
  readonly page: Page;
  readonly getStartedLink: Locator;
  readonly gettingStartedHeader: Locator;
  readonly pomLink: Locator;
  readonly tocList: Locator;

  constructor(page: Page) {
    this.page = page;
    this.getStartedLink = page.locator('a', { hasText: 'Get started' });
    this.gettingStartedHeader = page.locator('h1', { hasText: 'Installation' });
    this.pomLink = page.locator('li', {
      hasText: 'Guides',
    }).locator('a', {
      hasText: 'Page Object Model',
    });
    this.tocList = page.locator('article div.markdown ul > li > a');
  }

  async goto() {
    await this.page.goto('https://playwright.dev');
  }

  async getStarted() {
    await this.getStartedLink.first().click();
    await expect(this.gettingStartedHeader).toBeVisible();
  }

  async pageObjectModel() {
    await this.getStarted();
    await this.pomLink.click();
  }
}

几个值得注意的 POM 设计细节:

  • Locator 在构造函数中创建是安全的Locator 是惰性描述(selector + 过滤条件),只有真正执行 click()fill() 或断言时才会在 DOM 中解析元素,因此可以放心地在页面对象构造阶段批量声明;
  • 链式 .locator() 实现范围收窄pomLink 先定位文本为 Guidesli,再在其内部定位文本为 Page Object Modela,等价于「在某个父级范围内找子元素」,比写一条复杂的 CSS 路径更直观;
  • 高层方法内部自带断言getStarted() 点击后直接 await expect(this.gettingStartedHeader).toBeVisible(),把“点击成功 = 到达目标页”这一业务语义固化在页面对象里,让用例更短、失败信息更贴近业务;
  • { hasText } 选项过滤page.locator('a', { hasText: 'Get started' }) 表示「包含指定文本的 a 元素」,这是 Playwright 选择器体系中的 text 过滤能力。

如果使用 Playwright Libraryplaywright 包而非 @playwright/test),同一个页面对象只需去掉 @playwright/test 的导入,改为普通 class 导出:

// models/PlaywrightDevPage.js
class PlaywrightDevPage {
  /**
   * @param {import('playwright').Page} page
   */
  constructor(page) {
    this.page = page;
    this.getStartedLink = page.locator('a', { hasText: 'Get started' });
    this.gettingStartedHeader = page.locator('h1', { hasText: 'Installation' });
    this.pomLink = page.locator('li', {
      hasText: 'Playwright Test',
    }).locator('a', {
      hasText: 'Page Object Model',
    });
    this.tocList = page.locator('article div.markdown ul > li > a');
  }

  async getStarted() {
    await this.getStartedLink.first().click();
    await expect(this.gettingStartedHeader).toBeVisible();
  }

  async pageObjectModel() {
    await this.getStarted();
    await this.pomLink.click();
  }
}
module.exports = { PlaywrightDevPage };

注意 Library 版本没有 goto()(导航由用例自行组织),断言能力也更受限——这是测试库与 Library 的分工差异。

在用例中使用页面对象

页面对象就绪后,测试用例通过注入的 page fixture 实例化它:

// example.spec.ts
import { test, expect } from '@playwright/test';
import { PlaywrightDevPage } from './playwright-dev-page';

test('getting started should contain table of contents', async ({ page }) => {
  const playwrightDev = new PlaywrightDevPage(page);
  await playwrightDev.goto();
  await playwrightDev.getStarted();
  await expect(playwrightDev.tocList).toHaveText([
    `How to install Playwright`,
    `What's installed`,
    `How to run the example test`,
    `How to open the HTML test report`,
    `Write tests using web-first assertions, fixtures and locators`,
    `Run single or multiple tests; headed mode`,
    `Generate tests with Codegen`,
    `View a trace of your tests`,
  ]);
});

test('should show Page Object Model article', async ({ page }) => {
  const playwrightDev = new PlaywrightDevPage(page);
  await playwrightDev.goto();
  await playwrightDev.pageObjectModel();
  await expect(page.locator('article')).toContainText('Page Object Model is a common pattern');
});

Library 版本中则手动 newPage() 后使用:

const { PlaywrightDevPage } = require('./playwright-dev-page');

// In the test
const page = await browser.newPage();
const playwrightDev = new PlaywrightDevPage(page);
await playwrightDev.goto();
await playwrightDev.getStarted();
await expect(playwrightDev.tocList).toHaveText([
  `How to install Playwright`,
  `What's installed`,
  `How to run the example test`,
  `How to open the HTML test report`,
  `Write tests using web-first assertions, fixtures and locators`,
  `Run single or multiple tests; headed mode`,
  `Generate tests with Codegen`,
  `View a trace of your tests`,
]);

三、Java / Python / C# 实现:以 SearchPage 为例

除 JavaScript 外,POM 的形态在多语言绑定中是一致的——页面对象包装一个 Playwright Page。下面以「在 Bing 中执行搜索」的 SearchPage 为例。

Java

// models/SearchPage.java
package models;

import com.microsoft.playwright.*;

public class SearchPage {
  private final Page page;
  private final Locator searchTermInput;

  public SearchPage(Page page) {
    this.page = page;
    this.searchTermInput = page.locator("[aria-label='Enter your search term']");
  }

  public void navigate() {
    page.navigate("https://bing.com");
  }

  public void search(String text) {
    searchTermInput.fill(text);
    searchTermInput.press("Enter");
  }
}
import models.SearchPage;
import com.microsoft.playwright.*;
// ...

// In the test
Page page = browser.newPage();
SearchPage searchPage = new SearchPage(page);
searchPage.navigate();
searchPage.search("search query");

Python(async 与 sync 两种风格)

# models/search.py(async)
class SearchPage:
    def __init__(self, page):
        self.page = page
        self.search_term_input = page.locator('[aria-label="Enter your search term"]')

    async def navigate(self):
        await self.page.goto("https://bing.com")

    async def search(self, text):
        await self.search_term_input.fill(text)
        await self.search_term_input.press("Enter")
# models/search.py(sync)
class SearchPage:
    def __init__(self, page):
        self.page = page
        self.search_term_input = page.locator('[aria-label="Enter your search term"]')

    def navigate(self):
        self.page.goto("https://bing.com")

    def search(self, text):
        self.search_term_input.fill(text)
        self.search_term_input.press("Enter")
# test_search.py
from models.search import SearchPage

# in the test(sync 风格;async 风格对应 await)
page = browser.new_page()
search_page = SearchPage(page)
search_page.navigate()
search_page.search("search query")

C#

// models/SearchPage.cs
using System.Threading.Tasks;
using Microsoft.Playwright;

namespace BigEcommerceApp.Tests.Models;

public class SearchPage
{
  private readonly IPage _page;
  private readonly ILocator _searchTermInput;

  public SearchPage(IPage page)
  {
    _page = page;
    _searchTermInput = page.Locator("[aria-label='Enter your search term']");
  }

  public async Task GotoAsync()
  {
    await _page.GotoAsync("https://bing.com");
  }

  public async Task SearchAsync(string text)
  {
    await _searchTermInput.FillAsync(text);
    await _searchTermInput.PressAsync("Enter");
  }
}
using BigEcommerceApp.Tests.Models;

// in the test
var page = new SearchPage(await browser.NewPageAsync());
await page.GotoAsync();
await page.SearchAsync("search query");

四、源码透视:POM 依赖的 Page.locator() 与 Locator 到底做了什么

POM 的全部“魔法”来自 Page.locator() 返回的 Locator 对象。从仓库源码可以确认两个关键实现事实:

Page.locator() 委托给主 Frame

client/page.ts 中:

locator(selector: string, options?: LocatorOptions): Locator {
  return this.mainFrame().locator(selector, options);
}

也就是说 page.locator(...) 等价于在主文档(main frame)上创建定位器,顶层页面上定位的所有元素都归属于主 frame;若页面存在 iframe,需要改用 frameLocator() 等机制跨 frame 定位。

hasText 会被编译为内部选择器

client/locator.ts 中,Locator 的构造函数把选项“拼装”成一条内部选择器链:

export type LocatorOptions = {
  hasText?: string | RegExp;
  hasNotText?: string | RegExp;
  has?: Locator;
  hasNot?: Locator;
  visible?: boolean;
};

constructor(frame: Frame, selector: string, options?: LocatorOptions) {
  this._frame = frame;
  this._selector = selector;

  if (options?.hasText)
    this._selector += ` >> internal:has-text=${escapeForTextSelector(options.hasText, false)}`;
  // hasNotText / has / hasNot / visible 同样以 >> 分隔符追加 ...
}

由此可以确认:

  • POM 中 page.locator('a', { hasText: 'Get started' }) 实际生成的是 'a' >> internal:has-text=Get started 这样的链式选择器;{ has } / { hasNot } 会把内层 Locator 序列化后追加为 internal:has= / internal:has-not= 段;
  • 链式调用 this.pomLink = page.locator('li', { hasText: 'Guides' }).locator('a', { hasText: 'Page Object Model' }) 中,第二层 locator() 定义在 client/locator.tslocator(selectorOrLocator: string | Locator, ...)),它在前一个 Locator 之上继续追加选择器段,这正是 POM 里“父范围内找子元素”的底层实现;
  • 执行动作时,定位器通过 _frame._channel.waitForSelector({ selector: this._selector, strict: true, state: 'attached' }) 等待元素出现并保证严格模式解析(见 client/locator.ts),再复用剩余超时执行任务——这正是 POM 用例“无 sleep 也能稳定”的底层原因:每次操作都自带自动等待。

另外,POM 示例中用到的 first()(对 getStartedLink 取第一个匹配项以避免 strict mode 报错)、last()nth(index) 都定义在 client/locator.ts,属于 Locator 的标准 API。

五、落地建议与适用边界

结合文档内容与源码机制,POM 落地时可以遵循:

  1. 选择器集中、语义方法分层:元素定位只写在页面对象构造函数中;方法命名用业务语言(getStarted() 而非 clickDiv3()),让用例接近“用户故事”;
  2. 在高层方法里固化到达断言:如 getStarted()expect(header).toBeVisible(),把“流程走到了哪一步”显性化,失败定位更快;
  3. 利用 web-first 断言而非固定 sleep:Playwright 的断言与动作自带自动等待,POM 方法内部直接 await 即可;
  4. 注意边界:页面对象封装的是“页面”这一层;跨页流程、数据工厂、环境配置建议放在 fixture 或工具类中,而不是塞进单个页面对象,避免 POM 退化为“上帝对象”;
  5. 多语言团队:各语言 POM 模式完全同构(构造函数持有 Page + 声明 Locator + 高层方法),可以按仓库文档给出的 Java/Python/C# 模板一一对应迁移。

六、延伸阅读

登录后查看全文
热门项目推荐
相关项目推荐