前端ui自动化测试sdk封装

背景

前端业务场景中每次功能发布都会面临着相应的ui功能测试,因为前端业务的功能迭代之间往往存在显性或者隐性的关联性,每次上线某个功能迭代后,严格意义上也需要对整体功能进行回归,因此单靠人力的手工测试需要花费较多的时间和精力在功能回归上,且容易漏掉一些细节问题。
基于业务中的上述现状,我们尝试引入ui自动化测试来解决测试中的“重复回归”问题,基于 puppeteer 和 jest 两大开源工具,封装了一款UI自动化测试sdk,适用于以下两个常见业务场景:

  • 稳定的老业务,功能不经常迭代,通过自动化测试完成每次发布的测试
  • 正在快速迭代中业务中的核心流程,通过自动化测试保证每次发布后核心流程的功能正常

功能说明

  1. 通过sdk和配置文件,自动完成ui自动化测试流程
  2. 支持浏览器实时复现整个测试过程
  3. 支持pc和h5不同终端的测试
  4. 自动生成测试流程中的功能页面截图,用户自行通过截图进行测试结果判定
  5. 支持预设页面功能的比对图片,自动完成页面截图和预设比对图片的比对,根据比对差异进行测试结果判定
  6. 自动输出最终的测试报告
  7. 支持全局接口的监控,支持自定义接口测试
  8. 同时支持es和cjs的输出,兼容import和require的导入
  9. 测试过程中命令行终端中实时输出测试进展

使用方式

第一步:前端工程中引入

通过npm的方式,安装好sdk

npm i xxx
// 这里需要注意的是,一定不要用原始的npm镜像源,因为包里面的chromium的源地址在国外,下载会失败,可以用cnpm、taobao镜像源等代理镜像源

第二步:在工程中创建测试入口文件,推荐的文件目录如下

工程根目录
    |
    +---uiTest
        |       
        +---origin // 如果需要图片比对,存放原始的比对图片的目录
        |       xxx.png
        |       xxx.png
        |       
        +---result // 存放测试过程中截图和最终图片比对结果图的目录
        |       xxx.png
        |       xxx.png
        |
        +---test.js // 测试入口文件

第三步:在测试入口文件 test.js 中接入sdk

const UITestPlayer = require('xxx');
const myUITestPlayer = new UITestPlayer({
  headless: false,
  fullScreen: true
});
myUITestPlayer.run(runConfig);

若使用 import 方式,则需要保证当前工程的 package.json 中具有 type:module 字段,或者创建的入口文件的后缀是.mjs(test.mjs)

import UITestPlayer from 'xxx';
const myUITestPlayer = new UITestPlayer({
  headless: false,
});
myUITestPlayer.run(runConfig);

第四步:运行测试文件

在当前工程下的命令行中执行下述指令,即可等待自动化测试运行

node uiTest/test.js

更好的做法是在当前工程的 package.json 中的 scripts 字段中配置如下命令:

{
  "scripts": {
    "uiTest": "node uiTest/test.js"
  }
}

配置完之后,在当前工程下的命令行中执行下述指令即可

npm run uiTest

配置说明

初始化options配置说明

{
  chromiumPath?: string; // chromiumPath浏览器文件的存放目录, 如果需要使用本地的chromium
  headless?: boolean; // 是否是无浏览器模式,默认true
  ignoreHTTPSErrors?: boolean; //是否忽略https的报错,默认true
  fullScreen?: boolean; // 当headless为false时,打开的浏览器是否全屏,优先级高于width和height配置
  width?: number; // 当headless为false时,打开的浏览器的width, 默认800
  height?: number; // 当headless为false时,打开的浏览器的height, 默认600
}

runConfig配置文件说明

