← 回總覽

第 3 篇:用 TypeScript 写 Page Object — 告别混乱的定位器

📅 2026-07-22 07:58 Playwright实战教程 软件编程 11 分鐘 12632 字 評分: 86
自动化测试 Playwright TypeScript Page Object Model 工程实践
📌 一句话摘要 本文系统讲解如何用 TypeScript 编写 Playwright 的 Page Object Model,通过 BasePage 基类和三个实战页面对象,展示 TS 在编译时安全与类型推导上的优势。 📝 详细摘要 本文是 Playwright 实战教程的第三篇,聚焦于用 TypeScript 实现 Page Object Model。作者首先指出散装 POM 的维护痛点,引出 BasePage 基类的必要性,并详细展示了基类中导航、等待、填充、点击等通用方法的实现与设计原则。随后,文章通过 LoginPage、AccountsPage 和 TransferPage 三个

!Image 1

大家好,我是欢乐马。 第二篇我们把工程骨架搭好了。今天开始往里填内容——先从 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'); }} 每个页面类都重复写gotofillFieldsafeClickwaitForLoad。十来个页面类写下来,同样的代码出现二十遍——这就是 POM 的散装写法,不好维护。

有位读者在公众号留言说得特别直接:"举例说明永远是登录,复杂情况永久无法落地。"他说的没错。登录页的 POM 谁都会写,难的是 80 个页面共存时,怎么做才能让团队协作不打架、三个月后自己还看得懂。

正确做法:抽一个BasePage 基类,子类继承后只写自己特有的选择器和业务方法。登录只是起点,BasePage 才是骨架。 *

二、BasePage:把所有通用能力收进去

playwright-parabank-demo/pages/base.page.tsimport { 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 });  }} 设计原则:

* •fillFieldclear()fill()——避免残留文本追加进去

* •safeClick先等可见再点——避免元素还在动画中就点击导致报错

* •navigateTodomcontentloaded而不是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.tsimport { 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.tsimport { 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.tsimport { 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,敲.立刻列出所有可用方法:clickfillhoverscreenshot…… 3. 参数类型声明,调用时就能发现错误 async login(username: string, password: string): Promise<void>  // 参数类型明确 调用loginPage.login(123, true)→ IDE 标红,类型不匹配。Python 没有这个。 4. 子类继承父类方法,类型自动推导 LoginPage继承BasePage,自动获得fillFieldsafeClick等方法,输入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-testidname属性、role,最后才是text= *

八、本章总结

POM 不是新概念,但 TypeScript 给它加了编译时安全网readonly保护选择器不被意外修改,Locator类型提供完整的 API 提示,基类继承让所有页面对象共享通用能力。

这篇文章的三个页面对象——LoginPageAccountsPageTransferPage——分别演示了表单、表格、下拉框三种最常见的页面交互模式。后续所有测试都会建立在这些 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,一套代码压透全链路(附源码包)

_一文一码,就找欢乐马_

查看原文 → 發佈: 2026-07-22 07:58:00 收錄: 2026-07-22 14:00:45

🤖 問 AI

針對這篇文章提問,AI 會根據文章內容回答。按 Ctrl+Enter 送出。