几周前,我与某人进行了一次结对编程/指导会议,此人联系我是因为他们觉得自己需要一些支持。当我第一次看到他们编写的代码时,我印象深刻。
当然,有些地方我会做得不同,但大多数情况下,这只是个人偏好,并不是我的方法比他们的方法更好。我们没有直接修改他们的代码,而是决定从零开始一起构建一些测试代码,在此过程中讨论和应用良好的编程原则和模式。
由于测试使用的是 TypeScript 中的 Playwright,并且主要面向图形用户界面,我们决定为他们应用程序中的一个关键组件开始构建基于页面对象的结构。
这个组件是一个 UI 组件,允许最终用户在系统中创建报告。顺便说一下,系统的具体类型甚至领域本身对于本文的目的并不重要。该组件的外观大致如下(经过高度简化):
在顶部,有一个单选按钮,包含三个选项,用于选择不同的报告布局。每种报告布局由多个表单字段组成,大多数表单字段是文本区域加上锁定按钮,锁定按钮会打开一个类似下拉菜单的结构,您可以在其中通过选择一个或多个角色来编辑该字段的查看权限(这是一个隐私功能)。当然,还有一个保存按钮用于保存报告,以及一个打印按钮。
实际的 UI 组件还有其他几种类型的组件,但为了简洁起见,我们暂时只关注这些。
第 0 次迭代 - 创建初始页面对象
每当我自己从零开始,或者与其他人合作时,我的方法是采取小步骤并逐步引入复杂性。可能很想立即创建一个包含所有元素字段和交互方法的页面对象,但这很快就会变得混乱。
相反,我们从能想到的最简单的页面对象开始:一个允许我们创建标准报告的页面对象,而不考虑用于设置权限的锁定按钮。假设一个标准报告只包含标题和摘要文本字段。该页面对象的第一个迭代大致如下:
export class StandardReportPage {
readonly page: Page;
readonly radioSelectStandard: Locator;
readonly textfieldTitle: Locator;
readonly textfieldSummary: Locator;
readonly buttonSaveReport: Locator;
readonly buttonPrintReport: Locator;
constructor(page: Page) {
this.page = page;
this.radioSelectStandard = page.getByLabel('Standard report');
this.textfieldTitle = page.getByPlaceholder('Title');
this.textfieldSummary = page.getByPlaceholder('Summary');
this.buttonSaveReport = page.getByRole('button', { name: 'Save' });
this.buttonPrintReport = page.getByRole('button', { name: 'Print' });
}
async select() {
await this.radioSelectStandard.click();
}
async setTitle(title: string) {
await this.textfieldTitle.fill(title);
}
async setSummary(summary: string) {
await this.textfieldSummary.fill(summary);
}
async save() {
await this.buttonSaveReport.click();
}
async print() {
await this.buttonPrintReport.click();
}
}
这使得使用此页面对象的测试如下所示:
test('创建标准报告', async ({ page } ) => {
const standardReportPage = new StandardReportPage(page);
await standardReportPage.select();
await standardReportPage.setTitle('我的新报告标题');
await standardReportPage.setSummary('报告摘要');
await standardReportPage.save();
await expect(page.getByTestId('standard-report-save-success')).toBeVisible();
});
第 1 次迭代 - 分组元素交互
在我们实现并使用此页面对象后,我的第一个问题是:“你觉得这个测试的可读性如何?”当然,我们刚刚编写了这段代码,它是一个小例子,但想象一下,您正在使用的所有页面对象都像这样编写,并且提供更多元素交互。
这将很快导致非常程序化的测试代码“输入这个,输入那个,点击这里,检查那里”,这并不能很好地展示测试的意图。换句话说,这种编码风格并没有很好地隐藏页面的实现(即使它隐藏了定位器),而是专注于行为。
为了改进这一点,我建议将形成逻辑最终用户交互的元素交互分组到一个方法中,并公开该方法。当我阅读或编写测试时,我对执行高层次动作所需的单个元素交互的顺序并不特别感兴趣。我对“填充文本字段”或“点击按钮”不感兴趣,我感兴趣的是“创建标准报告”。
这促使我们将页面对象重构为如下所示:
export class StandardReportPage {
readonly page: Page;
readonly radioSelectStandard: Locator;
readonly textfieldTitle: Locator;
readonly textfieldSummary: Locator;
readonly buttonSaveReport: Locator;
readonly buttonPrintReport: Locator;
constructor(page: Page) {
this.page = page;
this.radioSelectStandard = page.getByLabel('Standard report');
this.textfieldTitle = page.getByPlaceholder('Title');
this.textfieldSummary = page.getByPlaceholder('Summary');
this.buttonSaveReport = page.getByRole('button', { name: 'Save' });
this.buttonPrintReport = page.getByRole('button', { name: 'Print' });
}
async select() {
await this.radioSelectStandard.click();
}
async create(title: string, summary: string) {
await this.textfieldTitle.fill(title);
await this.textfieldSummary.fill(summary);
await this.buttonSaveReport.click();
}
async print() {
await this.buttonPrintReport.click();
}
}
这反过来又使测试如下所示:
test('创建标准报告', async ({ page } ) => {
const standardReportPage = new StandardReportPage(page);
await standardReportPage.select();
await standardReportPage.create('我的新报告标题', '报告摘要');
await expect(page.getByTestId('standard-report-save-success')).toBeVisible();
});
在可读性和“暴露行为,隐藏实现”方面已经好多了。顺便说一下,这样做并不是 UI 自动化或测试自动化独有的。这个原则被称为封装,它是面向对象编程的基本原则之一。如果您希望保持测试代码的可读性,这是一个非常有用的原则。
第 2 次迭代 - 添加设置表单字段权限的能力
在我们的下一步中,我们决定引入为每个文本字段设置访问权限的能力。正如在本文顶部的表单图形表示中解释和显示的那样,标准表单中的每个表单字段都有一个关联的锁定按钮,该按钮打开一个小对话框,用户可以在其中选择哪些用户角色可以查看报告字段。
我们的初步想法是简单地在页面对象中添加额外的字段来表示标准报告。然而,这将导致大量重复工作,并且标准报告将包含许多包含元素定位器的字段。因此,我们决定看看是否可以将报告文本字段及其关联的权限锁定按钮视为页面组件,即一个单独的类,封装特定页面上一组相关元素的行为。
以可重用的方式设置此组件在应用程序中具有相同结构的 HTML 时会容易得多。好消息是,这种情况经常发生,特别是当前端设计师和开发人员使用像 Storybook 这样的工具设计和实现前端时。
因此,标准表单的相关 HTML 部分可能如下所示(再次简化):
<div id="standard_form">
<div data-testid="form_field_subject">
<div data-testid="form_field_subject_textfield"></div>
<div data-testid="form_field_subject_lock"></div>
</div>
<div data-testid="form_field_summary">
<div data-testid="form_field_summary_textfield"></div>
<div data-testid="form_field_summary_lock"></div>
</div>
</div>
一个可重用的页面组件类可能如下所示:
export class ReportFormField {
readonly page: Page;
readonly textfield: Locator;
readonly buttonLockPermissions: Locator;
constructor(page: Page, formFieldName: string) {
this.page = page;
this.textfield = page.getByTestId(`${formFieldName}_textfield`);
this.buttonLockPermissions = page.getByTestId(`${formFieldName}_lock`);
}
async complete(text: string, roles: string[]) {
await this.textfield.fill(text);
await this.buttonLockPermissions.click();
// 处理设置表单字段的权限
}
}
请注意,此页面组件类的构造函数使用(实际上是依赖于)应用程序中组件的可预测、重复结构以及 data-testid 属性的存在。如果您的组件没有这些属性,请找到一种方法来添加它们,或者找到另一种通用方式来定位页面上的单个元素。
现在我们已经定义了页面组件类,我们需要定义这些页面组件与包含它们的页面对象之间的关系。在过去,我的选择会默认创建包含可重用页面组件的基页面类,以及基页面中的其他实用方法。更具体的页面对象将继承这些基页面,从而允许它们使用父基页面类中定义的方法。
几乎不可避免的是,这最终会导致非常混乱的基页面类,其中包含大量字段和方法,这些字段和方法最多只是间接相关。这种混乱的原因是什么?我没有清楚地思考不同页面对象和组件之间的关系类型。
您看,创建基类并使用继承来实现可重用性会创建“is-a”关系。当对象之间的关系是“is-a”性质时,这些关系很有用。然而,在我们的情况下,不存在“is-a”关系,而是存在“has-a”关系。页面对象有一个特定的页面组件。
换句话说,我们需要以不同的方式定义关系,这就是通过组合而不是继承。我们将页面组件定义为页面对象的组件,这使得两者之间的关系更加自然,并且代码结构更加清晰:
export class StandardReportPage {
readonly page: Page;
readonly radioSelectStandard: Locator;
readonly reportFormFieldTitle: ReportFormField;
readonly reportFormFieldSummary: ReportFormField;
readonly buttonSaveReport: Locator;
readonly buttonPrintReport: Locator;
constructor(page: Page) {
this.page = page;
this.radioSelectStandard = page.getByLabel('Standard report');
this.reportFormFieldTitle = new ReportFormField(this.page, 'title');
this.reportFormFieldSummary = new ReportFormField(this.page, 'summary');
this.buttonSaveReport = page.getByRole('button', { name: 'Save' });
this.buttonPrintReport = page.getByRole('button', { name: 'Print' });
}
async select() {
await this.radioSelectStandard.click();
}
async create(title: string, summary: string, roles: string[]) {
await this.reportFormFieldTitle.complete(title, roles);
await this.reportFormFieldSummary.complete(summary, roles);
await this.buttonSaveReport.click();
}
async print() {
await this.buttonPrintReport.click();
}
}
阅读此代码比将所有内容塞入一个或多个父类或基页面对象中要自然得多。在这里学到的教训是:代码中对象之间的关系应该反映这些对象在现实生活中的关系,即在您的应用程序中。
第 3 次迭代 - 其他报告类型怎么办?
到目前为止,我们经历的开发和重构步骤使我们对代码感到满意。然而,我们仍然只有一种表单类型的页面对象,正如您在本文顶部的草图中看到的那样,还有不同类型的表单。我们如何处理这些表单?特别是当我们知道这些表单共享一些组件和行为,但并非全部时?
很容易立即得出结论并开始向问题抛出模式和结构,但在这样的结对编程会议中,我通常会尽量避免立即找到并实现“最终”解决方案。为什么?因为当您看到(或创建)一个次优的情况,讨论该情况的问题,调查潜在的解决方案,然后才实施它们时,最好的学习就完成了。
当然,最初会花费更长时间,但通过更好地理解次优代码的外观以及如何改进它,这将得到极大的补偿。
因此,首先我们为每个报告类型创建单独的类,每个类都与我们之前创建的标准报告的实现类似。以下是一个扩展报告的示例,包含更多表单字段(好吧,只是一个更多,但您明白我的意思):
export class ExtendedReportPage {
readonly page: Page;
readonly radioSelectExtended: Locator;
readonly reportFormFieldTitle: ReportFormField;
readonly reportFormFieldSummary: ReportFormField;
readonly reportFormFieldAdditionalInfo: ReportFormField;
readonly buttonSaveReport: Locator;
readonly buttonPrintReport: Locator;
constructor(page: Page) {
this.page = page;
this.radioSelectExtended = page.getByLabel('Extended report');
this.reportFormFieldTitle = new ReportFormField(this.page, 'title');
this.reportFormFieldSummary = new ReportFormField(this.page, 'summary');
this.reportFormFieldAdditionalInfo = new ReportFormField(this.page, 'additionalInfo');
this.buttonSaveReport = page.getByRole('button', { name: 'Save' });
this.buttonPrintReport = page.getByRole('button', { name: 'Print' });
}
async select() {
await this.radioSelectExtended.click();
}
async create(title: string, summary: string, additionalInfo: string, roles: string[]) {
await this.reportFormFieldTitle.complete(title, roles);
await this.reportFormFieldSummary.complete(summary, roles);
await this.reportFormFieldAdditionalInfo.complete(additionalInfo, roles);
await this.buttonSaveReport.click();
}
async print() {
await this.buttonPrintReport.click();
}
}
显然,这个类与标准报告的页面对象之间有很多重复代码。如何处理它们?与页面组件的情况不同,在这种情况下,通过创建一个基报告页面对象来减少重复是有意义的。我们在这里讨论的是创建一个“is-a”关系(继承),而不是“has-a”关系(组合)。标准报告是一种报告。
这意味着在这种情况下,我们可以、也应该创建一个基报告页面对象,将一些(甚至可能是所有)重复代码移到那里,并让特定的报告页面对象从该基报告类派生。
在这里,我的建议是将基报告页面对象实现为一个抽象类,以防止人们直接实例化它。这将导致更具表现力和清晰的代码,因为我们只能实例化具体的报告子类型,这将立即让代码的读者清楚他们正在处理哪种类型的报告。
在抽象类中,我们声明所有报告共享的元素。这适用于方法,但也适用于出现在所有报告类型中的网页元素。这就是抽象基类可能的外观:
export abstract class ReportBasePage {
readonly page: Page;
readonly reportFormFieldTitle: ReportFormField;
readonly reportFormFieldSummary: ReportFormField;
readonly buttonSaveReport: Locator;
readonly buttonPrintReport: Locator;
abstract readonly radioSelect: Locator;
protected constructor(page: Page) {
this.page = page;
this.reportFormFieldTitle = new ReportFormField(this.page, 'title');
this.reportFormFieldSummary = new ReportFormField(this.page, 'summary');
this.buttonSaveReport = page.getByRole('button', { name: 'Save' });
this.buttonPrintReport = page.getByRole('button', { name: 'Print' });
}
async select() {
await this.radioSelect.click();
}
async print() {
await this.buttonPrintReport.click();
}
}
现在,实现抽象类的标准报告的具体类如下所示:
export class ExtendedReportPage extends ReportBasePage {
readonly page: Page;
readonly radioSelect: Locator;
readonly reportFormFieldAdditionalInfo: ReportFormField;
constructor(page: Page) {
super(page);
this.page = page;
this.radioSelect = page.getByLabel('Extended report');
this.reportFormFieldAdditionalInfo = new ReportFormField(this.page, 'additionalInfo');
}
async create(title: string, summary: string, additionalInfo: string, roles: string[]) {
await this.reportFormFieldTitle.complete(title, roles);
await this.reportFormFieldSummary.complete(summary, roles);
await this.reportFormFieldAdditionalInfo.complete(additionalInfo, roles);
await this.buttonSaveReport.click();
}
}
抽象类负责所有报告共享的方法,例如 print() 和 select() 方法。它还定义了实现的具体类应该实现哪些元素和方法。目前,这只是 radioSelect 定位器。
请注意,目前,由于不同类型的报告所需的数据并不相同,我们还不能在抽象类中添加一个抽象的 select(): void 方法要求,所有报告页面对象都应该实现这个方法。这是一个暂时的缺点,我们将在稍后解决。
同样请注意,测试代码没有变化,但我们现在可以创建标准报告和扩展报告,它们在幕后共享大量代码。这绝对是一个正确的方向。
第 4 次迭代 - 处理测试数据
我们的测试已经看起来很好了。它们易于阅读,并且代码的结构与它们所代表的应用程序部分的结构一致。我们完成了吗?也许吧。作为我们测试的最后改进,让我们看看我们处理测试数据的方式。
目前,我们在测试方法中使用的测试数据只是一个未结构化的字符串、整数、布尔值等的集合。对于小测试和简单的领域,这可能还可以,但一旦您的测试套件增长且领域变得更加复杂,这将变得令人困惑。那个字符串值到底代表什么?为什么那个变量是布尔值,如果它被设置为 true(或 false)会发生什么?
这就是测试数据对象可以提供帮助的地方。测试数据对象是简单的类,通常不过是数据传输对象(DTO),它们代表领域实体。在这种情况下,该领域实体可能是报告,例如。拥有代表领域实体的类型可以大大提高我们代码的可读性,这将使理解我们到底在做什么变得更加容易。
这些测试数据对象的实现通常很简单。在 TypeScript 中,我们可以为此使用一个简单的接口。我选择创建一个 ReportContent 类,它包含我们所有报告类型的数据。随着它们的分歧,我可能会选择将它们重构为单独的接口,但目前这没问题。
此外,定义这个测试数据对象还有一个额外的好处,它允许我将不同报告页面对象的 create() 方法的定义移到抽象基类中,这是我们之前无法执行的一步。这就是我的接口的样子:
export interface ReportContent {
title: string;
summary: string;
additionalInfo?: string;
roles: string[];
}
additionalInfo 字段被标记为可选,因为它只出现在扩展报告中,而不是标准报告中。
在某些情况下,为了进一步提高代码的灵活性,我们可能会选择将测试数据对象定义为类而不是接口。这将允许我们为属性设置合理的默认值,以避免在每个测试中为这些属性分配相同的值。在 TypeScript 中使用接口时,无法设置默认值。
在这种特定情况下,我使用接口是可以的,因为我们的 ReportContent 对象很小。您的情况可能会有所不同。
现在我们已经为报告数据定义了一个类型,我们可以更改页面对象中 create() 方法的签名和实现,以使用这个类型。以下是扩展报告的示例:
async create(report: ReportContent) {
await this.reportFormFieldTitle.complete(report.title, report.roles);
await this.reportFormFieldSummary.complete(report.summary, report.roles);
await this.reportFormFieldAdditionalInfo.complete(report.additionalInfo, report.roles);
await this.buttonSaveReport.click();
}
现在,我们可以在抽象的 ReportBasePage 类中添加以下行:
abstract create(report: ReportContent): void;
以强制所有报告页面对象实现一个接受 ReportContent 类型参数的 create() 方法。我们也可以对其他测试数据对象执行相同的步骤。
这是一项艰巨的工作,但它导致了我认为结构良好、易于阅读和维护的代码。正如本文所希望展示的那样,当您编写测试代码时,对常见的面向对象编程原则和模式有良好的工作知识是非常有用的。对于 UI 自动化尤其如此,但我们在本文中看到的许多原则也可以应用于其他类型的测试自动化。
还有许多其他模式可以探索。本文并不是试图列出所有模式,也没有展示编写页面对象的“唯一正确方法”。希望本文展示了我编写测试自动化代码时的思考过程,以及如何理解面向对象编程的基本原理帮助我更好地做到这一点。
非常感谢 Olena 参与我讨论的结对编程会议,并审阅了本文。我非常感激。