前端E2E自动化测试方案
04/17/2024
背景
随着公司产品线的增加,主力产品分配到的测试越来越少,他们做回归测试的压力也越来越大,甚至都出现过几次 P0 bug。
技术选型
在了解了市面上各个E2E的前端库之后,最后选择了微软的playwright。
why
虽然各种E2E的库最后生成的case文件 *.spec.js 都差不多,playwright自身还支持无头浏览器录制,及可视化查看case运行情况。
当然,在具体实践中,肯定是不能让测试同学也敲命令行打开playwright的无头浏览器录制case,也还是需要我们有相应的插件直接在浏览器上录制case。
主要目标
- 系统流程的完整性
- 系统数据的准确性
- 业务组件在不同参数、输入、操作下的标准化输出
前置工作-testid
录制case最首先遇到的问题就是,我们项目中使用了CSS-Module。这导致了我们项目在每次build时所有的className都会发生变化,这也是之前测试同学尝试录制e2e用例时遇到的最大的问题。
在playwright文档中,他们推荐使用在元素属性里的data-testid或testid作为元素的唯一标识。
这次既然E2E自动化测试平台项目是前端主动发起的,那给项目里所有元素都添加data-testid也是我们义不容辞的工作了。
testid规范
在项目中,添加testid的过程是一个递归过程。
具体来说就是先在page层定义一个常量,pageTestid。
给遇到的每个子component都传入 testid={`${pageTestid}--childComponent`},
然后再去子component的文件中,继续给子子component添加testid,就像testid={testid && `${testid}--grandchildComponent`},
抑或是一些最后的操作元素,比如有click事件的元素、button元素、input元素等,就要加上data-testid={testid && `${testid}--action-btn`}
一层层文件递归完成,就表示这个page下的testid添加完成了。
如果添加过程中,有遇到循环遍历渲染的部分,要记住,千万不要把数据的id等加到testid中,而是要把index放进去。
示例如下
// myPage.tsx
const pageTestid = 'my-page'
export function MyPage() {
return <div>
<ChildComponent testid={`${pageTestid}--child-component`} />
<input name="value" data-testid={`${pageTestid}--value=input`} />
</duv>
}// child-component.tsx
export function ChildComonent({testid}) {
const dataList = [{id: 357, value: 1}, {id: 958, value: 1}]
return <div>
<GrandchildComponent testid={testid && `${testid}--grand-child-component`} />
{
dataList.map(({id}, index) => (
<Button
data-testid={testid && `${testid}--data-btn-${index}`}
key={id}
>{id}</Button>
))
}
</div>
}组件库添加testid
组件库中添加testid是一个比较困难的事情,你没有办法直接去修改组件库的源代码以便加入data-testid。
对于antd来说,其组件本身是支持data-testid这个属性的,但是也是支持有限,很多都只是在外层简单的加上了data-testid,其内部元素其实根本没有关注到。
当然,对于其中的Button、Input等组件,其支持的data-testid已经够用了。
对于select、dropdown等,可以给每个具体渲染文本改写成** <span data-testid>{text}</span> **
对于我司来说,组件库是基于antd的二次封装,我们在一些必要的组件里加入了强制递归写入data-testid的逻辑。
代码类似如下
function addUiTestId (className, testid, flag?){
if (testid) {
const elements: any = document.getElementsByClassName(className) || [];
Array.from(elements)?.forEach((el, i) => {
setAttributeRecursively(
el,
'data-testid',
generateId,
!flag ? `${testid}--${className}` : className, // flag 为true,表示className已经包括testid了
0,
`${i}`,
);
});
}
};
function setAttributeRecursively(
element,
attrName,
attrValueGenerator,
testid,
depth = 0,
index = '',
) {
// 为当前元素设置属性,attrValueGenerator是一个函数,用于生成属性值
if (shouldAddTestid(element, attrName)) {
element.setAttribute(attrName, attrValueGenerator(testid, depth, index));
}
// 遍历所有子元素并递归调用此函数
Array.from(element.children).forEach((child, i) => {
setAttributeRecursively(
child,
attrName,
attrValueGenerator,
testid,
depth + 1,
`${index}-${i}`,
);
});
}
export function Component ({testid}) {
useEffect({
setTimeout(() => {
addUiTestId(`ui-component--${testid}`, testid, true);
}, 400)
}, [])
useEffect({
if (open) {
setTimeout(() => {
addUiTestId(`ui-component--${testid}--dropdown`, testid, true);
}, 400)
}
}, [open])
return <div className={testid && `ui-component--${testid}`}>
<Select dropdownClassName={testid && `ui-component--${testid}--dropdown`} />
...
</div>
}小结
testid 最后在页面上其实可以表达为 PageTestid--child-component--ui-component--test-id。
其中可以从双-中找到具体是哪个组件文件。
添加testid的工作其实很快,给一个成熟的项目添加完备的testid,大概要占用一个同学一个月?的时间,至少在我司是这样。
录制case
最开始的想法,是让测试同学电脑上也安装上playwright,运行命令行进行录制。
之后进行了几番思考,以及后续的功能迭代,还是决定自行开发录制case插件。
插件嘛,当然是选择使用chrome extension插件咯。
工作流
预期中插件的工作流程应该是与playwright相差不大的,比如:
- 输入命令行启动playwright的浏览器 和 点击插件的录制按钮
- 在playwright浏览器上hover到元素会有高亮,显示元素信息 和 录制过程中在chrome浏览器中也有高亮等
- 点击之后playwright会记录点击元素 和 like that
- input输入之后会记录 和 like that
- 生成完整的js文件 和 like that
由上述可以得出来,在录制过程中,或者说是录制开始阶段,是需要在页面上插入一系列js的。
并且,为了能够持续、直观的看到之前的操作记录,插件应该是保持一致打开的,或者在页面上有一个弹窗展示操作记录。
最后还要有根据操作记录生成完整的js文件。
插件
由上面工作流可以看到,插件的主体部分,只承载了插入页面js、插入弹窗的功能。当然,插件部分,也会有更多的功能,之后再讲。
为了功能的好实现,这边首先引入一个js文件,承载插件与页面之间的通信。这个js文件在插件运行时就直接插入进去。
// background.js
// 插件启动,监听tabs激活就直接插入进去
chrome.tabs.onActivated.addListener(async function (activeInfo) {
chrome.tabs.get(activeInfo.tabId, function (tab) {
if (!tab.url.startsWith("chrome://")) {
chrome.scripting.executeScript({
target: { tabId: activeInfo.tabId },
files: ["content.js"],
});
}
});
});
//
// 负责与content.js通信
chrome.runtime.onMessage.addListener(function (message, sender, sendResponse) {
if (message.action === 'xxx') {}
})而在content.js中,就负责在页面插入js、弹窗,以及和插件进行通信。这边的content.js是直接运行在页面中,而非插件中。
chrome.runtime.onMessage.addListener(function (request, sender, sendResponse) {
const { tag, action } = request;
const ul = window.location.host;
switch (action) {
case 'startRecord':
const element = document.getElementById('e2e-testing-extend-container');
if (element) {
return;
}
appendModal();
addResScript();
break;
}
})
function appendModal() {
const pickerDiv = document.createElement("div");
pickerDiv.id = "e2e-testing-extend-container";
document.body.appendChild(pickerDiv);
}
function addResScript() {
let script = document.createElement('script');
script.setAttribute('defer', 'defer');
script.src = chrome.runtime.getURL('embedded/embedded_main.js');
document.body.appendChild(script);
}操作捕捉
从上面就可以看到,其实所有复杂的逻辑都放在了embedded.js里面了。
embedded.js是一个react项目的压缩文件。这里先讲操作捕捉。
在操作开始录制之后,会向document上面绑定一系列的事件。
const addEventListenerToDom = () => {
document.addEventListener('click', handleClickEvent, true);
document.addEventListener('mouseover', handleMouseOverEvent);
document.addEventListener('mouseout', handleMouseOutEvent);
};从这里可以看出,绑定事件是选择绑定在document上,而不是每个元素,主要是因为冒泡事件会被各种吃掉。
handleMouseOverEvent和handleMouseOutEvent 控制hover的元素高亮以及展示该元素的信息,handleClickEvent负责添加点击事件。
首先先看handleMouseOverEvent这个方法。
这个方法会 setCurrentTarget 为 event.target, 当currentTarget发生变化时,为currentTarget这个元素添加背景色、边框,并有tooltip展示这个元素的testid,id 或是className。
const lastTargetRef = useRef(null)
const lastTargetStyle = useRef(null)
function handleMouseOverEvent(event) {
let targetElement: any = event.target;
// 对于一些特殊的元素进行过滤
if (checkShouldExcludeNode(targetElement)) {
return;
}
const nodeInfo = getElement(targetElement, true);
const {
son: {type},
} = nodeInfo;
if (type === undefined) {
return;
}
setCurrentTarget(targetElement)
}
useEffect(() => {
if (lastTargetRef.current) {
lastTargetRef.current.style = lastTargetStyle.current
}
lastTargetRef.current = currentTarget, lastTargetRef.current.style = currentTarget.style;
const txt = getFindTxt(nodeInfo),
rect = targetElement.getBoundingClientRect();
currentTarget.style = {...}
globalTooltip.innerText = txt;
globalTooltip.style.top = rect.top - 10 // something like that
globalTooltip.style.left = rect.left - 10 // something like that
}, [currentTarget])此外还有一个快捷键监听事件 cmd+u,当触发这个事件时,会 setCurrentTarget 为 currentTarget 的父节点。如此就可以避免hover到一些icon,或是单纯的没有testid的文字,以便能找到最近的testid元素。
然后再看handleMouseOutEvent方法。
这个方法就是很单纯的将currentTarget设置为空。nothing else。
最后再看handleClickEvent方法。
首先,会去阻止当前的捕获事件的进一步传播,stopPropagation、preventDefault和stopImmediatePropagation等。
然后判断这个事件的target是否为currentTarget或者是currentTarget包含的子元素。是的话用currentTarget进行下去,否则用target。下面都用target进行表示。
target如果是一个input类元素,那就去监听其blur事件,如若不然,则记录这个target的点击,并且调用这个target元素的click方法。
input类元素在其blur事件被触发之后,记录这个target被输入的内容。
此外还有hover等其他事件,这种事件怎么去记录,且往下看。
操作记录
embedded.js这个react项目,会向e2e-testing-extend-container这个元素append那个react应用。
这个应用的页面部分就是展示操作记录及编辑操作记录啦。
一般情况下,记录的事件都是点击事件,或者输入事件。在这里,就可以将这些事件修改为 hover。
此外,还可以添加一些其他的步骤,比如说,
- 等待x秒钟
- expect - 添加预期方法(直接修改点击事件为预期的话,则是预期存在那个元素的innerText)
- element 等待元素出现
- pageData - 添加预期方法,调用项目预置的getPageData方法,填入其中
- buttonAvailable - 等待button可用
- 等等
还有复制、删除步骤等功能。
生成js代码
生成js代码,是根据步骤的类型去生成。
在记录操作时,会记录 类型、元素的唯一标识类型、元素的唯一标识符、元素的innerText等,在这一步,就要根据这些内容去分门别类的生成js。
代码如下
function getClickText(node: ActList) {
const pathStr = nodePathForPlay(node);
return `await page.${pathStr}.click();\n`;
}
function getHoverText(node: ActList) {
const pathStr = nodePathForPlay(node);
return `await page.${pathStr}.hover();\n`;
}
function getFillText(node: ActList) {
const {fillTxt} = node;
const pathStr = nodePathForPlay(node);
return `await page.${pathStr}.fill('${fillTxt}');\n`;
}
function getTimeText(time = '1') {
const num = parseInt(time, 10) * 1000;
return `await page.waitForTimeout(${num});\n`;
}
function getWaitElementText(node: ActList) {
const pathStr = nodePath(node);
return `await page.waitForSelector('${pathStr}');\n`;
}
function getWaitBtnText(node: ActList) {
const pathStr = nodePath(node);
return `await page.waitForSelector('${pathStr}[data-active="true"]');\n`;
}
function getPageResult() {
return `const v1 = await page.evaluate(() => {
return window.__getPageResult();
});\n`;
}如此一来,一个case就完成了。
提交case
提交case,这边建议是通过sendMessage和chrome.runtime.onMessage把case内容传递给background.js层,也就是插件层,让插件去进行提交。
这样做的好处主要就是为了避免跨域。
还有一个要考虑的是,我们就仅提交case的js代码吗?
我在具体实践中,是将case的js代码和操作步骤一同进行提交。
保留操作步骤,意味着以后用其他的测试框架,也能很轻松的转换成其他框架的js代码格式。
测试平台
第三个大的方面就是测试平台。
测试平台大概包括这几个方面。
- case的管理
- 版本的管理
- 常量的管理
- 运行case
- 测试结果
- 定时任务与即时任务
- 与gitlab等工作流挂钩
- 消息推送提醒
case与版本管理
保存case一般有两种方式,一种是存在git项目里,一种是存在数据库中。
不管哪种方式,case都会有固定的路径/模块路径+case名这些信息。
比如说 A大模块/b小模块/xxxxxxx功能.spec.js
如此,也便于在平台前端进行展示,也方便测试、开发同学去划分case。
版本也是一个绕不开的问题。这个在具体实践中也会出一些差错。
在我们的实现中,升级版本之后,低版本的会默认同步到高版本,而高版本则不会同步到低版本。
然后再从case运行的结果,再去判断哪些case已经被废弃,哪些case需要重新录制。
常量管理
在提交case时,有忽略一些地方:登陆和路由。
这部分,其实就是与常量相关。不同版本对应不同的路由,也对应不同的登录信息。
这些都会在case运行时,给填入相应的常量值。
一般来说,常量会分为
- 版本号对应的常量
- 版本号,角色,权限对应的常量
- 版本号,case单独设置常量
- 带有特殊id的路由的常量
- 等等
case运行
playwright官方提供了可以在服务器上运行的包,可以直接写在dockerfile里面使用。nodejs版本要求至少18.
playwright默认运行三次,分别在chrome、firefox、safari上运行,当然可以设置成只运行在chrome。
在具体实践中,很容易就会遇到元素找不到,分析下来大概是页面还没有加载完成。虽然playwright默认会等待30秒还是3分钟。这时,我们可以做的就是,在case中添加等待时间,或者多运行两次,如果都失败则为失败。