大家好,我是欢乐马。 第二篇我们把工程骨架搭好了。今天开始往里填内容——先从 Page Object Model 开始。
如果你写过 Python 版 Playwright 或 Selenium,POM 这个概念不陌生。但 TypeScript 的 POM 跟 Python 的 POM 写起来差别很大:readonly、类型推导、Locator泛型、JSDoc 文档注释——这些在 Python 里要么不存在,要么是运行时约定。
这篇文章会带你从 BasePage 基类入手,把登录页、账户页、转账页三个页面对象完整写一遍。源码在playwright-parabank-demo/pages/目录下。
*
一、先说问题:POM 为什么必须有基类?
很多人写 POM 是这样的:
class LoginPage { constructor(private page: Page) {} async goto() { await this.page.goto('/login'); }}class AccountsPage { constructor(private page: Page) {} async goto() { await this.page.goto('/accounts'); }}
每个页面类都重复写goto、fillField、safeClick、waitForLoad。十来个页面类写下来,同样的代码出现二十遍——这就是 POM 的散装写法,不好维护。
有位读者在公众号留言说得特别直接:"举例说明永远是登录,复杂情况永久无法落地。"他说的没错。登录页的 POM 谁都会写,难的是 80 个页面共存时,怎么做才能让团队协作不打架、三个月后自己还看得懂。
正确做法:抽一个BasePage 基类,子类继承后只写自己特有的选择器和业务方法。登录只是起点,BasePage 才是骨架。 *
二、BasePage:把所有通用能力收进去
playwright-parabank-demo/pages/base.page.ts:
import { Page, Locator } from '@playwright/test';export class BasePage { protected page: Page; constructor(page: Page) { this.page = page; } / 导航到指定路径 / async navigateTo(path: string): Promise<void> { // 显式拼接完整 URL——不能依赖 Playwright 的 baseURL, // 因为 baseURL 带路径时 page.goto('/xxx') 会把路径替换掉 const base = process.env.PARA_BASE_URL || 'https://parabank.parasoft.com/parabank'; await this.page.goto(${base}${path}, { waitUntil: 'domcontentloaded' }); } / 等待页面加载完成 / async waitForLoad(): Promise<void> { await this.page.waitForLoadState('networkidle'); } / 等待元素可见 / async waitForVisible(locator: Locator, timeout = 10000): Promise<void> { await locator.waitFor({ state: 'visible', timeout }); } / 安全填充文本框(先清空再填) / async fillField(locator: Locator, value: string): Promise<void> { await locator.clear(); await locator.fill(value); } / 安全点击(等待元素可见后点击) / async safeClick(locator: Locator): Promise<void> { await locator.waitFor({ state: 'visible' }); await locator.click(); } / 截图(用于 Allure 报告) / async takeScreenshot(name: string): Promise<Buffer> { return this.page.screenshot({ fullPage: true }); }}
设计原则:* •fillField先clear()再fill()——避免残留文本追加进去
* •safeClick先等可见再点——避免元素还在动画中就点击导致报错
* •navigateTo用domcontentloaded而不是networkidle——更快,且对静态页面足够
* •protected page而不是private page——子类可以直接用this.page,不用调 getter
> 给 Python 转 TS 的同学:上面这段代码里有几个 TS 才有的概念,解释一下:
>
>
> * •readonly:声明一个属性在构造函数之外不能被修改。Python 用_前缀做约定("别改我"),TS 用readonly让编译器强制执行——你写loginPage.usernameInput = xxx直接编译报错。
>
> * •Promise<void>:表示"这个函数是异步的,不返回任何值"。Python 里你写async def login() -> None:,TS 里写async login(): Promise<void>。void≈ Python 的None。
>
> * •protected:这个属性只能在本类和子类中访问,外界访问不到。Python 没有语言层面的protected,靠_约定。
>
> * •: string:参数类型声明。login(username: string, password: string)告诉编译器"这两个参数必须是字符串"。Python 3.10+ 也有类型注解(def login(username: str, password: str)),但 Python 不强制执行,TS 会——传数字进来编译直接挂。
欢乐马踩坑记:没写 BasePage 的代价
几年前我接手过一个团队项目,80 多个页面对象,没有一个基类。
每个页面类里都散落着page.waitForLoadState('networkidle')、page.locator(...).click()这种重复代码。后来业务变更,等待策略从networkidle改成domcontentloaded——就因为没抽 BasePage,我得翻遍 80 多个文件一个一个改。
改到第 40 个的时候,我就下了决心:以后任何项目,POM 第一件事就是抽 BasePage。
这个项目里你看到的waitForLoad()、fillField()、safeClick(),背后都是那次痛苦的教训。BasePage 不是为了看起来高级——它实际帮你减少 40% 的重复代码,而且当你需要修改通用行为时,只改一个文件。
另外,fillField里为什么先clear()再fill()?因为我碰到过 ParaBank 的一个 bug:错误登录后页面不刷新,用户名还留在输入框里,直接fill()会导致johnwrongpassword这种拼接字符串。先clear()是防御性编程。
*
三、LoginPage:实战的第一个页面对象
playwright-parabank-demo/pages/login.page.ts:
import { Page, Locator } from '@playwright/test';import { BasePage } from './base.page';export class LoginPage extends BasePage { // ========== 选择器(集中管理) ========== readonly usernameInput: Locator; readonly passwordInput: Locator; readonly loginButton: Locator; readonly errorMessage: Locator; constructor(page: Page) { super(page); // 语义化定位器:优先用 name 属性 this.usernameInput = page.locator('input[name="username"]'); this.passwordInput = page.locator('input[name="password"]'); this.loginButton = page.locator('input[value="Log In"]'); this.errorMessage = page.locator('.error'); } / 导航到登录页 / async goto(): Promise<void> { await this.navigateTo('/index.htm'); await this.waitForLoad(); } / 执行登录 / async login(username: string, password: string): Promise<void> { await this.fillField(this.usernameInput, username); await this.fillField(this.passwordInput, password); await this.loginButton.click(); await this.page.waitForLoadState('networkidle'); } / 使用默认账户登录 / async loginAsDefault(): Promise<void> { const username = process.env.PARA_USERNAME || 'john'; const password = process.env.PARA_PASSWORD || 'demo'; await this.login(username, password); } / 获取错误信息 / async getErrorMessage(): Promise<string> { const text = await this.errorMessage.textContent(); return text || ''; }}
为什么这样写?
- 1.
readonly声明选择器。这是 TS 独有的优势——表示"初始化后不可修改",防止测试代码里意外改了选择器。
- 2.
Locator类型而不是any。IDE 能自动补全locator.click()、locator.fill()等所有方法。
- 3.
loginAsDefault()封装默认账户。测试代码里不用每次写用户名密码,也不用关心环境变量读取细节。
- 4.
getErrorMessage()返回string而不是Locator。测试断言需要的是文本内容,不是 DOM 元素。
在测试里怎么用
// login.spec.tstest('@smoke 使用默认账户成功登录', async ({ loginPage, page }) => { await loginPage.loginAsDefault(); await expect(page).toHaveURL(/overview\.htm/);});test('登录失败:错误密码', async ({ loginPage }) => { await loginPage.login('john', 'wrong_password'); const errorText = await loginPage.getErrorMessage(); expect(errorText).toContain('Error');});
测试代码里看不到一个选择器,也看不到一个page.goto()。所有操作都是通过loginPage这个语义化对象完成的——这就是 POM 的目标。
*
四、AccountsPage:表格解析与动态等待
银行系统最常见的一个页面是账户概览——一张动态生成的表格,列出所有账户和余额。解析这种表格是 POM 的经典场景。
playwright-parabank-demo/pages/accounts.page.ts:
import { Page, Locator } from '@playwright/test';import { BasePage } from './base.page';export class AccountsPage extends BasePage { readonly accountsTable: Locator; readonly totalBalance: Locator; constructor(page: Page) { super(page); this.accountsTable = page.locator('#accountTable'); this.totalBalance = page.locator('//[contains(text(),"Total")]'); // XPath 当 CSS 不适用时 } / 获取所有账户 ID / async getAccountIds(): Promise<string[]> { await this.waitForVisible(this.accountsTable); const rows = this.accountsTable.locator('tbody tr'); const count = await rows.count(); const ids: string[] = []; for (let i = 0; i < count; i++) { const id = await rows.nth(i).locator('td').first().textContent(); if (id) ids.push(id.trim()); } return ids; } / 获取总余额 */ async getTotalBalance(): Promise<number> { await this.waitForVisible(this.totalBalance); const text = await this.totalBalance.textContent(); const match = text?.match(/\$([\d.]+)/); return match ? parseFloat(match[1]) : 0; }}
关键技巧:
* •表格解析用rows.count()+rows.nth(i)组合。Playwright 没有"获取所有行"的批量方法,但通过循环遍历可以精确拿到每个单元格。
* •XPath 作为备选。#accountTable用 CSS 选择器,但Total这个文本没有合适的 CSS 定位方式,用 XPathcontains(text(),"Total")更准确。
* •waitForVisible放在取值之前。这在动态加载页面上特别重要——如果表格是 AJAX 加载的,定位器存在但内容为空,waitForVisible确保数据渲染完再读。
*
五、TransferPage:下拉框 + 表单提交
转账是 ParaBank 的核心业务流程:选择账户、输入金额、提交、验证结果。
playwright-parabank-demo/pages/transfer.page.ts:
import { Page, Locator } from '@playwright/test';import { BasePage } from './base.page';export class TransferPage extends BasePage { readonly fromAccount: Locator; readonly toAccount: Locator; readonly amountInput: Locator; readonly transferButton: Locator; readonly resultMessage: Locator; constructor(page: Page) { super(page); this.fromAccount = page.locator('#fromAccountId'); this.toAccount = page.locator('#toAccountId'); this.amountInput = page.locator('#amount'); this.transferButton = page.locator('input[value="Transfer"]'); // ParaBank 转账结果页:结果区域在 #showResult 内 this.resultMessage = page.locator('#showResult .title'); } / 导航到转账页 / async goto(): Promise<void> { await this.navigateTo('/transfer.htm'); await this.waitForLoad(); } / 执行转账 / async transfer(fromIndex: number, toIndex: number, amount: number): Promise<void> { // Playwright 原生 select 操作 await this.fromAccount.selectOption({ index: fromIndex }); await this.toAccount.selectOption({ index: toIndex }); await this.fillField(this.amountInput, String(amount)); await this.transferButton.click(); await this.page.waitForLoadState('networkidle'); } / 获取转账结果消息 / async getResultMessage(): Promise<string> { const text = await this.resultMessage.textContent(); return text || ''; } / 验证转账成功 / async isTransferSuccessful(): Promise<boolean> { const text = await this.getResultMessage(); return text.includes('Complete'); // ParaBank 返回 "Transfer Complete!" }}
为什么用.selectOption({ index })而不是按文本选?
ParaBank 的下拉框选项是动态生成的:<option>12345</option>,每次创建账户后选项会变。按index选择比按文本更稳定——你永远选"第一个账户"和"第二个账户",不管账户 ID 是什么。
*
六、POM 在 TS 里的五个差异化优势
写完三个页面对象,总结一下 TypeScript 版 POM 相比 Python 版的提升:
1.readonly显式声明不可变
Python 用_前缀约定"别改",TS 用readonly让编译器强制执行。你写loginPage.usernameInput = xxx,TS 编译直接报错。
2.Locator类型让 IDE 自动补全
Python 的page.locator()返回什么?不知道,IDE 也不提示。TS 的返回类型是Locator,敲.立刻列出所有可用方法:click、fill、hover、screenshot……
3. 参数类型声明,调用时就能发现错误
async login(username: string, password: string): Promise<void> // 参数类型明确
调用loginPage.login(123, true)→ IDE 标红,类型不匹配。Python 没有这个。
4. 子类继承父类方法,类型自动推导
LoginPage继承BasePage,自动获得fillField、safeClick等方法,输入loginPage.自动补全父类的全部方法。Python 同样可以继承,但没有类型提示。
5. JSDoc 注释即时显示
在 TS 里写@param username - 用户名,鼠标悬停在任何调用login()的地方都能看到这个说明。Python 的 docstring 需要专门的插件才能做到。
*
七、踩坑记录
坑 1:fillField没加clear()
// ❌ 错误await this.usernameInput.fill(username);await this.passwordInput.fill(password);
ParaBank 在错误的登录后会保留用户名,第二次fill可能追加到已有文本后面,导致登录失败。正确做法是fillField里先clear()。
坑 2:转账后不等页面加载就读结果
await this.transferButton.click();const result = await this.resultMessage.textContent(); // ❌ 可能读到空
Parabank 的转账结果是通过重定向返回的,click()不保证页面已经到结果页。必须加waitForLoadState('networkidle')。
坑 3:直接用page.locator('text=...')当定位器
text=定位器匹配范围太广。页面上任何包含该文本的元素都会命中,容易误匹配。优先用data-testid、name属性、role,最后才是text=。
*
八、本章总结
POM 不是新概念,但 TypeScript 给它加了编译时安全网。readonly保护选择器不被意外修改,Locator类型提供完整的 API 提示,基类继承让所有页面对象共享通用能力。
这篇文章的三个页面对象——LoginPage、AccountsPage、TransferPage——分别演示了表单、表格、下拉框三种最常见的页面交互模式。后续所有测试都会建立在这些 POM 之上。
*
九、下一篇预告
下一篇:《Fixture 系统:Playwright TS 的杀手锏》。
POM 写好了,但每个 test 里还得手动new LoginPage(page)。Fixture 依赖注入能让你直接在测试参数里声明loginPage,TS 自动注入、自动补全、自动检查类型——这是 Python 版做不到的。
关注本公众号,后台回复「TS」,获取完整配套源码包。 *
往期更多干货 Playwright Skill[](https://www.bestblogs.dev/article/7e1c742f3c?amp%3Butm_medium=feed&%3Butm_campaign=resources&%3Bentry=rss_article_item)#1:登录态持久化,一次人工登录脚本/AI无限复用 Playwright 1.59 新特性:3 个 API 帮你告别 F12 手动找定位 AI提示词模板库:1.测试用例设计|10分钟学会让ai帮你生成合格测试用例(附完整源码) Playwright+MCP:给POM和pytest加一层AI能力(AI实战落地企业·源码) Playwright+Pytest+POM:3个fixture让你的测试框架稳定运行100+用例(源码包02) 接口+UI一体化测试天花板!Playwright API测试实战(附完整源码) Locust 压测实战:从 API 到 UI,一套代码压透全链路(附源码包)
_一文一码,就找欢乐马_