Playwright 页面对象模型(Page Object Model)实践:封装 Page 与 Locator 构建可维护的测试套件
大型 Web 测试套件如何兼顾“易编写”和“易维护”?Playwright 官方推荐的组织方式之一是页面对象模型(Page Object Model, POM):用一类对象封装一个页面的元素定位与常见操作,把分散在各处用例里的选择器收敛到一处。本文基于 Playwright 官方文档 Page object models 的核心内容展开,并结合仓库源码说明 POM 所依赖的 Page.locator() / Locator 机制在底层是如何工作的,读完后你能够为自己的项目落地一套可复用、易维护的 POM 结构。
一、为什么需要页面对象模型
大型测试套件可以通过结构化来提升编写与维护效率,POM 是其中一种典型的组织方式:
- 页面对象表示 Web 应用的一部分。以电商应用为例,它可能有首页、商品列表页、结算页,每一页都可以由一个对应的页面对象来表示;
- 简化编写(simplify authoring):页面对象在 Playwright 原语之上创建出一层更贴近你自己应用的高层 API,用例代码读起来更像业务语言;
- 简化维护(simplify maintenance):元素选择器集中在一个地方维护,页面 UI 变化时只需修改页面对象,而不用逐个文件全局替换;同时可以沉淀可复用代码,避免重复。
一个页面对象通常做两件事:
- 在构造函数里持有
Page,并预先构造好若干Locator(懒定位,此时并不立即在 DOM 中查找元素); - 提供以业务语义命名的高层方法(如
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先定位文本为Guides的li,再在其内部定位文本为Page Object Model的a,等价于「在某个父级范围内找子元素」,比写一条复杂的 CSS 路径更直观; - 高层方法内部自带断言:
getStarted()点击后直接await expect(this.gettingStartedHeader).toBeVisible(),把“点击成功 = 到达目标页”这一业务语义固化在页面对象里,让用例更短、失败信息更贴近业务; { hasText }选项过滤:page.locator('a', { hasText: 'Get started' })表示「包含指定文本的a元素」,这是 Playwright 选择器体系中的 text 过滤能力。
如果使用 Playwright Library(playwright 包而非 @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.ts(locator(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 落地时可以遵循:
- 选择器集中、语义方法分层:元素定位只写在页面对象构造函数中;方法命名用业务语言(
getStarted()而非clickDiv3()),让用例接近“用户故事”; - 在高层方法里固化到达断言:如
getStarted()中expect(header).toBeVisible(),把“流程走到了哪一步”显性化,失败定位更快; - 利用 web-first 断言而非固定 sleep:Playwright 的断言与动作自带自动等待,POM 方法内部直接
await即可; - 注意边界:页面对象封装的是“页面”这一层;跨页流程、数据工厂、环境配置建议放在 fixture 或工具类中,而不是塞进单个页面对象,避免 POM 退化为“上帝对象”;
- 多语言团队:各语言 POM 模式完全同构(构造函数持有 Page + 声明 Locator + 高层方法),可以按仓库文档给出的 Java/Python/C# 模板一一对应迁移。
六、延伸阅读
- 本页文档本体:docs/src/pom.md
PageAPI 参考(goto、newPage等):docs/src/api/class-page.mdLocatorAPI 参考(locator、first、nth、动作与断言入口):docs/src/api/class-locator.md- 仓库中的真实用例可参考 examples/todomvc/tests 等目录,观察
locator在端到端测试中的实际用法
atomcodeClaude Code 的开源替代方案。连接任意大模型,编辑代码,运行命令,自动验证 — 全自动执行。用 Rust 构建,极致性能。 | An open-source alternative to Claude Code. Connect any LLM, edit code, run commands, and verify changes — autonomously. Built in Rust for speed. Get StartedRust0624
Hy4-previewHy4 preview 是由腾讯混元团队研发的新一代混合专家(MoE)旗舰模型。模型总参数量 770B,每个 token 激活 49B,主干共包含78层,第一层采用标准 FFN,其余 77 层均为 MoE 结构,每层包含 256 个路由专家与 1 个共享专家,每个 token 激活 top-8 路由专家及共享专家。主干之外原生内置 1 层 MTP(总参数量 10B,激活 0.7B)以支持投机解码。Python00
GLM-5.3GLM-5.3 与 GLM-5.2 使用相同的基座模型——所有提升均来自后训练。与 GLM-5.2 相比,它在复杂编程和长程任务上的表现显著提升。Jinja00
GLM-5.3-FlashGLM-5.3-Flash (320B-A18B),是GLM-5系列的首个原生多模态模型。320B总参数,能力超过GLM-5.2Jinja00
Spark-X2.5-4BSpark-X2.5-4B 旨在让强大的 AI 更实用、更高效、更易获得。在广泛日常任务中表现强劲,涵盖对话、写作、翻译、推理、编码、工具调用以及智能体工作流,并在同等规模的开源模型中取得领先成绩。Spark-X2.5 将面向效率的架构与最高 1M tokens 的原生上下文窗口相结合,并支持 200 多种语言。Python00
Spark-X2.5-1.7BSpark-X2.5-1.7B 旨在让强大的 AI 更加实用、高效且易于获取。这些模型在广泛的日常任务中表现出色,涵盖对话、写作、翻译、推理、编程、工具调用和智能体工作流,并在同等规模的开源模型中取得领先结果。Spark-X2.5 将面向效率的架构与最高 1M tokens 的原生上下文窗口相结合,并支持 200 多种语言。Python00