实施 Playwright 移动端 E2E 测试时的挑战
在使用 Playwright 实施端到端 (E2E) 测试的移动端运行时,如何高效管理工作流可能是一项挑战:
单独的测试文件
为移动端和桌面端创建单独的测试文件,通常会导致代码重复,特别是当差异很小时,比如移动端上只需要点击多两个按钮。
测试中的条件逻辑
在测试代码中添加大量 if (isMobile)
判断来处理平台特定的行为,容易使代码臃肿,难以阅读和维护。
平台特定的组件创建
重复使用某个函数检查当前平台是否是移动端并决定实例化哪个组件,这增加了不必要的复杂性和冗余。
通过使用 TypeScript 装饰器,我们可以将这些平台特定的逻辑封装在一个地方,从而简化代码并提高其可维护性。
我们的目标是编写一个可以在不同平台上都能运行的单一测试,测试实例会根据平台的不同而表现出不同的行为。如下示例所示:
test.describe('Crawler', () => {
test('Navigation', async ({ app, page, isMobile }) => {
await page.goto('/');
await app.dashboardPage.navbar.assertTitle();
await app.dashboardPage.navbar.selectNavItem('Docs');
await app.article.assertHeader('Installation');
await app.article.assertTableOfContentsLinkCount(9);
await app.dashboardPage.navbar.openNavigation();
await app.dashboardPage.navbar.clickCloseNavigation();
await app.article.navbar.clickSearch();
await app.article.search.fill('api');
await app.article.search.close();
await app.article.search.assertModalNotExist();
});
});
什么是 TypeScript 装饰器?
TypeScript 装饰器是一种为类及其成员添加特殊行为的方法。它们允许你在不重复代码的情况下修改或扩展功能,从而使代码库更加简洁。
实现平台装饰器
首先将 Playwright 的 isMobile
测试选项与我们的 global.IS_MOBILE
变量相关联。这使我们能够在定义 Playwright 项目时设置 isMobile
值,并在全局范围内使用其值,特别是在平台装饰器中,而无需在 .env
文件中创建单独的 isMobile
变量。
// test.ts
import { test as base } from '@playwright/test';
export const test = base.extend<>({
isMobile: async ({ isMobile }, use) => {
global.IS_MOBILE = isMobile;
await use(isMobile);
},
});
为了使用装饰器,请确保你使用的是 TypeScript 5.0 或更高版本。
// Type definition for a class constructor
type ClassConstructor = new (...args: any[]) => any;
// A Map to store the mobile and desktop class mappings for each base class
const classMappings = new Map<Function, { mobile?: ClassConstructor; desktop?: ClassConstructor }>();
// Function to map a class to either 'mobile' or 'desktop'
function mapClass(classConstructor: ClassConstructor, classType: 'mobile' | 'desktop'): void {
const baseClass = Object.getPrototypeOf(classConstructor);
const mappings = classMappings.get(baseClass) ?? {};
mappings[classType] = classConstructor;
classMappings.set(baseClass, mappings);
}
// Decorator for the desktop version of the class
export const Desktop = () => {
return function (DesktopClass: ClassConstructor, _context: Record<string, any>): ClassConstructor {
mapClass(DesktopClass, 'desktop');
return DesktopClass;
};
};
// Decorator for the mobile version of the class
export const Mobile = () => {
return function (MobileClass: ClassConstructor, _context: Record<string, any>): ClassConstructor {
mapClass(MobileClass, 'mobile');
return MobileClass;
};
};
// Decorator for the base class that proxies to either the mobile or desktop version
export const Base = () => {
return function (BaseClass: ClassConstructor, _context: Record<string, any>): ClassConstructor {
return class Proxy extends BaseClass {
public static isProxying = false; // Flag to prevent infinite recursion in the constructor
constructor(...args: any[]) {
if (!Proxy.isProxying) {
Proxy.isProxying = true;
try {
const TargetClass = global.IS_MOBILE
? classMappings.get(Proxy).mobile
: classMappings.get(Proxy).desktop;
if (TargetClass) {
const instance = new TargetClass(...args); // Create an instance of the appropriate class
Proxy.isProxying = false;
return instance;
}
} catch (error) {
throw new Error(`Index file with exports might not have been created \n ${error.message}`);
}
Proxy.isProxying = false;
}
super(...args); // Call the constructor of the base class if no proxying occurs
}
};
};
};
应用平台装饰器
为了展示这些平台装饰器在实际中的工作方式,让我们将它们应用于 NavbarComponent,该组件在移动端和桌面端需要有不同的行为表现。
为了测试这些功能,我将使用 Playwright 网站作为测试环境,代码示例基于 Playwright 1.46.0 版本。以下是基础组件及其平台特定变体的实现。
// navbar.component.ts
import { Base } from '@global/decorators/platform.decorator';
import { expect, Page } from '@playwright/test';
@Base()
export class NavbarComponent {
constructor(
protected page: Page,
protected closeNavigation = page.locator('[aria-label="Close navigation bar"]'),
private title = page.locator('.navbar__title'),
private colorMode = page.locator('.colorModeToggle_DEke button'),
private search = page.locator('[aria-label="Search"]')
) {}
public async assertTitle(): Promise<void> {
await expect(this.title.first()).toHaveText('Playwright');
}
public async clickSearch(): Promise<void> {
await this.search.click();
}
public async clickColorMode(): Promise<void> {
await this.colorMode.click();
}
public async clickCloseNavigation(): Promise<void> {}
public async openNavigation(): Promise<void> {}
public async selectNavItem(_item: string): Promise<void> {}
}
// navbar.desktop.component.ts
import { Desktop } from '@global/decorators/platform.decorator';
import { NavbarComponent } from '@src/modules/shared/components/navbar/navbar.component';
@Desktop()
export class NavbarDesktopComponent extends NavbarComponent {
public override async selectNavItem(item: string): Promise<void> {
await this.page.getByText(item).click();
}
}
// navbar.mobile.component.ts
import { Mobile } from '@global/decorators/platform.decorator';
import { NavbarComponent } from '@src/modules/shared/components/navbar/navbar.component';
@Mobile()
export class NavbarMobileComponent extends NavbarComponent {
private navToggle = this.page.locator('[aria-label="Toggle navigation bar"]');
public override async selectNavItem(item: string): Promise<void> {
await this.openNavigation();
await this.page.getByRole('link', { name: item }).click();
}
public override async openNavigation(): Promise<void> {
await this.navToggle.click();
}
public override async clickCloseNavigation(): Promise<void> {
await this.closeNavigation.click();
}
}
- 基础组件 (
NavbarComponent
) :作为一个通用类,定义了桌面端和移动端都需要的功能,比如assertTitle
、clickSearch
等方法。这是一个被@Base()
装饰的基础类。 - 桌面端组件 (
NavbarDesktopComponent
) :通过@Desktop()
装饰,桌面端特定的方法,比如selectNavItem
,只需要点击相应的导航项。 - 移动端组件 (
NavbarMobileComponent
) :用@Mobile()
装饰,包含了移动端特定的逻辑,如在selectNavItem
方法中,先打开导航栏,再点击导航项;并且还定义了移动端的导航开关操作
导出组件以及 index.ts
的工作原理
为了确保在使用 @Mobile()
和 @Desktop()
装饰器时正确实例化相应的组件(移动端或桌面端),所有相关组件都必须从一个中心化的 index.ts
文件中导出。这对于装饰器的正常工作至关重要,因为它确保装饰器在执行时可以正确找到相应的类。
// index.ts
export * from './navbar.component';
export * from './navbar.mobile.component';
export * from './navbar.desktop.component';
// dashboard.page.ts
import { Page } from '@playwright/test';
import { BasePage } from '@src/modules/shared/modules/base/base.page';
import { NavbarComponent } from '@src/modules/shared/components/navbar';
export class DashboardPage extends BasePage {
constructor(
page: Page,
public navbar = new NavbarComponent(page),
private getStarted = page.getByText('Get started')
) {
super(page);
}
public async clickGetStarted(): Promise<void> {
await this.getStarted.click();
}
}
为什么这种方法有效?
当你导入 index.ts
时(它重新导出组件),TypeScript 会执行模块中的所有顶级代码,包括类定义和装饰器的执行。
由于 TypeScript 中的装饰器是在类定义时执行的,因此 Desktop
和 Mobile
装饰器会在模块加载期间将其各自的类(NavbarDesktopComponent
、NavbarMobileComponent
)注册到 classMappings
映射中。这样,当你稍后创建这些类的实例时,平台特定的逻辑就会正常工作。
装饰器的执行顺序
当 NavbarComponent
(用 @Base
装饰)被导入到 NavbarDesktopComponent
时,NavbarComponent
上的 @Base()
装饰器会在 NavbarDesktopComponent
上的 @Desktop()
装饰器之前执行。此时,classMappings
映射可能是空的,因为 NavbarMobileComponent
和 NavbarDesktopComponent
的装饰器还没有应用。
Proxy 类的作用
Proxy
类会延迟 @Base()
装饰器内部的逻辑,直到实际创建实例时才执行。也就是说,构造函数逻辑会在稍后执行,仅在你创建 NavbarComponent
实例时(实际上是 Proxy
的实例)执行。这自然发生在 Desktop
和 Mobile
装饰器已经执行并填充了 classMappings
映射之后。这确保了当 @Base()
装饰器需要决定实例化哪个类时,所有必要的信息都已可用,从而保证正确且可预测的行为。
解决无限递归问题
当创建 NavbarMobileComponent
时,它扩展了 NavbarComponent
,而由于 @Base
装饰器的原因,NavbarComponent
被 Proxy
类包装。在实例化时,Proxy
检查当前平台是否为移动端,并尝试再次创建 NavbarMobileComponent
,这导致 Proxy
构造函数反复触发,每次创建 NavbarMobileComponent
都会导致再次尝试实例化同一个类,从而导致无限递归循环。这种循环会一直持续到堆栈溢出,最终引发运行时错误。
解决方案: 在 Proxy
类中引入 isProxying
标志来防止这种无限递归。
运行测试以查看装饰器的实际效果
现在我们已经设置好了装饰器,并将 isMobile
值与 global.IS_MOBILE
关联起来,让我们运行一个简单的测试,看看装饰器的实际效果。
这是一个示例测试:
test.describe('Crawler', () => {
test('Navigation', async ({ app, page }) => {
await page.goto('/');
await app.dashboardPage.navbar.assertTitle();
await app.dashboardPage.navbar.selectNavItem('Docs');
await app.article.assertHeader('Installation');
});
});
可以通过以下命令分别在桌面和移动端平台上运行该测试:
npx playwright test --project=local-mobile --project=local-chrome crawler.spec.ts
我们可以看到,基于 isMobile
条件,不同的组件被使用。
省略多余的桌面实现
在某些情况下,基础类可能已经满足了桌面端的需求,因此不需要额外创建桌面端类并应用 @Desktop
装饰器。以下是一个示例:
// article.page.ts
@Base()
export class ArticlePage extends BasePage {
constructor(
page: Page,
public navbar = new NavbarComponent(page),
protected tableOfContentsLink = page.locator('.table-of-contents__link'),
private header = page.locator('header')
) {
super(page);
}
public async assertHeader(text: string): Promise<void> {
await expect(this.header).toHaveText(text);
}
public async assertTableOfContentsLinkCount(count: number): Promise<void> {
await expect(this.tableOfContentsLink).toHaveCount(count);
}
}
// article.mobile.page.ts
@Mobile()
export class ArticleMobilePage extends ArticlePage {
private onThisPage = this.page.getByText('On this page');
public override async assertTableOfContentsLinkCount(count: number): Promise<void> {
await this.onThisPage.click();
await expect(this.tableOfContentsLink).toHaveCount(count);
}
}
在这个例子中,ArticlePage
作为桌面端的基础实现。由于它已经满足桌面端的需求,因此无需创建额外的桌面实现和应用 @Desktop
装饰器。@Mobile
装饰器仍然用于 ArticleMobilePage
以处理移动端特定的行为。
这种方法减少了冗余,使代码库更加简洁,利用基础类作为桌面端的默认实现,同时为移动端提供了特定的版本。
使用实用函数处理条件操作
在某些情况下,共享组件中的操作通常适用于两个平台,但在多个测试中需要根据平台有选择性地跳过或执行。比如你可能希望在移动端跳过某个特定操作,或仅在移动端执行额外步骤。在这种情况下,重写方法可能并不够或不够实际。
为此,你可以创建实用函数,根据条件有选择地执行操作。
以下是如何在 Playwright 测试中使用这些实用函数的示例:
// utils.ts
export async function skipOn(flag: boolean, callback: () => Promise<void>): Promise<void> {
if (!flag) {
return callback();
}
return;
}
export async function onlyOn(flag: boolean, callback: () => Promise<void>): Promise<void> {
if (flag) {
return callback();
}
return;
}
test.describe('Crawler', () => {
test('Navigation', async ({ app, page, isMobile }) => {
await page.goto('/');
await app.dashboardPage.navbar.assertTitle();
await app.dashboardPage.navbar.selectNavItem('Docs');
await app.article.assertHeader('Installation');
await app.article.assertTableOfContentsLinkCount(9);
await app.dashboardPage.navbar.openNavigation();
await app.dashboardPage.navbar.clickCloseNavigation();
await onlyOn(isMobile, async () => {
await app.article.scrollToBottom();
await app.article.footer.assertCopyright();
});
await skipOn(isMobile, async () => {
await app.article.navbar.clickColorMode();
});
await app.article.navbar.clickSearch();
});
});
这仍然是一个 if
判断,但具有更具可读性的名称,使代码意图更清晰、更易于理解。
扩展类中的移动端实现问题
当扩展具有移动端实现的类时,装饰器不会起作用,因为子类的移动端版本不会自动继承父类的移动端版本。
如图所示,当 ArticleMobilePage
扩展 ArticlePage
时,它不会自动继承 BasePageMobile
。
解决方案
从技术上讲,可以实现一个解决方案,遍历类的原型,检查父类是否扩展了其他类,如果扩展了,则找到它的移动端版本,并重新分配原型链以使用该版本。
这是一个概念性示例:
if (isMobile && mobileClass?.__proto__?.__proto__?.prototype?.__proto__?.__proto__) {
const parentMobileClass = classStore.find(
item =>
item.desktopClass ===
mobileClass.__proto__.__proto__.prototype.__proto__.__proto__.constructor
)?.mobileClass;
if (parentMobileClass) {
mobileClass.__proto__.__proto__.prototype.__proto__ = new parentMobileClass(...args);
}
}
虽然这种方法有效,我也使用过一段时间,但最终决定放弃它,因为这种行为不够自然。
结论
使用 TypeScript 装饰器处理 Playwright 测试中的平台特定逻辑是简化代码的一种有效方式。通过使用装饰器,你可以避免代码重复,减少 if
判断,并保持针对移动端和桌面端的单一测试规范。
一些建议
- 跳过桌面上的操作:如果你需要在桌面端跳过某个操作,而在移动端执行,请为桌面创建一个空方法。
- 使用实用函数进行条件逻辑处理:如果重写方法不足以跳过或添加特定平台的操作,请使用
skipOn()
和onlyOn()
函数。这些函数帮助你控制操作或断言的执行方式。 - 标记测试:如果一个测试文件仅打算在桌面端运行,可以添加
@onlyDesktop
标签。 - 选择器管理:虽然你可以为不同平台覆盖选择器,但一般来说,尽可能统一选择器更好。这减少了平台特定实现的需要。
示例代码仓库:GitHub 仓库