{
  title?: 'ui自动化测试' // 生成测试报告的标题
  url: 'http://localhost:7002/', // 测试的页面地址
  screenshotPath?: 'uiTest/result', // 截图存放的路径,默认会创建uiTest目录
  expectedMismatch?: 1000; // 截图对比可接受的像素差,默认1000
  pageLoadTest?: { // 首页测试
    value?: 'xxxx', // 比对图片的url, 如果配置了会做截图比对,如果为空只会截图
    trigger?: { // 截图触发时机,默认 time 2000ms
      mode: 'time', // 截图触发的方式,目前有'time'和'dom'两种方式
      value: 5000 // 数字对应time,字符串对应dom
    }
  },
    process: [{ // 测试流程配置
        title?: 'top视图功能', // 测试功能名称
        step: [ // 测试的具体步骤,如果只需要一次操作则配置单个对象即可,否则按顺序配置多个对象
      {
        eventType: 'click', // click: 点击 | hover: 鼠标悬浮 | goTo: 页面跳转 | reload: 刷新页面 | focus: 聚焦页面元素 | keydown: 键盘按键按下 | keyup: 键盘按键抬起 等等浏览器事件
        eventTarget: '.minimap-container',
        eventOption?: {}; // 事件参数
            test?: {
              value?: 'xxxx', // 比对图片url, 配置了会做截图比对,为空只会截图
              trigger?: { // 截图触发时机,默认 time 2000ms
                  mode: 'time', // 触发的方式,目前有'time'和'dom'两种方式
                  value: 5000 // 数字对应time,字符串对应dom
                },
          skipScreenshot?: false; // 是否跳过截屏,默认false
        },
      }
    ],
  }]
}

注意点

初始化options
  1. 默认不开启浏览器运行效果,在控制台会实时输出测试过程中的关键信息,可以通过 headless: true 进行开启
  2. width和height是运行浏览器的尺寸,因此过程中的测试截图也是这个尺寸
runConfig
  1. 最终会调用jest自动生成测试报告,title的配置就是用于最终的测试报告
  2. 预设的比对图片的宽高必须和初始化 options 中的宽高尺寸(默认800*600) 保持同比例,否则缩放后会有压缩或拉伸,会影响比对结果
  3. expectedMismatch 是测试截图和预设比对截图的对比的像素差,完全一致的情况下最初对比的结果是0,可以根据实际要求的精确度进行数值调整
  4. process用于配置整体需要测试的流程,测试运行时会按照数组对象的顺序执行,里面的每个对象都是一个单独的测试用例。每个测试用例里用step字段配置具体的测试操作,大多数情况下可以采用单个操作来配置,比如点击某个按钮,等待响应后,自动进行截图,构成了一个step。但是如果是有多个连贯操作构成的测试操作,比如先跳转到某个页面,再进行点击,则在step中就要进行跳转和点击两个先后操作的配置。
  5. 测试结果判断,如果没有配置比对图片,则默认每个测试用例都是通过的。如果配置了比对图片,则只有测试截图和比对图片的差异小于设定的 expectedMismatch 值,才判断测试通过。如果一个测试用例的step包含多个对象的图片比对,则需要满足所有图片比对符合要求,才判断测试通过。
  6. skipScreenshot一般用于step中连续操作中的不重要步骤的跳过截图操作,比如先跳转到某个页面,再进行点击,跳转到某个页面后到截图不是关注的重点,点击的效果才是重点。这时候就可以在这个跳转的操作对象中配置跳过截图。

方案设计

了解了功能和用法之后,下面具体说说功能中的一些具体设计思路和实现方案

设计思路

ui自动化测试的是否真的需要,跟具体的前端业务有十分密切的关联,这一点在文章开头就说了。根据自己的开发经验来看,如果太过繁琐的测试操作(比如全部手写测试用例),往往会有点鸡肋的感觉,所以sdk的设计思路就是做出一款比较简单灵活的测试工具。测试的结果判断可以是全自动化的(配置了比对图片,通过图片比对来给出测试结果),也可以是半自动化的(不配置比对图片,只让sdk做默认的测试截图,最后人工查阅这些截图做出测试结果的判断)。实际使用下来之后,一个比较好的实践是第一次测试半自动化,自动生成测试截图,将符合预期的图片作为后续测试的比对图片,配置好比对图片后,后续在没有ui调整的情况下都进行自动化测试。

核心能力的选型

前端的ui自动化测试需要用到浏览器的能力,前期的技术调研主要考虑的就是 selenium 和 puppeteer,对比后的大致结论是 selenium 的能力更强,puppeteer的使用更友好。基于轻量化的定位,最终选取了puppeteer作为核心框架。如果要做更强的测试能力(如支持多终端,测试浏览器兼容性)那么可能 selenium会更适合。此外,在这个过程中,还考虑过另外一种ui自动化测试的形式,即录屏记录测试人对页面的操作流程,自动化生成测试的脚本。这种方案其实感觉更好,例如sahi pro就一定程度上支持,但是自己走下来遇到的问题比较多,偏离了"轻量化"的定位。
至于测试脚本的的技术选型,这个因为jest和mocha都比较成熟,之前用得都比较熟悉。所以两者其实都是可以的,最终随机选择了jest。

