前端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中添加等待时间,或者多运行两次,如果都失败则为失败。