Featured image of post UI自动化,POM之外的另一模式

UI自动化,POM之外的另一模式

POM是UI自动化的最佳实践吗?

引言

在UI测试自动化领域,页面对象模型(Page Object Model,POM)几乎已经是行业标准。它被广泛认为是UI自动化测试领域的最佳实践,对于提高可维护性和可扩展性,几乎是不二之选。

笔者在实际工作中,组织搭建过的UI自动化框架虽然大部分也是遵循了个这个模型,但隐隐也确实觉得这个更多是个开发实践而不是应用实践。是OOP(面向对象)思想在自动化测试实施种的体现。

现偶然看到一篇国外博主的 Shubham Sharma 的文章 《Beyond the Page Object Model-A Functional Approach to Test Automation》,对POM模型进行了思考,并提出了一个函数化方法模型。(文末阅读原文可跳转原文)

虽然不知具体实践成效如何,但兼听则明,开拓下思路总是没错的。

以下基于原博文内容翻译整理而来(help by Gemini)

关于POM

POM的核心思想是将页面元素和浏览器交互封装到整洁、可重用的对象中。

它的方法很简单:为每个页面或组件创建一个类,包含定位器(locators)和操作这些元素的方法。

虽然POM相比那些充满硬编码元素的脆弱脚本是一个进步,但它本质上也是一个有缺陷的抽象。它将面向对象原则应用到一个本质上过程化和行为驱动的问题领域,导致测试套件难以理解、与状态紧密耦合且扩展困难。

POM问题

以一个典型的登录流程POM实现为例:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
class LoginPage {
  constructor(driver) {
    this.driver = driver;
    this.usernameField = '#username';
    this.passwordField = '#password';
    this.loginButton = '#login-btn';
  }

  async enterUsername(username) {
    await this.driver.locator(this.usernameField).fill(username);
  }

  async enterPassword(password) {
    await this.driver.locator(this.passwordField).fill(password);
  }

  async clickLogin() {
    await this.driver.locator(this.loginButton).click();
  }

  async loginAs(username, password) {
    await this.enterUsername(username);
    await this.enterPassword(password);
    await this.clickLogin();
  }
}

在测试中的使用:

1
2
3
4
test('user can log in', async ({ page }) => {
  const loginPage = new LoginPage(page);
  await loginPage.loginAs('testuser', 'password123');
});

乍一看,这看起来很整洁。但在表面之下,它引入了几个严重问题:

隐藏状态和紧密耦合

对象存储了对驱动程序(page)的引用,创建了隐式依赖。这使得页面类无法独立测试,并将其行为和浏览器的运行时状态进行了锁定。

分散的用户逻辑

像"登录、进入设置、更改密码" 这样的用户旅程变成了跨多个页面对象的方法调用链。工作流被分割——而不是一个连贯的、可测试的单元。

僵化和膨胀

一些新的流程(如SSO登录),则被迫添加更多方法(违反单一职责原则)或创建新类(导致重复)。随着时间的推移,你的页面对象要么膨胀,要么分裂。

POM将页面作为抽象单位。但页面只是背景。

真正重要的是用户操作。

替代方案:函数式模型,可组合的工作流

函数式模型颠覆了这一脚本。它将用户行为,而非UI结构作为主要抽象。

第一步:分离数据(定位器)

将选择器保持为普通的、不可变的数据——没有方法,没有状态:

1
2
3
4
5
export const LoginPageLocators = {
  usernameField: '#username',
  passwordField: '#password',
  loginButton: '#login-btn',
};

第二步:编写无状态交互函数

不要将方法绑定到类,而是编写可重用的无状态函数:

1
2
3
4
5
6
7
export async function fillField(page, locator, text) {
  await page.locator(locator).fill(text);
}

export async function clickElement(page, locator) {
  await page.locator(locator).click();
}

这些可以在整个应用中重用,易于独立测试,并且可以集中改进(例如添加重试、等待、日志记录)。

第三步:以声明方式组合用户工作流

将真正的用户旅程构建为一等函数:

1
2
3
4
5
6
7
export function login(page, credentials) {
  return async () => {
    await fillField(page, LoginPageLocators.usernameField, credentials.username);
    await fillField(page, LoginPageLocators.passwordField, credentials.password);
    await clickElement(page, LoginPageLocators.loginButton);
  };
}

现在你的测试读起来就像一个故事:

1
2
3
4
test('user can log in', async ({ page }) => {
  const credentials = { username: 'admin', password: 'securePassword' };
  await login(page, credentials)();
});

为什么推荐这种方法

真正的可组合性

你不仅仅是在调用方法,而是在构建工作流。login()函数可以组合到changePassword()工作流中,或者与导航步骤结合。函数成为有意义的可测试单元。

改进的清晰度

测试不再是逐步执行UI操作的命令式脚本。相反,它们成为用户意图的声明式管道,更易于阅读、编写和理解。

集中化维护

修复不稳定的选择器或改进点击行为发生在一个地方,并传播到所有地方。不再需要在分散在文件中的类方法中寻找。

测试的范式转变

页面对象模型反映了一个遗留范式:面向对象编程,其中所有事物都被建模为具有状态和行为的"事物"。但UI测试不是关于事物,而是关于流程

通过拥抱函数式思维,我们将测试建模为用户行为的可组合管道,而不是僵化的对象。这些工作流具有以下特点:

  • 无状态
  • 隔离
  • 声明式
  • 易于测试
  • 易于更改
  • 易于组合

核心要点

停止问:“我这里需要什么页面对象?”

开始问:“我想要描述的用户工作流是什么?”

答案将引导你构建一个更简单更清洁更接近真实用户体验的测试套件,它不是建立在继承和隐藏状态之上,而是建立在纯函数和可组合流程之上。这不仅仅是一种编码风格,它是测试的更好心智模型。

结论:迈向更自然的测试方式

页面对象模型曾经是UI测试自动化的重要进步,但随着测试复杂度的增加和软件开发理念的发展,它的局限性日益明显。函数式方法为我们提供了一个更自然、更灵活的替代方案。

通过将关注点从页面结构转向用户行为,从对象状态转向工作流程,我们能够创建更贴近真实用户体验的测试。这种方法不仅提高了代码的可维护性和可读性,更重要的是,它改变了我们对测试本质的理解。

在快速变化的软件开发现代中,我们需要能够适应变化的测试策略。函数式测试方法正是这样一种策略——它灵活、可组合、易于维护,能够随着应用的发展而演进。

使用 Hugo 构建
主题 StackJimmy 设计