puppeteer

在这里不说得太多,可以去 puppeteer中文官网 以及很多资料上具体看。关键就在于puppeteer提供了一套页面操作相关的api,能够唤起一个chrome浏览器,并且模拟出常见的用户操作,比如鼠标事件、键盘事件、页面跳转等,这就让我们可以通过代码模拟出用户对页面的常见操作,构成了整个自动化测试的主体流程。此外利用puppeteer提供的请求拦截的能力,sdk封装后就能做到对页面接口的相关测试。

整体逻辑

初始化

sdk中封装了一个类,在这个类接受初始化参数并进行参数处理,然后就会初始化一个 puppeteer 的实例,启动一个全局浏览器,并根据参数设定启动的浏览器的尺寸、是否是h5的页面。

  private async _init() {
    this.log('process', '程序初始化中...');
    const browserOptions = this.browserOptionsCheck(this.option);
    this.browser = await Puppeteer.launch(browserOptions);
    this.page = await this.browser.newPage();
    await this.runOnH5(this.option?.h5, IPHONE6);
    this.log('process', '程序初始化结束');
  }

执行测试操作

初始化完成后,当run方法被调用,就会进入测试操作,首先也会对传入的 runConfig 进行一系列的参数处理和合并。紧接着就开始执行首页测试的逻辑,操作浏览器跳转到首页,进行首页的截图操作,如果当前工程中不存在存放截图的目录,在这里也会将目录创建好。

  private async _pageLoadTest() {
    if (this.isClose()) return;
    this.log('process', '开始运行首页测试...');
    const { value, trigger } = this.playConfig.pageLoadTest;
    await this.pageGoto(this.playConfig.url);
    await this.waitForByTrigger(trigger);
    this.mkdirSync(this.playConfig.screenshotPath);
    this.log('process', '首页截图中...');
    const imgPath = `${this.playConfig.screenshotPath}/screenshot_home_page.png`;
    try {
      await this.page.screenshot({
        path: imgPath,
      });
      this.log('success', `${imgPath} 截图成功!`);
    } catch (error) {
      this.log('error', `${imgPath} 截图失败`);
    }
    this.log('process', '首页测试结束');
  }

