本文的操作需要在上篇建立的测试工程的基础上执行,上篇回顾:使用 Playwright 搭建前端自动化测试工程 (上)
如何编写测试用例也是一个问题,如果各位曾实践过前端 E2E 测试的话,应该会对以下问题有所体会:
- 难以唯一定位到一个 DOM 元素。
- 测试用例有大量的重复代码。
- 做某些断言的时机难以掌握,太早了相关数据还未准备好,太晚了(使用 sleep)影响测试效率。
- 写好了测试用例,但是页面修改导致用例失效。
- 前端 E2E 测试用例不是简单的脚本,编写困难,且需要反复调试,认真设计其工作量不亚于开发页面。
我个人在去年用 Cypress
搭建自动化测试时,因为这些困扰而最终弃坑。本次使用 Playwright
重建 UI 测试体系时仍然遇到了类似的困扰。
这些问题,有的因为 Playwright
的易用性更强而得以解决,有些需要我们积累更多的经验、加深对工具使用的理解,但是更多的需要从哲学的角度去思考取舍与 trade-off。
目前业界对于是否应该做 UI 自动化测试也在争论不休。对于哪些业务适合做这种测试,大部分人持有这样的观点:
- 业务的维护周期足够长。
- 业务的需求不会频繁地、破坏性地变动。
- UI 与交互已经进入稳定的阶段。
- 回归测试任务繁多。
我做测试的动机是项目组没有测试人员,开发同学在本职工作足够忙碌的情况下,没有足够的心力保证每次版本发布后的功能回归测试,导致线上总是出现低级错误。我需要基本的防护网来兜底,尽早发现错误。
我只是一个前端开发,并不是专业的测试人员,因此本文的重点不会放在方法论的陈述上,而关注测试用例的具体编写实践。在我们的项目中,为了应对不同的现实情况,会有三种不同“规格”的测试用例并存。下面我将给大家介绍在 Playwright
框架下的三种不同“规格”的用例编写,在过程中穿插对 Playwright
功能的讲解。
低规格:自动生成测试代码
测试代码生成器 是 Playwright
的一个主要卖点,生成器会根据我们在浏览器中的交互与操作,自动生成对应的测试代码。
使用代码生成器能够以极小的时间成本生成测试用例,但是这样的用例也存在着缺点:
- 对页面元素的定位不够精准,难以适应 DOM 结构的修改。(使用
testid
可以大幅改善) - 生成的代码只有元素定位与交互操作的部分,只能证明“流程可以跑通”,但是由于缺少断言,无法确保“流程正确”。
- 生成的代码重复度很高,不利于后期的修改调整,往往只能推倒重来。
我们会在以下场景中使用这种方式生成代码:
- 补充存量业务用例时,当前迭代周期交付压力大,编写测试用例的时间不足,至少能够以这种方式实现“从无到有”。
- 新开发的特性,后续存在着大幅修改和迭代的可能,但至少要确保业务能够跑通。
这种低规格的用例作为一种临时方案,在时间充裕或者业务稳定后,会被更加精细化的用例逐步替代。
通过以下命令能够启动测试代码生成器:
npx playwright codegen
在 上篇 中我们解决了登录鉴权问题,在确保成功执行过 setup
工程的情况下,通过命令 npx playwright codegen --load-storage=.auth/user.json
启动,可以加载保存在本地的浏览器缓存,从而在完成登录鉴权的状态下生成测试代码。我们在工程中的 package.json
中声明这条命令,之后通过 pnpm run codegen
启动代码生成器。
{
// ...
"scripts": {
// ...
"codegen": "playwright codegen --load-storage=.auth/user.json"
},
}
在浏览器中我们执行的任意操作都会在右侧的编辑器中生成代码。新建 tests/juejin.spec.ts
,将生成的代码复制进文件,运行 npx playwright test --ui
并在操作界面中执行刚生成的测试文件。
// 自动生成的代码
import { test, expect } from '@playwright/test';
test.use({
storageState: '.auth/user.json'
});
test('test', async ({ page }) => {
// 访问掘金首页
await page.goto('https://juejin.cn/');
// 在搜索输入框中输入 Vue 并确认
await page.getByPlaceholder('探索稀土掘金').click();
await page.getByPlaceholder('搜索文章/小册/标签/用户').press('CapsLock');
await page.getByPlaceholder('搜索文章/小册/标签/用户').fill('V');
await page.getByPlaceholder('搜索文章/小册/标签/用户').press('CapsLock');
await page.getByPlaceholder('搜索文章/小册/标签/用户').fill('Vue');
await page.getByRole('search').getByRole('img').click();
// 在搜索结果页面切换类别标签
await page.getByRole('link', { name: '文章', exact: true }).click();
await page.getByRole('main').getByRole('link', { name: '课程' }).click();
await page.getByRole('link', { name: '标签' }).click();
await page.getByRole('link', { name: '用户' }).click();
});
这样我们就顺利地通过代码生成器实现了一个测试用例,这个用例验证了以下操作流程:
- 在搜索框内输入文字,点击搜索能够正确跳转到搜索结果页。
- 搜索结果页具有文章、课程、标签、用户四个分类标签。(注意:只证明了标签在搜索结果页存在,但是没有验证标签是否能正确跳转)
中规格:通过 API 手写测试用例
为了弥补自动生成测试代码的种种缺陷,对于重要性更高的业务,在时间相对充裕的时候,我们还是要回归 Playwright
的 API,对自动生成的测试用例加以补充,使其更加精准可靠。
- 使用测试 API,更好地组织测试用例。
- 使用定位 API,让元素定位更加精准,甚至能够适应一定程度的页面修改。
- 使用断言 API,在流程跑通的基础上,增加正确性的校验。
- 使用等待 API,处理难以把握的断言时机。
测试 API
Playwright
提供的 test
方法用于声明测试用例,这个方法上也挂载了丰富的钩子,用于控制测试进程。完整用法需要阅读官方的 API 文档
测试分组
通过 test.describe
方法,可以对逻辑相近的测试用例进行分组。
test.describe('提交表单', () => {
test('A 类型表单提交', async ({ page }) => {
// ...
});
test('B 类型表单提交', async ({ page }) => {
// ...
});
});
前/后置操作钩子
前/后置操作钩子一般用于处理测试用例的公共逻辑,或设置用例之间共享的资源。
test.beforeEach
每个测试用例执行前触发test.afterEach
每个测试用例执行后触发test.beforeAll
所有测试用例执行前触发test.afterAll
所有测试用例执行完触发
import { test } from '@playwright/test';
// 全局钩子对每一个用例都生效
test.beforeEach(async ({ page }) => {
// 没个用例执行前先跳转到起始 url
await page.goto('https://juejin.cn/');
});
test.describe('group A', () => {
// 分组内的钩子只对当前分组中的用例生效
test.beforeEach(async ({ page }) => {
// ...
});
test('my test', async ({ page }) => {
// ...
});
// 在 my test 用例完成后执行,可以用来删除测试遗留数据
test.afterEach(async ({ page }) => {
// ...
});
});
定位 API
学习或测试 Playwright
页面定位的最佳方式就是打开代码生成器,在浏览器界面按 F12
打开控制台,window.playwright
对象上挂了所有的定位 API,直接在控制台调用 API 就可以直观地获得输出结果。
定位器本身也是一种断言,也能起到等待的效果。当定位器暂时没选取到任何元素时,测试进程也会保持等待与重试,直到选中目标元素或者测试超时、用例失败。
选择器定位
locator
方法是定位元素最通用的方法,用法与 jquery 的 $
方法类似,接收 css 选择器 或 xpath 选择器,返回选中的元素列表。
import { test } from '@playwright/test';
test('test', async ({ page }) => {
// 选取 class 为 item 的元素
page.locator('.item')
// 选取 id 为 app 的 <div></div> 元素
page.locator('div#app')
// 支持 xPath 选择器
// 参考:https://playwright.dev/docs/other-locators#xpath-locator
// 参考:https://playwright.dev/docs/locators#locate-by-css-or-xpath
const target = page.locator('xpath=//button');
// xpath 选取父元素
target.locator('..')
})
更多的选择器定位方法请参考:其他定位方式
语义定位
在定位 API 中还有一系列通过语义实现定位的方法。
import { test } from '@playwright/test';
test('test', async ({ page }) => {
/**
* 通过元素内部的文字定位元素
*
* https://playwright.dev/docs/locators#locate-by-text
*/
page.getByText('首页')
/**
* 通过 alt / title 属性定位元素
*
* https://playwright.dev/docs/locators#locate-by-alt-text
*
* https://playwright.dev/docs/locators#locate-by-title
*
* 例如:
*
* <img src="..." alt="稀土掘金" class="logo-img">
*
* <img src="..." title="稀土掘金" class="logo-img">
*/
page.getByAltText('稀土掘金');
page.getByTitle('稀土掘金');
/**
* 通过输入框的占位文本定位元素
*
* https://playwright.dev/docs/locators#locate-by-placeholder
*
* 例如:
*
* <input type="search" maxlength="64" placeholder="探索稀土掘金" class="search-input isResourceVisible">
*/
page.getByPlaceholder('探索稀土掘金');
/**
* 通过无障碍技术 ARIA 角色来定位元素
*
* https://playwright.dev/docs/locators#locate-by-role
*/
page.getByRole('link', { name: '首页' });
/**
* 通过约定好的测试 id 定位元素(推荐)
*
* https://playwright.dev/docs/locators#locate-by-test-id
*
* 例如:
*
* <button data-testid="submit-btn">Button</button>
*/
page.getByTestId('submit-btn');
});
语义定位比起选择器定位在使用上要简便一些,并且由于选择的依据更偏向于业务属性,不容易因为页面 DOM 结构的调整而导致定位失效。
其中,Locator.getByTestId
是我个人比较倾向的实践,这种方式需要测试人员与前端开发人员沟通,为需要测试的元素加上 data-testid
属性,这样 Locator.getByTestId
就可以唯一地选中我们期望的对象。这种方式增加了前端人员与测试人员的沟通成本,但好处是不言而喻的。
纯选择器定位难以抵挡 DOM 结构或者页面布局的调整,非常脆弱;其他语义定位虽然不依赖于 DOM 结构,但是业务层面的修改依然会导致用例失效。当下,国内大部分 Web 应用都不重视 H5 的语义化,无障碍技术的理念还没有深入人心,大部分语义定位使用起来依然困难。而 testid
方案既能唯一精准地定位到目标元素,也能无视页面布局的调整,抵抗大部分的业务调整,即使出现了大幅度重构,这个 data-testid
属性也能够清晰地向开发人员表明:这是一个关键测试元素,请评估是否修改或调整测试用例。
断言 API
如果你熟悉 Jest
、Vitest
等单元测试框架,那么对这类断言应该是在了解不过了。
// 值断言
expect(2).toBe(2);
// 对象比较断言
expect({ code: 0 }).toEqual({ code: 0 })
// 错误断言
expect(() => {
throw new Error('Something bad');
}).toThrow(/something/);
同样地,可以同过 .not
实现否定断言。否定断言也可以应用于其他断言类型。
expect(1).not.toBe(2);
在 UI 测试中,这类对值的校验除了检查接口返回的数据,出现频率不高。页面元素的状态才是我们更加关心的。
除了通常断言以外,其他所有复杂的断言类型都是异步任务。我们可以观察 .d.ts
声明中这个 API 的返回值,凡是返回 Promise<XXX>
的,都是异步任务,不要忘记通过 async/await
进行调用。
// 检查页面标题是否为 稀土掘金
await expect(page).toHaveTitle('稀土掘金');
// 检查页面 URL 是否包含 /login
await expect(page).toHaveURL(/.*\/login/);
定位器断言可以对定位 API 选中的元素的状态进行检验,它是测试用例中具有最高的使用频率。
const headerElement = page.getByTestId('my-header')
// 检查标题元素是否包含类名:title
await expect(headerElement).toHaveClass(/title/);
// 检查标题是否为:Title
await expect(headerElement).toContainText(/Title/);
const inputElement = page.getByRole('textbox');
// 检查输入框是否聚焦
await expect(inputElement).toBeFocused();
// 检查输入框的值是否为纯数字
await expect(inputElement).toHaveValue(/[0-9]+/);
除了以上高频断言外,还有一些其他类型的断言,读者可以自行阅读官方文档:
学习了各种断言后,我们可以在先前生成器实现的 tests/juejun.spec.ts
基础上补充一些断言。
import { test, expect } from '@playwright/test';
test('搜索测试', async ({ page }) => {
// 访问掘金首页
await page.goto('https://juejin.cn/');
// 在搜索输入框中输入 Vue 并确认
await page.getByPlaceholder('探索稀土掘金').click();
await page.getByPlaceholder('搜索文章/小册/标签/用户').fill('Vue');
await page.getByRole('search').getByRole('img').click();
// 默认选中的标签应该是【综合】,并通过类名检查综合标签的选中样式是否符合预期
const common = page.getByRole('link', { name: '综合', exact: true })
await expect(common.locator('..')).toHaveClass(/router-link-exact-active/);
// 检查页面 url,路径:/search,查询参数:query=Vue&type=0
await expect(page).toHaveURL(/.*\/search\?query=Vue&type=0/);
// 在搜索结果页面切换类别标签
const article = page.getByRole('link', { name: '文章', exact: true });
await article.click();
await expect(article.locator('..')).toHaveClass(/router-link-exact-active/);
await expect(page).toHaveURL(/.*\/search\?query=Vue&type=2/);
const classes = page.getByRole('main').getByRole('link', { name: '课程', exact: true });
await classes.click();
await expect(classes.locator('..')).toHaveClass(/router-link-exact-active/);
await expect(page).toHaveURL(/.*\/search\?query=Vue&type=12/);
const tags = page.getByRole('link', { name: '标签', exact: true });
await tags.click();
await expect(tags.locator('..')).toHaveClass(/router-link-exact-active/);
await expect(page).toHaveURL(/.*\/search\?query=Vue&type=9/);
const users = page.getByRole('link', { name: '用户', exact: true });
await users.click();
await expect(users.locator('..')).toHaveClass(/router-link-exact-active/);
await expect(page).toHaveURL(/.*\/search\?query=Vue&type=1/);
});
增加了断言后,我们的用例不再只是过一遍流程,对于正确性的检查也严谨了许多。
等待 API
有的时候,我们需要在页面的某个事件发生后,或者某个网络请求返回结果后,再进行断言,这就需要进行等待。
Playwright
官方文档旗帜鲜明地反对 sleep(time)
这种等待固定时间的写法,他们甚至将 Page.waitForTimeout
方法标记为废弃(deprecate)。目前官方推荐使用的等待 API 可以前往文档查看:Page.waitForxxx
在实际应用中,最常见的场景是等待网络请求返回数据(waitForResponse)。下面给出一个检查掘金首页 /user_api/v1/author/recommend
接口的例子。
import { test } from '@playwright/test';
test('接口检查', async ({ page }) => {
// 访问掘金首页
await page.goto('https://juejin.cn/');
const promise = page.waitForResponse(
(res) => res.url().includes('/user_api/v1/author/recommend'));
const res = await promise;
const data = await res.json();
expect(data.err_no).toBe(0);
})
高规格:设计测试对象状态机
E2E 测试的用例代码往往比单元测试要复杂得多。单元测试通常只关心函数的输入与输出,但是 E2E 测试需要模拟用户的实际操作,具有更长的过程链条。E2E 测试的用例之间也容易呈现出共有逻辑多,分支逻辑少的特点。
当业务重要性高,流程复杂度高,涉及的测试用例数量多时,我们便不再能忍受相同内容的定位器(page.locate(...)
)、断言器(expect(...)
)的反复出现,希望能够做一些封装,消除重复代码,使测试代码的可靠性、可维护性、可读性更强。
如何测试组件
一些基于现代前端框架的组件,如 Ant Design
、element-ui
等,它们是对一系列 HTML 元素的封装,内部实现比较复杂(有些组件甚至采用非标准实现,例如用 div 模拟 input),往往难以测试。
在调用组件时,代码是这样的(代码案例来自 element-ui Select 基础多选):
<el-select v-model="value1" multiple placeholder="请选择">
<el-option
v-for="item in options"
:key="item.value"
:label="item.label"
:value="item.value">
</el-option>
</el-select>
而实际生成的内容却是这样的
<!-- el-select 本体 -->
<div class="el-select">
<div class="el-select__tags" style="width: 100%; max-width: 208px;">
<span>
<span class="el-tag el-tag--info el-tag--small el-tag--light">
<span class="el-select__tags-text">双皮奶</span>
<i class="el-tag__close el-icon-close"></i>
</span>
<span class="el-tag el-tag--info el-tag--small el-tag--light">
<span class="el-select__tags-text">黄金糕</span>
<i class="el-tag__close el-icon-close"></i>
</span>
</span>
</div>
<div class="el-input el-input--suffix">
<input type="text" readonly="readonly" autocomplete="off" placeholder="" class="el-input__inner" style="height: 40px;">
<span class="el-input__suffix">
<span class="el-input__suffix-inner">
<i class="el-select__caret el-input__icon el-icon-arrow-up"></i>
</span>
</span>
</div>
</div>
<!-- 这部分下拉弹框被动态添加到了 <body> 下 -->
<div
class="el-select-dropdown el-popper is-multiple"
style="min-width: 240px; transform-origin: center top; z-index: 2009; position: absolute; top: 463px; left: 301px;"
x-placement="bottom-start">
<div class="el-scrollbar" style="">
<div class="el-select-dropdown__wrap el-scrollbar__wrap" style="margin-bottom: -17px; margin-right: -17px;">
<ul class="el-scrollbar__view el-select-dropdown__list">
<li class="el-select-dropdown__item selected hover">
<span>黄金糕</span>
</li>
<li class="el-select-dropdown__item selected">
<span>双皮奶</span>
</li>
<li class="el-select-dropdown__item">
<span>蚵仔煎</span>
</li>
<li class="el-select-dropdown__item">
<span>龙须面</span>
</li>
<li class="el-select-dropdown__item">
<span>北京烤鸭</span>
</li>
</ul>
</div>
<div class="el-scrollbar__bar is-horizontal">
<div class="el-scrollbar__thumb" style="transform: translateX(0%);"></div>
</div>
<div class="el-scrollbar__bar is-vertical">
<div class="el-scrollbar__thumb" style="transform: translateY(0%);"></div>
</div>
</div>
<div x-arrow="" class="popper__arrow" style="left: 35px;"></div>
</div>
即使我们给组件打上 data-testid
标记,通过 page.getByTestId
也只能定位到最外层的 <div>
,如果要模拟其功能,必然需要进一步使用 locator
进行深层定位,如何定位动态的 DOM 更是困难。对于这种复杂但是稳定(基础组件的逻辑是不变的)的特性,是值得专门设计代码封装测试行为的。
我们选取上面例子中提到的 elememt-ui
多选 Select
,用代码说明如何封装组件测试逻辑。
在测试工程中建立 components
目录,用于存放对组件的封装。注意在 tsconfig.json
中的 include
字段中补充此目录。
首先定义测试组件基类 Component
,处理页面对象保存、组件根元素定位保存的公共逻辑。
// components/Components.ts
import { Page, Locator } from '@playwright/test';
export class Component {
protected page: Page;
protected locator: Locator;
/**
* @param page 组件所在的页面对象
* @param locator 如何定位组件的根元素,传入字符串通过 getByTestId 获取,或者直接传入一个 locator
*/
constructor(page: Page, locator: Locator | string = '') {
this.page = page;
if (typeof locator === 'string') {
this.locator = locator ? page.getByTestId(locator) : page.locator('body');
} else {
this.locator = locator ? page.locator('body').locator(locator) : page.locator('body');
}
}
}
接着建立 Select
类继承组件基类,正式处理下拉多选框的测试逻辑。这里用到了相对灵活的 Locate.evaluate 方法获取页面元素的实例对象,为下拉框(后续要被移动到 body 下)提前设置 testid
,方便之后的定位。
// components/Select.ts
import { Locator, expect } from '@playwright/test';
import { Component } from './Component';
export class Select extends Component {
/** 记录已选中的选项 */
selected = new Set<string>();
/** 用当前日期生成下拉框的 testid */
popupId = new Date().getTime().toString();
/** 是否激活下拉框 */
private isPopupInitialized = false;
/** 选择框主体,用于触发点击交互 */
get el() {
return this.locator.getByRole('textbox');
}
/**
* 激活下拉框,并且遍历指定的 option 选项
* @param selections 每个选项的文字
* @param callback 对每个遍历到的选项的处理方式
*/
private async traceSelections(
selections: string[],
callback: (data: SelectionData) => Promise<void>,
) {
if (!this.isPopupInitialized) {
/**
* 下拉框还未激活时,处于组件根元素内部,第一次点击激活后才会被移动到 body 中。
*
* 在此之前先完成定位,并通过 evaluate 方法提前给下拉框设置 testid,方便之后定位
*
* evaluate 方法说明:https://playwright.dev/docs/api/class-locator#locator-evaluate
*/
await this.locator.locator('.el-select-dropdown').evaluate((node, id) => {
node.dataset.testid = id;
}, this.popupId);
this.isPopupInitialized = true;
}
// 点击激活下拉框
await this.el.click();
// 用设置好的 testid 可以轻松定位下拉框
const popover = this.page.getByTestId(this.popupId);
for (let i = 0; i < selections.length; i++) {
const target = popover.getByText(selections[i], { exact: true });
const text = await target.textContent();
if (!text) {
throw new Error('Selection has not text content!');
}
// 定位到文字的父元素,即容器元素,容器元素上有各种类名,可以用来判断选项的状态
const wrapper = target.locator('..');
const isSelected = this.selected.has(text);
const classNames = await wrapper.getAttribute('class');
// 容器元素有 is-disabled 类代表不可选
const isDisabled = classNames ? classNames.includes('is-disabled') : false;
// 执行自定义处理方法
await callback({
target,
wrapper,
text,
isSelected,
isDisabled,
});
}
await this.el.blur();
}
/** 选中目标选项 */
async checkSelections(...selections: string[]) {
await this.traceSelections(selections, async ({
target, wrapper, text, isSelected, isDisabled,
}) => {
if (!isSelected) {
await target.click();
this.selected.add(text);
}
if (isDisabled) {
throw new Error('Option is disabled!');
}
// 通过容器是否具有 selected 类,判断是否成功选中选项
await expect(wrapper).toHaveClass(/selected/);
});
}
/** 取消目标选项 */
async uncheckSelections(...selections: string[]) {
await this.traceSelections(selections, async ({
target, wrapper, text, isSelected, isDisabled,
}) => {
if (isSelected) {
await target.click();
this.selected.delete(text);
}
if (isDisabled) {
throw new Error('Option is disabled!');
}
await expect(wrapper).not.toHaveClass(/selected/);
});
}
}
interface SelectionData {
target: Locator;
wrapper: Locator;
text: string;
isSelected: boolean;
isDisabled: boolean;
}
完成封装后,我们建立 tests/element.spec.ts
文件验证一下。
// tests/element.spec.ts
import { test } from '@playwright/test';
import { Select } from '../components/Select'
test('测试多选框', async ({ page }) => {
await page.goto('https://element.eleme.cn/#/zh-CN/component/select', { waitUntil: 'domcontentloaded' });
const target = page.locator('.el-select').nth(4);
const select = new Select(page, target);
await select.checkSelections('黄金糕', '双皮奶');
})
封装页面测试行为
一个页面的多个测试用例可能包含同样的操作。例如提交表单,假如要测试不同的提交的方式,甚至包括验证失败的场景,那么填写某个表单项这一行为就会在多个用例中出现。
我们可以把页面看做一个复杂一些的组件,页面的内部由多个组件构成,每一项测试行为都视为几个组件行为的组合。
下面我们以 element-ui Form 自定义校验规则 作为例子,将这个简单的表单看做一个页面,用实际的代码展示如何设计页面的测试代码。
首先我们补充一下 Input
和 FormItem
这两个组件的封装。随着组件数量的增多,我们还需要添加 components/index.ts
文件作为组件导出的索引。
// components/Input.ts
import { Component } from './Component';
export class Input extends Component {
/** 定位 el-input 组件中的 <input> 原生元素 */
get el() {
return this.locator.getByRole('textbox');
}
/** 输入内容 */
async input(value: string) {
await this.el.fill(value);
}
}
// components/FormItem.ts
import { expect, Page, Locator } from '@playwright/test';
import { Component } from './Component';
export class FormItem<T extends Component> extends Component {
/** form-item 内部的输入元素,可以为任意输入组件 */
target: T;
constructor(
InputConstructor: typeof Component,
page: Page,
locator: Locator | string = '',
) {
super(page, locator);
this.target = new InputConstructor(page, locator) as T;
}
/** 定位到 el-form-item 的校验对象 */
get errorEl() {
return this.locator.locator('.el-form-item__error');
}
/** el-form-item 校验成功 */
async checkSuccess() {
await expect(this.locator).toHaveClass(/is-success/);
}
/** el-form-item 校验失败 */
async checkError(errTxt: RegExp | string = '') {
await expect(this.locator).toHaveClass(/is-error/);
if (errTxt) {
await expect(this.errorEl).toContainText(errTxt);
}
}
}
// components/index.ts
export * from './Component';
export * from './Input';
export * from './Select';
export * from './FormItem';
接着我们创建 views
目录,存放对页面的封装。同样要注意在 tsconfig.json
中的 include
字段中补充此目录。
与组件相同,我们定义页面的基类,对页面测试共有的逻辑进行封装。因为页面本身是复杂一些的组件,因此继承了组件类。
// views/View.ts
import { Page } from '@playwright/test';
import { Component } from '../components';
export class View extends Component {
protected url: string;
constructor(page: Page, url: string) {
super(page);
this.url = url;
}
/** 页面的访问操作时公共行为,适合放在基类中 */
async visit() {
await this.page.goto(this.url, { waitUntil: 'domcontentloaded' });
}
}
之后声明 LoginView
类继承页面基类,一方面将需要被测试的组件定义为类的成员,另一方面将高频测试操作封装为公用方法。这里我们对表单填写、表单提交两个常见操作进行了封装。
// views/LoginView.ts
import { Page } from '@playwright/test';
import { Input, FormItem } from '../components';
import { View } from './View'
export class LoginView extends View {
/** 页面的第五个案例作为我们的测试区域 */
get root() {
return this.page.locator('.source').nth(4)
}
/** 获取区域内所有的表单项 */
get formItems() {
return this.root.locator('.el-form-item');
}
/** 页面由组件组成 */
pwd = new FormItem<Input>(Input, this.page, this.formItems.nth(0));
confirmPwd = new FormItem<Input>(Input, this.page, this.formItems.nth(1));
age = new FormItem<Input>(Input, this.page, this.formItems.nth(2));
submitBtn = this.root.getByRole('button', { name: '提交' });
resetBtn = this.root.getByRole('button', { name: '重置' });
constructor(page: Page) {
super(page, 'https://element.eleme.cn/#/zh-CN/component/form');
}
/** 表单输入操作 */
async input(
pwd: string = '',
confirmPwd: string = '',
age: string = '',
) {
await this.pwd.target.input(pwd);
await this.confirmPwd.target.input(confirmPwd);
await this.age.target.input(age);
}
/** 表单输入并提交 */
async inputAndSubmit(
pwd: string = '',
confirmPwd: string = '',
age: string = '',
) {
await this.input(pwd, confirmPwd, age);
await this.submitBtn.click();
}
}
// views/index.ts
export * from './View';
export * from './LoginView';
最后,在 tests/element.spec.ts
里面补充用例代码,可以看到 inputAndSubmit
方法高频被复用,代码非常简洁,语义也比较清晰。
// tests/element.spec.ts
import { test, expect } from '@playwright/test';
import { LoginView } from '../views';
// 省略前面的无关用例
// 新增用例
test.describe('测试表单', () => {
let view: LoginView
test.beforeEach(async ({ page }) => {
view = new LoginView(page);
await view.visit();
});
test('不同的密码', async () => {
await view.inputAndSubmit('123', '1234', '19');
await view.confirmPwd.checkError(/密码不一致/);
})
test('年龄不满18岁', async () => {
await view.inputAndSubmit('123', '123', '15');
await view.age.checkError(/18岁/);
})
test('存在未填项', async () => {
await view.inputAndSubmit('', '', '19');
await view.pwd.checkError(/输入密码/);
await view.confirmPwd.checkError(/输入密码/);
})
test('输入内容重置', async () => {
await view.input('123', '123', '19');
await view.resetBtn.click();
await expect(view.pwd.target.el).toHaveValue('');
await expect(view.confirmPwd.target.el).toHaveValue('');
await expect(view.age.target.el).toHaveValue('');
})
test('成功输入', async () => {
await view.inputAndSubmit('123', '123', '19');
await view.pwd.checkSuccess();
await view.confirmPwd.checkSuccess();
await view.age.checkSuccess();
})
})
其他测试要点
善用 test.afterEach
销毁遗留数据
有些测试行为是会在系统重产生遗留数据的,对生产环境做回归测试的时候,我们需要这些临时数据被及时清除。test.afterEach
钩子就适合做这样的操作。受限于条件,下面的例子是伪代码说明。
import { test, expect } from '@playwright/test';
test.describe('测试表单', () => {
let view: FormView
let formId: number | null = null;
test.beforeEach(async ({ page }) => {
view = new FormView(page);
await view.visit();
formId = null;
});
test('表单提交A', async () => {
await view.input({
title: '111',
content: '222',
tags: ['A', 'B']
})
// 提交表单成功时会生成新的 id,将其保存下来
formId = await view.submit();
})
test('表单提交B', async () => {
await view.input({
title: '222',
content: '444',
desc: '666'
})
formId = await view.submit();
})
test.afterEach(async ({ request }) => {
if (formId) {
// formId 不为空代表新建了测试数据,调用删除接口及时删除新产生的数据
const res = await request.post(`/delete/${formId}`);
}
})
})
page.goto
与 load
事件
这里提及一个在实际测试中容易遇到的问题。大家可能注意到,我在使用 page.goto
方法时,总是使用 waitUntil: 'domcontentloaded'
的选项,这是因为默认情况下,goto
方法通过页面的 load
事件判定访问成功,而 load
事件的触发是要求页面中所有资源加载完成的,而 domcontentloaded
只要求 HTML 文档完成解析。
很多时候自动化测试是在 CI 执行机上运行,CI 执行机对网页中的某些资源存在网络不通的问题时,就会导致 load
事件迟迟不触发,最终测试超时失败。大家在测试的时候需要注意这个细节,根据实际情况选择给 page.goto
方法加上 waitUntil: 'domcontentloaded'
,还是致力于去解决执行机的网络问题。
更多可能性
Playwright
的使用潜力远不止于此。我们的测试程序可以实现更高度的自动化,官方为其接入 CI 系统提供了充分的支持。另外,由于 Playwright
基于浏览器运行,其模拟用户行为的特点,也非常适合用来做爬虫程序。
持续集成
自动化测试的最终归属还是要与 CI / CD 系统集成,实现完全的自动化:回归测试自动化、报告归档自动化。
Docker 这篇官方文档介绍了如何在容器中执行 Playwright
测试,并且提供了预装好所有测试依赖的容器。
持续集成 这篇官方文档介绍了如何使用 Github Action
以及其他常见的 CI 系统,自动执行 Playwright
测试任务。
由于我的工作环境中,代码托管和 CI 系统都是公司内特有的,我参考这些文档专门定制了一套落地方案,因为不具有普适性,所以就不做详细说明了,这里只是描述一下大体思路:
- 前端代码仓每次合并代码后,触发流水线执行对测试环境的测试。
- 每日定时执行测试流水线进行回归测试。
- 测试流水线执行失败会给相关人员推送告警消息。
- 测试流水线无论成功失败都自动将报告部署到一个静态文件服务中。
- 在一个
VitePress
搭建的文档代码仓中,定时执行归档测试报告的流水线(获取静态文件中部署的报告,自动生成 md 文档并更新)。
爬虫工具
Playwright
应该是现代爬虫工具的不二选择:
- 模拟用户行为访问目标网站。目标网站即使不是服务端渲染,内容通过
Vue
等现代前端框架动态生成,也能顺利读取到网页内容。 - Locate.evaluate 方法可以获取 Dom 节点的信息。
- Page.waitForResponse 方法可以拦截页面中网络请求,并获取数据。
总结
在 Playwright
介绍的下半部分,我从如何写测试用例的角度分享了使用心得,希望能够对大家有所帮助。
回过头来看前面的内容,可以发现即使有了 Playwright
这样优秀的工具,UI 自动化测试的痛点还是成本高昂,使用自动生成的代码就难以顾及测试的可靠性,而设计可靠的测试代码要求测试工程师对前端也有相当多的了解。让前端工程师来写测试用例吗?可能在当下的环境,大部分前端工程师不认为这是一个投入产出比足够高的方向。
除了经验的分享,我在这里也抛出了自己的困惑,欢迎在自动化测试方面更有经验的朋友能指出我的不足,帮助我打开新的思路。