平常咱们写完代码都会写单测来测验代码的可靠性,可是单测只能测验某个函数或许某个组件的可靠性,可是当悉数的组件和函数组合成一个网页的时分,这时分就需求QA
来大显身手了,可是QA
也不是万能的,他们平常只测验新功用,关于没有改动的旧功用通常就没有测验了。可是,咱们新改动的代码极有或许影响旧功用,所以,咱们的项目上线之前必须通过一次全面的主动化测验来保证功用完整,一般咱们把这个进程叫做E2E(end to end) —— 端对端测验。
本篇咱们就一个例子来深化一下e2e
的写法。
前置知识
e2e
测验主要功用是模仿用户操作,然后判别页面有没有呈现料想的状况,比较中心的操作我概括为以下几点
- 运用
CSS
挑选器获取页面的一些DOM
- 调用测验结构的才干模仿用户操作,如点击、悬浮、输入
- 调用相似
jest
结构的办法(如expect
)去判别DOM
的状况是否满意预期
由于web
页面的操作非常杂乱,依据以上几点能够演变出非常多的操作,当然也带出非常多问题,下面我会运用e2e
给一个咱们比较熟的网站做测验,一同也会解说写e2e
的一些留意点和技巧。
e2e
纷歧定是那一块功用对应的开发者来写,在大多数情况下是整个项目成熟完后再进行弥补,这样能够防止因项目需求的频频改动而付出很大精力去更新e2e
。
所以咱们彻底有机会为别人的项目弥补e2e case
(实例,下同),当然提早知道项意图原理和DOM
树的结构会更有利于e2e case
的开发。
技术选型
主动化测验东西咱们挑选Playwright
playwright 是由微软公司 2020 年头发布的新一代主动化测验东西,playwright 依据 jest 的 e2e 测验结构,其在 jest 的根底上集成了仅用一个 API 即可主动履行 Chromium、Firefox、WebKit 等干流浏览器主动化操作,从而完结快捷化主动化测验。
假如屏幕前的你运用其他东西,也请定心食用,他们的API
迥然不同,基本功用也大致相同。在这儿我把常常用到的api
的url
贴在这儿,方便同学们查询文档。
Page:playwright.dev/docs/api/cl…
ElementHandle:playwright.dev/docs/api/cl…
其他API
都在左面的侧边栏里,也能够点击右上角的Search
直接查找
!!需求留意的是,Playwright
不支撑在MacOS 12
系统上运转(11及以下能够),需自行装置虚拟机
确认需求
本期的“大冤种”网站是 www.baidu.com,我会就百度查找主页来提出一些测验点,能够参阅一下
先说明一下,以下功用需求先登录
- 点击查找框,未输入时,会主动调起前史记载框,前史记载显现正常
- 前史记载显现正常,无堆叠
- 假如前史记载到达4条或以上,右下角有“封闭前史”、“删去前史”、“更多前史”三个按钮
- 点击“封闭前史”,主动弹出查找设置界面,能够封闭前史记载,封闭后再点击查找框就不再显现前史记载
- 点击“删去前史”能够删去悉数前史
- 点击“更多前史”会进入个人中心,在里边能够正常删去前史记载
以上测验需求都是环绕“前史记载”翻开,属于一块功用,能够放在一同测验
准备工作
e2e
能够作为一个独自的项目,所以咱们先初始化一个项目;先新建一个空文件名为e2e-case
,运转如下指令
npm init playwright@latest
然后一路回车
咱们运用Typescript
来写;在tests
文件夹下面放测验用例,后边Playwright
就会去这个文件夹下面找测验用例;GitHub Action workflow
咱们先false
;最终一个是下载browsers
,假如是第一次init
,需求等一会儿下载,browsers
是测验用的浏览器,咱们后边便是用运用Chromium
内核的Chrome
来进行测验。
初始化完后的目录结构如上,在package.json
增加script
"scripts": {
"test": "playwright test"
},
运转npm run test
就会主动履行tests
目录下面一切的case
,咱们平常调试的时分只需求运转指定的case
,咱们在tests
目录下创立一个baidu.spec.ts
(一切的case
文件名习惯运用.spec.ts
作为后缀),再增加指令
"scripts": {
"debug": "playwright test baidu.spec.ts"
},
假如是多层级目录,只需求增加目录层级即可,详情可参阅文档
接下来需求装备一下playwright.config.ts
,咱们这儿改几个参数即可,详情可参阅文档
const config: PlaywrightTestConfig = {
reporter: 'line', // case的运转陈述显现在终端即可
projects: [ // 只需求运用Chrome浏览器
{
name: 'chromium',
use: {
...devices['Desktop Chrome'],
},
},
// {
// name: 'firefox',
// use: {
// ...devices['Desktop Firefox'],
// },
// },
// {
// name: 'webkit',
// use: {
// ...devices['Desktop Safari'],
// },
// },
],
}
在写case
之前请务必手动模仿一遍操作,调查鼠标点击的方位和动画响应时刻。
正式开端
假如有同学在初始化完直接运转case
,那么是看不到正在运转浏览器的,第一步咱们先把浏览器显现出来,方便咱们调查程序的作用和调试
/**
* @file PC主页-查找前史
* @author plutoLam
*/
import { test, Page, chromium } from '@playwright/test';
let page: Page;
let browser: Browser;
test.beforeAll(async () => {
browser = await chromium.launch({
headless: false
});
const context = await browser.newContext();
page = await context.newPage();
});
test('查找前史测验', async () => {
const indexUrl = 'https://www.baidu.com/';
await page.goto(indexUrl);
await page.waitForTimeout(3000);
});
一个.spec.ts
文件下面能够有多个tset
,在一切test
的前面能够调用test.beforeAll
办法,作为一切tset
的做初始化操作。我在beforeAll
里边调起浏览器并获取page
目标,page
目标是最重要的目标之一,里边包含了大部分中心操作。
test
里边的大部分操作都是异步操作,所以运用异步函数,并且下面的异步操作都会回来promise
,都要记住运用await
。
运转npm run debug
,你能看到进去了百度主页,逗留三秒后消失,并且在终端显现通过了一个case
,我运用了page.waitForTimeout
让页面停止了三秒,这也是调试常用的手法之一,测验进程的操作总是在几毫秒之间闪过去,假如要在特定的方位停下来,就能够用这个办法。或许能够运用page.screenshot
在各个部分截图,这样能够看到一些瞬间的动画,并且Playwright
在操作的时分,你也是能够手动干预的,也能够看控制台。
登录
第一步咱们需求先登录,百度在登录后或许会让你进行安全验证,所以需求你自己先登录一遍,之后playwirght
运转的浏览器是无痕浏览器,不会有之前的登录状况。这儿做登录操作仅仅为了演示,假如是自己的项目,也能够直接调用登录接口,登录功用的验证放在其他case
。
登录的思路很简单,先点击右上角的登录按钮,分别点击和输入账号暗码,点击登录按钮
test('查找前史测验', async () => {
const indexUrl = 'https://www.baidu.com/'
await page.goto(indexUrl);
// 获取登录按钮
const loginButton = await page.$('#s-top-loginbtn') as ElementHandle;
// 点击
await loginButton.click();
// 等候登录框呈现
await page.waitForSelector('.tang-body');
// 获取表单
const form = await page.$('.pass-form.pass-form-normal') as ElementHandle;
const useNameInput = await form.$('.pass-text-input-userName') as ElementHandle;
await useNameInput.click();
// 输入账号
await page.keyboard.type('123456');
const pwdInput = await form.$('.pass-text-input-password') as ElementHandle;
await pwdInput.click();
// 输入暗码
await page.keyboard.type('123456');
const sumitButton = await form.$('.pass-form-item-submit') as ElementHandle;
await sumitButton.click();
// 等候页面稳定无网络恳求
await page.waitForLoadState('networkidle')
});
1. 慎用waitForTimeout
page.$()
的参数是一个字符串,传入的css
挑选器与传入querySelector
的相同,得到一个ElementHandle
目标,上面有许多DOM
元素的操作办法,比如说click()
,点击登录按钮后登录框弹出需求等候网络恳求,关于没有动画作用的弹框呈现进程,能够用page.waitForSelector
办法,它会等候DOM
元素被选中后才持续履行。
有同学就说:”那我用waitForTimeout
等个几秒行不行呢?”,能够是能够,可是网络情况是不稳定的,假如你等个一秒,那下次恳求时刻超过一秒了,那就不行了,要是恳求时刻极快,那持续等的时刻也是糟蹋的。
2. 如何获取元素
在网页中或许有许多类名相同的元素,所以尽量运用id
来获取DOM
元素,假如没有id
,那就要保证class
的仅有性,所以我先获取了表单的DOM
,再在form
的根底上去获取里边的input
,由于我发现pass-text-input-password
类名是不仅有的,由于我确认了form
的仅有性,再调用form.$()
获取的DOM元素一定是仅有的,这也是精确获取DOM
元素的一个思路。
3. 等候页面改写
输入暗码后点击“登录”按钮后,页面会改写,涉及到页面改写的等候能够调用page.waitForLoadState
,由于这儿的改写有许多http
恳求,所以挑选'networkidle'
作为参数,作用是等候页面没有网络恳求后500ms后持续履行。假如是页面第一次加载能够运用默许参数'load'
,等候load事情
被触发。
咱们将登录进程抽离出来成一个函数,在根目录新建一个utils/common.ts
文件,调用时将page
实例传进去
// utils/common.ts
export async function login(page: Page) {
// 登录操作
}
// baidu.spec.ts
import { login } from '../utils/common';
test('查找前史测验', async () => {
// ...
await login(page)
});
假如发现登录后需求手机验证,能够用page.waitForTimeout
等久一会验证后完毕本次debug
制造前史记载
假如你登录后点击查找框呈现前史记载,那你平常或许常常用百度查找,可是有的同学的账号是没有前史记载的,为了保证e2e
的通用性,需求保证这个case
在每种情况下都试用;那没有前史记载怎么办?自己查找几条不就有了。当然这儿仅仅为了演示(究竟没有测验查找功用的需求),假如是自己的项目能够运用page.request
来恳求几条数据。
/**
* 创造前史记载
*
* @param {Page} page page目标
* @param {number} count 前史记载条数
*/
export async function createHis(page: Page, count: number) {
for (let i = 0; i < count; i++) {
const searchBox = await page.waitForSelector('#kw') as ElementHandle;
await searchBox.click();
await page.keyboard.type("test");
// 等候http恳求的response
await page.waitForResponse(response => response.url().indexOf('/sugrec') !== -1 && response.status() === 200);
const searchBtn = await page.$('.s_btn_wr');
// 设置点击方位
await searchBtn?.click({
position: {
x: 10,
y: 10
}
});
// 等候恳求完结
await page.waitForResponse(response => response.url().indexOf('pc/pcsearch') !== -1 && response.status() === 200);
}
}
// baidu.spec.ts
await createHis(page, 5);
留意以下几点
- 在循环里边写异步操作时不要运用
Array.forEach
,Array.forEach
操作是同步办法,所以无法直接运用async进行咱们理想中的异步操作,主张运用惯例for循环 - 一定要等候恳求
response
后才干持续操作,感兴趣的同学能够看看waitForResponse
的回调函数的参数response里的办法能够回来些什么,能够自定义一些操作 - 这儿其实不用设置
click
的position
也是能够,这儿是为了提示一下:position
默许是0,0
,也便是左上角,对某些DOM
的边缘进行click
操作或许是没有作用的,假如click
之后没反应就能够试试设置一下position
点击DOM
的中心方位(获取x,y坐标计算中点方位)
通过以上的操作后作用如下
翻开下拉框
create history
完毕之后,咱们这时分是处在查找结果页的,需求goto
到主页
// baidu.spec.ts
await page.goto(indexUrl);
留意这一行不要写在createHis
办法里边,goto
到主页仅仅这个case
的需求,不要耦合到公共办法中。
接下来咱们要点击查找框翻开下拉框,咱们先自己试一遍,一同翻开控制台的Network
能够看到他一同有动画和网络恳求,然后咱们再看看翻开前的DOM(我就叫他bgsug
吧)
翻开后
那咱们就依据网络恳求、style
或许上面说到waitForSelector
的来判别他是否翻开
笔者试过网络恳求和waitForSelector
,且在不同的模仿网速下实验,跑了许多次仍旧有小概率会失利,或许是由于它的那个动画影响了Playwright
的判别,后边想出了一个比较稳妥的判别办法,代码如下
/**
* 翻开his
*
* @param {Page} page page目标
*/
export async function openHis(page: Page) {
const searchBox2 = await page.$('#kw') as ElementHandle;
const box = await searchBox2.boundingBox();
if (box) {
await page.mouse.click(box.x + 10, box.y + 10);
}
// @ts-ignore
for (const item of Array(10)) {
await page.waitForTimeout(500);
const res = await page.evaluate(() => {
const his = document.querySelector('.bdsug') as Element;
return window.getComputedStyle(his);
});
if (res.display === 'block') {
return;
}
}
}
大概意思是每隔一小段时刻就判别一次bgsug
的style
的display
属性是否为block
,假如十次循环完毕后,也便是5000ms后,还没有变为block
的话,就默许前史下拉框不正常显现(下面会判别)。
这儿运用了page.evaluate()
这个办法,这个办法的回调函数里边是浏览器环境,能够调用document
和window
的办法,比如说能够改变scrollTop
去模仿翻滚事情;也能够在里边获取DOM
节点的一些属性。
这儿我没有找到Playwright
支撑相似原生window.getComputedStyle
的办法,所以我调用了page.evaluate()
来利用浏览器才干,在evaluate
的回调函数里return
出去的值会被Promise.resolve
包裹,用await
解包后就能够获得。
验证下拉框
翻开前史下拉框后就需求判别bgsug
是否显现正常
/**
* 检测his是否存在
*
* @param {Page} page page目标
* @return {Boolean} his是否存在,true为存在,false为不存在
*/
export async function checkHis(page: Page) {
const form = await page.$('#form') as ElementHandle;
const his = await form.$('.bdsug') as ElementHandle;
if (!his) return false;
const res = await page.evaluate(() => {
const his = document.querySelector('.bdsug') as Element;
return window.getComputedStyle(his);
});
return res.display === 'block';
}
// baidu.spec.ts
import { ... , expect } from '@playwright/test';
expect(await checkHis(page)).toBe(true);
考虑这个判别是一个公共功用,或许在别的当地也会触发bgsug
弹出,这儿相同抽出一个公共办法,
由于在没有弹出的情况下,bgsug
也是存在于DOM
树中的,这儿能够在控制台运用document.querySelector('.bdsug')
验证一下,是能够选中的。所以不能只判别bgsug
是否存在,还需判别style
的display
属性是否为block
。
能够看到我的expect
是写在case
中的,咱们在写的时分尽量不要把断语写在东西函数与之耦合,由于下一次的业务就有或许和这次的断语纷歧样,下降耦合才干提高东西函数的运用率。
接下来验证一下前史记载是否显现正常、无堆叠。
/**
* 检测his中的query是否对齐
*
* @param {Page} page page目标
*/
export async function checkHisQuery(page: Page) {
const form = await page.$('#form') as ElementHandle;
const his = await form.$('.bdsug') as ElementHandle;
// 监控his的li是否堆叠
// 取出一切的li
const hisList = await his.$$('ul>li') as ElementHandle[];
const liBoundingBox = [];
for (const li of hisList) {
const obj = await li.boundingBox();
liBoundingBox.push(obj?.y);
}
// 去重
const hisListSet = new Set(liBoundingBox);
// 没有重复的y值
return hisListSet.size === liBoundingBox.length;
}
// baidu.spec.ts
expect(await checkHisQuery(page)).toBe(true);
这儿我是运用了ElementHandle
的$$
办法,这个办法就相似document.querySelectorAll
,选出符合挑选器的DOM
数组,我取出每一个DOM
的y
值,没有相同的y
值即没堆叠。
前史记载操作
封闭前史按钮
咱们先测验“封闭前史”按钮
// 封闭前史按钮
const closeHisBtn = await page.$('.setup_storeSug') as ElementHandle;
expect(closeHisBtn).toBeTruthy();
// 封闭前史
await closeHisBtn.click();
// 等候设置框呈现
await page.waitForSelector('.pfpanel-bd');
const closeBtn = await page.$('#sh_2') as ElementHandle;
await closeBtn.click();
await confirmSetting(page);
// 封闭后记住翻开!!
await openHisRecord(page);
// common.ts
/**
* 在查找设置中确认
*
* @param {Page} page page目标
*/
export async function confirmSetting(page: Page) {
const confirm = await page.$('.prefpanelgo') as ElementHandle;
await confirm.click();
await page.keyboard.down('Enter');
// 等候设置框消失
await page.waitForFunction(selector => !document.querySelector(selector), '.pfpanel-bd');
// 等候改写完结
await page.waitForResponse(response => response.url().indexOf('/sugrec?prod=pc_hi') != -1 && response.status() === 200);
}
咱们用断语先验证“封闭前史”按钮显现是否显现正常,点击后等候设置框呈现,点击“封闭”后点击“保存设置”,然后我运用键盘的回车键来确认,最终等候设置框消失和http
恳求的response
回来成功。
假如你是一步一步做的,或许会发现在调试完封闭前史操作后,前面的获取bgsug
报错了,没有成功弹出,由于你封闭了前史但没有翻开呀;所以,为了在测验某个功用前能保证它处于敞开状况,咱们在测验前也要主动给他翻开,这也是保证case
顺畅履行的条件之一。
假如前史记载被关掉,只能从右上角进入设置
/**
* 在查找设置中翻开前史记载
*
* @param {Page} page page目标
*/
export async function openHisRecord(page: Page) {
const setting = await page.$('#s-usersetting-top') as ElementHandle;
await setting.hover();
const searchSetting = await page.$('.setpref') as ElementHandle;
await searchSetting.click();
await page.waitForSelector('.pfpanel-bd');
// 敞开前史
const displayBtn = await page.$('#sh_1') as ElementHandle;
await displayBtn.click();
await confirmSetting(page);
}
// baidu.spec.ts
await login(page)
await openHisRecord(page);
await createHis(page, 5);
在最开端进来的时分先跑一下openHisRecord
函数,然后封闭前史后要记住翻开!
封闭前史后判别一下bdsug
是否打不开,在这之前先改造一下openHis
函数,由于在封闭前史记载后就获取不到bgsug
了,而将null
传入window.getComputedStyle
会报错。
export async function openHis(page: Page) {
// ...
// @ts-ignore
for (const item of Array(5)) {
await page.waitForTimeout(500);
const res = await page.evaluate(() => {
const his: Element | null = document.querySelector('.bdsug');
if (his === null) {
return -1;
}
return window.getComputedStyle(his);
});
if (res === -1 || res.display === 'block') {
return;
}
}
}
更多前史按钮
假如咱们先测验“删去前史”按钮,那后边就没前史记载了,所以先测验“更多前史按钮”,有时分自己调整测验的次序也是一个技巧。
// 点击更多前史去个人中心
await openHis(page);
// 更多前史按钮
const moreHisBtn = await page.$('.more_storeSug') as ElementHandle;
expect(moreHisBtn).toBeTruthy();
// 不能跳多页面,goto到href
const href = await moreHisBtn.getAttribute('href') ?? '';
expect(href).toBeTruthy();
await page.goto(href);
await page.waitForLoadState();
// 拿到今天最终一条记载
const historyBox = await page.$('div[class^="history-box"]') as ElementHandle;
const historyBoxLi = await historyBox.$('ul>li') as ElementHandle;
// 删去记载
await historyBoxLi.hover();
const deleteBtn = await historyBoxLi.$('i') as ElementHandle;
await deleteBtn.click();
await page.waitForResponse(response => response.url().indexOf('/data/usrdelete') !== -1 && response.status() === 200);
// 回到主页
await page.goto(indexUrl);
await openHis(page);
笔者为了不多开一个标签页,获取了a
标签上的href
属性,直接goto
到了个人主页,假如按照咱们平常的操作,是多开一个标签页后封闭这个标签页或直接切换回前面一个标签页,在这儿笔者仍是找不到有什么可行的办法,笔者试过await page.keyboard.press('Control+PageUp')
试图去触发浏览器的快捷键,好像也不行,假如咱们有什么办法能够完结这个需求的话能够在评论区留言。
这儿我拿到了最顶部的一条记载将其删去,假如收到http恳求回来成功则表明将其删去。这儿知道我前面为什么要create
五条记载了吗,便是为了在这儿删去一条,然后剩下四条持续后边的测验。
能够看到我用div[class^="history-box]
挑选器来挑选那个DOM
,有时分咱们的style scope
会把类名拼上哈希值,就能够挑选这种挑选器。
删去前史按钮
// 删去前史按钮
const deleteHisBtn = await page.$('.del_all_storeSug') as ElementHandle;
expect(deleteHisBtn).toBeTruthy();
await deleteHisBtn.click();
await page.waitForResponse(response => response.url().indexOf('data/usrclear') !== -1 && response.status() === 200);
这样就完结最终一个任务了。
封闭浏览器
在测验完毕后手动封闭浏览器,防止其他过错
test(...)
test.afterAll(async () => {
await browser.close();
});
最终附上整个进程的gif
接入工作流
咱们写e2e的意图便是当其他项目push到库房时主动运转,由于篇幅所限,这部分后边再给小伙伴们呈现。
总结
尽管本次测验的功用没那么杂乱,可是写起e2e真的是很麻烦,要考虑到许多细节,小伙伴们在开发时一定要翻开浏览器调查,笔者之前由于某些原因在没有浏览器调查的情况下开发,许多时分出了问题都找不到原因在哪里。主张同学们在写的时分一步一步来,这样方便调试,削减犯错。
代码已上传github:github.com/plutoLam/e2…
期望这篇文章对屏幕前的你有帮助,原创不易,欢迎点赞、保藏、转发、关注~~!