接下来就会进行process中配置的测试操作,其实这个测试的逻辑跟pageLoadTest的首页测试基本上相同。为什么会分成两个呢,是因为考虑到刚进入首页是一个比较特殊的节点,希望把这个页面进行截图保存下来,不管用户有没有配置pageLoadTest都会做这么一个操作。而process则是完全交给了使用者去配置,根据配置的结果进行相应的的测试操作。process的测试代码跟pageLoadTest相比多了一些遍历的操作和根据参数不同调用不同的页面操作api。

 const process = this.playConfig.process;
    for (let i = 0; i < process.length; i++) {
      const processItem = process[i];
      for (let j = 0; j < processItem.step.length; j++) {
        const stepItem = processItem.step[j];
        const { eventType, eventTarget, eventOption, test } = stepItem;

        switch (eventType) {
          case processEventType.selectorclick: {
            await this.page.click(eventTarget, eventOption);
            break;
          }
          case processEventType.hover: {
            await this.page.hover(eventTarget);
            break;
          }
           case processEventType.goto: {
            await this.page.goto(eventTarget, eventOption);
            break;
          }
    ......

     const { trigger, skipScreenshot } = test;
        await this.waitForByTrigger(trigger);
        const imgPath = `${this.playConfig.screenshotPath}/screenshot_${processItem.name}_${j}.png`;
        if (!skipScreenshot) {
          try {
            await this.page.screenshot({
              path: imgPath,
            });
            this.log('success', `${imgPath} 截图成功!`);
          } catch (error) {
            this.log('error', `${imgPath} 截图失败`);
          }
    ......

    this.log('process', 'process运行结束');

jest进行图片比对

等到测试流程都执行完成后,这时候已经在相应的目录下生成了页面截图的图片。接下来就进入了最后一步:调用jest进行图片对比,生成最终的测试报告。这个过程其实可以分为两个步骤:

  1. 启动jest测试脚本
  2. 在测试脚本中进行图片比对
启动脚本

在这一步遇到了很多坑,首先要考虑到jest匹配测试脚本的路径问题,因为最终这个sdk是在业务工程中去使用的,当执行自动化测试时,所在的目录是在业务工程的根目录下,而此时sdk是在业务工程的node_modules下。因此在jest的配置文件中,需要把rootDir设置为当前文件目录,否则默认就是被执行时的目录,即业务工程的根目录,那么就找不到sdk中的index.test.js这个测试脚本了。

  rootDir: path.resolve(__dirname, '.'),

这时候又引发了第二个问题,“__dirname” 是在node环境下的存在的变量,在sdk中是获取不到值的,因此需要通过另一种方式去获取当前文件的目录,即:

import * as url from 'url';
const __dirname = url.fileURLToPath(new URL('.', import.meta.url));

解决了当前路径的问题后,接下来就要执行jest脚本,并把图片比对需要的配置参数传入脚本中。在这里遇到了第三个问题,就是index.test.js匹配不到的问题,试了很多种方式,配合jest配置中 rootDir、testMatch 和 testPathIgnorePatterns 字段进行了多次的测试。根据配置说明理论上觉得应该已经可以了,但是结果一直有问题。经过反复尝试,最终去掉了testMatch(同理testRegex)字段,并用 --runTestsByPath 指定文件路径,终于访问到了。。。这一步花费了很多时间,解决之后泪流满面,至于为什么前面的多次尝试都失败了,可能得从jest源码中找答案了。。。

 await execa(
        'npx',
        ['jest', '--runTestsByPath', __dirname + 'index.test.js', '--config', JSON.stringify(configFinal)],
        {
          stdio: 'inherit',
        }
      );

配置中的几个重要参数也提一下:

  1. reporters字段指定了生成报告的工具包和具体的一些配置,jest-html-reporters会根据测试结果生成一份简单的测试报告
  2. globals 参数是jest提供的可以在测试脚本中全局访问到的字段配置,把runConfig的配置挂载在__DEV__字段下,这样在index.test.js下就能取到了

这部分具体的代码如下:

try {
      const jestConfig = {
        preset: 'ts-jest',
        testEnvironment: 'node',
        transform: {
          '^.+\\.(js|ts|tsx)$': 'ts-jest',
        },
        rootDir: path.resolve(__dirname, '.'),
        testPathIgnorePatterns: ['<rootDir>/node_modules/'],
        reporters: [
          'default',
          [
            'jest-html-reporters',
            {
              pageTitle: this.playConfig.title,
              publicPath: `${reportPath}/uiTestReport`,
              filename: 'UITestReport.html',
              openReport: true,
            },
          ],
        ],
        testTimeout: 1000 * 60 * 10,
      };
      const configFinal = {
        globals: {
          __DEV__: { ...this.playConfig },
        },
        ...jestConfig,
      };
      this.log('process', '开始截图比对');
      this.closeBrowser();
      await execa(
        'npx',
        ['jest', '--runTestsByPath', __dirname + 'index.test.js', '--config', JSON.stringify(configFinal)],
        {
          stdio: 'inherit',
        }
      );
    } catch (error) {
      this.closeBrowser();
      throw new Error('' + error);
    }
    this.log('done', '全部测试运行结束');
  }
图片比对和断言

接下来就进入index.test.js中执行具体的图片比对和测试断言,这部分的逻辑也比较清晰,首页pageLoadTest比对和过程process配置比对,如果没有提供比对图片的,则只要截图存在就通过测试用例;如果提供了比对图片的,调用图片像素比对方法,将测试截图和比对图片进行像素比对,满足要求则通过测试用例,不满足则不通过,同时生成比对后的结果图片。至此整个测试流程结束,等待生成测试报告。以首页比对为例,代码如下:

describe(playConfig.globals.__DEV__.title, () => {
  const { screenshotPath, pageLoadTest, process, expectedMismatch } = playConfig.globals.__DEV__;
  it('首页测试', async () => {
    const originImagePath = pageLoadTest.value;
    const screenShotImagePath = screenshotPath + '/screenshot_home_page.png';
    const diffImagePath = screenshotPath + '/diff_home_page.png';
    if (originImagePath) {
      const diffRes = await diffImage(originImagePath, screenShotImagePath, diffImagePath, expectedMismatch);
      try {
        expect(diffRes).toBeTruthy();
      } catch (error) {
        throw new Error('首页截图对比超过预期像素差');
      }
    } else {
      fs.readFile(screenShotImagePath, (err, data) => {
        try {
          expect(!!data).toBeTruthy();
        } catch (error) {
          throw new Error('读取首页截图失败');
        }
      });
    }
  });

测试信息输出

如果启动sdk的时候用的是无浏览器模式,那么测试者是没有直观感受测试过程的,测试过程中花费的时间也不算短,直到整个自动化测试流程跑完之后才会自动跳出测试报告的页面。这对测试者来说是很突兀的,甚至中途的等待过程中可能就以为出错了。因此必要的测试过程中的信息反馈是很重要的,如果仔细观察上述的一些代码,会发现有this.log的输出,如:

this.log('process', '程序初始化中...');

这是sdk中基于chalk这款工具封装的终端信息输出方法,实际中的使用效果如下:
截屏2023-01-17 下午5.53.07.png
有了这些信息的反馈,给测试者的体验效果就会好很多了

效果展示

最后附上一个实际的测试效果demo(ps.录制好视频demo才发现竟然不支持上传本地视频...希望看到sf早日支持吧)

运行测试

测试截图

测试报告


山外de楼
前端学习的一些心得体会,与大家共勉。

前端小小弄潮儿~

1.5k 声望
36 粉丝
0 条评论
推荐阅读
从零搭建 Node.js 企业级 Web 服务器(零):静态服务
过去 5 年,我前后在菜鸟网络和蚂蚁金服做开发工作,一方面支撑业务团队开发各类业务系统,另一方面在自己的技术团队做基础技术建设。期间借着 Node.js 的锋芒做了不少 Web 系统,有的至今生气蓬勃、有的早已夭折...

乌柏木140阅读 11.9k评论 10

从零搭建 Node.js 企业级 Web 服务器(十五):总结与展望
总结截止到本章 “从零搭建 Node.js 企业级 Web 服务器” 主题共计 16 章内容就更新完毕了,回顾第零章曾写道:搭建一个 Node.js 企业级 Web 服务器并非难事,只是必须做好几个关键事项这几件必须做好的关键事项就...

乌柏木60阅读 5.9k评论 16

再也不学AJAX了!(二)使用AJAX ① XMLHttpRequest
「再也不学 AJAX 了」是一个以 AJAX 为主题的系列文章,希望读者通过阅读本系列文章,能够对 AJAX 技术有更加深入的认识和理解,从此能够再也不用专门学习 AJAX。本篇文章为该系列的第二篇,最近更新于 2023 年 1...

libinfs39阅读 6.1k评论 12

封面图
从零搭建 Node.js 企业级 Web 服务器(一):接口与分层
分层规范从本章起,正式进入企业级 Web 服务器核心内容。通常,一块完整的业务逻辑是由视图层、控制层、服务层、模型层共同定义与实现的,如下图:从上至下,抽象层次逐渐加深。从下至上,业务细节逐渐清晰。视图...

乌柏木39阅读 7k评论 6

CSS 绘制一只思否猫
欢迎关注我的公众号:前端侦探练习 CSS 有一个比较有趣的方式,就是发挥想象,绘制各式各样的图案,比如来绘制一只思否猫?思否猫,SegmentFault 思否的吉祥物,是一只独一无二、特立独行、热爱自由的(&gt;^ω^&lt...

XboxYan41阅读 2.8k评论 14

封面图
还在用 JS 做节流吗?CSS 也可以防止按钮重复点击
举个例子:一个保存按钮,为了避免重复提交或者服务器考虑,往往需要对点击行为做一定的限制,比如只允许每300ms提交一次,这时候我想大部分同学都会到网上直接拷贝一段throttle函数,或者直接引用lodash工具库

XboxYan34阅读 2.2k评论 2

封面图
从零搭建 Node.js 企业级 Web 服务器(二):校验
校验就是对输入条件的约束,避免无效的输入引起异常。Web 系统的用户输入主要为编辑与提交各类表单,一方面校验要做在编辑表单字段与提交的时候,另一方面接收表单的接口也要做足校验行为,通过前后端共同控制输...

乌柏木32阅读 6k评论 9

前端小小弄潮儿~

1.5k 声望
36 粉丝
宣传栏