针对具有复杂canvas交互操作的e2e测试,尝试使用puppeteer模拟复杂canvas交互,自动执行相关测试用例,利于减少重复的人工验证和回归测试。

主要做了封装通用操作添加操作辅助类与测试工具集成结果验证等工作。

为何使用puppeteer模拟canvas上的操作

前端不乏比较优秀的e2e测试框架,比如cypressnightwatch等等,他们均具有较完整的相关工具集和API,如browser driver、断言等等。

他们操纵浏览器的方式,即driver的实现方式主要有两种:

  1. 通过WebDriver远程调用,通过协议与HTTP通信给内置driver传递指令来指挥浏览器执行操作,如nightwatch, selenium等
  2. 在浏览器内部执行操作,通过使用dom元素上的API执行相关操作,如cypress等

这些框架不怎么适用于测试canvas上的复杂交互,问题就来自于他们的driver,不支持更加细粒度的鼠标操作

在第一种方式中,driver的能力受限于WebDriver标准中的定义。可以参考WebDrier标准中有关元素交互的部分,具有如下条目:

* 12.4 Interaction
  * 12.4.1 Element Click
  * 12.4.2 Element Clear
  * 12.4.3 Element Send Keys

即元素的点击、可编辑元素的清空和发送按键指令。

而在第二种方式中,driver的能力受限于DOM API。可以在cypress自己实现的内置driver中看到它所拥有的actions

* check
* click
* focus
* scroll
* type
...

以scroll操作为例,内部直接调用的scrollTo方法:

# https://github.com/cypress-io/cypress/blob/develop/packages/driver/src/cy/commands/actions/scroll.coffee
$(options.$parent).scrollTo(options.$el, {
  axis:     options.axis
  easing:   options.easing
  duration: options.duration
  offset:   options.offset
  done: (animation, jumpedToEnd) ->
    resolve(options.$el)
  fail: (animation, jumpedToEnd) ->
    ## its Promise object is rejected
    try
      $utils.throwErrByPath("scrollTo.animation_failed")
    catch err
      reject(err)
  always: ->
    if parentIsWin
      delete options.$parent.contentWindow
})

介绍完测试框架中已有的两种driver,再来看看puppeteer中是如何做的。

puppeteer(/ˌpəpəˈtir/,以下简称pptr)是一个通过CDP(Chrome Devtools Protocol)和websocket实现与chromium实例交互的Node顶层API。 可以使用它提供的API来操作浏览器实例,在之前的文中有简单介绍,还可以看看官方的一些例子

pptr并没有使用上面的两种driver,而是通过实现一个遵循CDP的Node端应用来操作浏览器,内部分别使用CDPSession与FrameManager来管理网络与页面。

CDP提供了大量细粒度的操作类型与选项,而pptr充分利用了这些,在pptr中提供的API可以满足复杂canvas交互中所需的键鼠模拟操作。

现在有一个canvas,可以在上面进行笔画绘制的操作:

See the Pen interaction-mouseevent by yrq110 (@yrq110) on CodePen.

如果要模拟这个操作,需要实现:

  1. 单独控制控制鼠标按键的按下和抬起,实现拖拽效果
  2. 根据位置坐标控制光标移动,绘制一段线段或曲线

pptr中的Mouse类所提供的方法就可以满足上述要求,如下:

await page.mouse.move(...start);
await page.mouse.down();
for (let i = 0; i < points.length / 2; i++) {
  await page.waitFor(200);
  await page.mouse.move(points[i*2], points[i*2+1);
}
await page.mouse.up();

若仅使用pptr测试少量复杂交互的话单独编写几个方法即可,如果要与测试工具集成的话就需要额外做一些工作了。

封装常用操作

首先,处理一些项目中测试集的常用操作,包含初始化页面、双击等通用操作和指定路径拖拽和登录组件登录等业务中的常用操作,下面是两个示例。

初始化页面:

export const initPage = async (url: string, config: Object): Promise<Page> => {
  const launchOption = Object.assign({}, BROWSER_CONFIG.LaunchOption, config);
  const browser = await pptr.launch(launchOption);
  const page = await browser.newPage();
  await page.goto(url);
  return page
}

按照传入路径执行鼠标移动操作:

export const dragByRoute = async (page: Page, routes: Route, isClose: boolean = false, delay: number = 200): Promise<void> => {
  const [start, ...points] = routes;
  await page.mouse.move(...start);
  await page.mouse.down();
  await page.mouse.move(start[0] + 1, start[1] + 1); // hack to trigger mousemove event
  for (let i = 0; i < points.length; i++) {
    await page.waitFor(delay);
    await page.mouse.move(points[i][0], points[i][1]);
  }
  if (isClose) {
    await page.waitFor(delay);
    await page.mouse.move(start[0], start[1]);
  }
  await page.waitFor(delay);
  await page.mouse.up();
};

添加操作辅助类

封装好常用操作后,在编写实际的用例集之前,需要再引入一个操作辅助类。

这个辅助类主要有两个作用:

  1. 收集错误及对应的操作描述信息,方便debug

    若单纯的让测试工具抛出异常,那么找不到选择器元素这类的错误很难定义到具体的操作

  2. 在调用时为操作添加说明,方便协作与修改

    在编写完操作流程后,若其他人对交互不甚熟悉,单单看代码中某个点击或移动的操作会一头雾水,并不知道这些操作的含义

// refs to alex's blog[1]
export default class ActionStack {
  public actionsSoFar: string[];
  constructor() {
    this.actionsSoFar = [];
  }
  async executeAction(actionDescription: string, action: Function) {
    try {
      this.actionsSoFar.push(actionDescription);
      return await action();
    } catch (e) {
      this.actionsSoFar.pop();
      // bind error msg to specific action
      let errorMessage = `Failed during action "${actionDescription}", due to error: ${e}\n`;
      errorMessage += 'Actions leading up to failure:\n';
      for (let i = this.actionsSoFar.length >= 3 ? this.actionsSoFar.length - 3 : 0; i < this.actionsSoFar.length; i++) {
        errorMessage += '"' + this.actionsSoFar[i] + '"\n';
      }
      throw new Error(errorMessage);
    }
    }
  }
}

使用时:

this.as = new ActionStack();
// ...
await this.as.executeAction('Remove a path point', async () => {
  const del = points[0];
  await page.waitFor(500);
  await page.mouse.move(del[0] + 3, del[1] + 3);
  await page.waitFor(500);
  await page.mouse.click(del[0] + 12, del[1] - 20);
  await page.waitFor(3000);
});

添加用例Runner

创建一个基类Runner和针对不同类型用例操作的Runner

在BaseRunner中声明Page对象,处理部分初始化与资源释放等操作

export default class BaseRunner implements IBaseRunner{
  public address: string;
  public config?: object;
  public page: Page;

  constructor(address: string, config?: object) {
    this.address = address;
    this.config = config || {};
  }

  public async prepose() {
    this.page = await initPage(this.address, this.config);
    // 其他处理...
  }

  public dispose() {
    // 释放资源...
  }
}

在用例集相关的Runner中,进行前置处理,创建操作辅助类,封装用例操作。

export default class DrawingRunner extends BaseRunner {
  private page: Frame;
  private canvasCenter: coordType;
  public as: ActionStack;
  // 初始化,创建操作辅助类
  constructor(public url: string, public config?: object) {
    super(url, config);
    this.as = new ActionStack();
  }
  // 设置该用例集的前置操作
  public async prepose(): Promise<void> {
    await super.prepose();
    await this.as.executeAction('Get canvas centre position', async () => {
      const originCanvas = await this.page.$('.origin-canvas');
      const originBox = await originCanvas.boundingBox();
      this.canvasCenter = [originBox.x + originBox.width / 2, originBox.y + originBox.height / 2];
    });
  }
  // 单独封装的操作
  public async clearDrawing(): Promise<void> {
    await this.as.executeAction('Click clear drawing button', async () => {
      const buttons = await this.page.$$('.edit-buttons .el-button');
      const clearButton = buttons.pop();
      clearButton.click();
    });
  }
  // 测试用例操作
  public async runStrokeDrawing(): Promise<void> {
    const { page, route } = this;
    await this.as.executeAction('Draw keep type pen path', async () => {
      // drawing mode => keep (default)
      await dragByRoute(page, route, true);
    });
    await this.as.executeAction('Draw drop type pen path', async () => {
      // drawing mode => drop
      await page.click('#drop-button');
      await dragByRoute(page, route, true);
    });
    await page.screenshot({
      path: `/e2e/shortcuts/case/stroke-drwaing.png`
    });
  }
}

与测试工具绑定

当编写完runner后,就该与测试工具绑定了,用于测试集的运行、结果判定等。

可以选择mocha或jest等工具,这里采用mocha进行演示。

import 'mocha';
import DrawingRunner from '@case/drawing';
const url  = 'YOUR_ADDRESS';
describe('matting e2e test', function () {
  let runner: DrawingRunner;
  // 在各种hooks中执行对应操作
  before(async function() {
    runner = new DrawingRunner(url, { headless: true });
    await runner.prepose();
    await runner.clearDrawing();
  });
  // 每个用例执行前清空画布
  beforeEach(async function() {
    await runner.clearDrawing();
  });
  // 所有用例执行完释放资源
  after(function() {
    runner.dispose();
  });
  // 执行用例
  describe('drawing case suite', function () {
    it('stroke drwaing case', function (done) {
      (async function() {
        try {
          await runner.runStrokeDrawing();
        } catch (e) {
          console.log(e);
        }
        done();
      })();
    });
  });
});

结果验证

在执行完用例还需要验证执行结果,canvas相关的测试有时无法单纯的通过判断元素或者具体数值来断言测试是否通过,需要使用一些额外的手段。

对于一般的canvas操作可以通过对比渲染结果图自动验证,对于复杂繁琐的交互也可以记录操作的动图人工验证。

自动验证:对比预置的标准渲染图像

准备预置的操作结果渲染图像,截取测试结果的图像进行对比,验证是否符合预期。

在图像对比部分可以使用Resemble.js等图像分析与对比工具进行,将对比的结果传入断言函数中。

人工验证:记录操作流程动图

可以使用gif工具记录下操作过程的动图并保存下来,之后进行人工验证,检查效果是否符合预期。

实现方法很多,这里以gifencoder与png-js为例举个栗子:

准备GifRecorder方法

const GIFEncoder = require("gifencoder");
const PNG = require("png-js");
const decode = png => {
  return new Promise(r => {
    png.decode(pixels => r(pixels));
  });
};

// 截图并添加到gif frame中
const gifAddFrame = async (page, encoder, snapshotRect) => {
  const clipRect = snapshotRect || { width: 1024, height: 768, x: 0, y: 0 };
  const pngBuffer = await page.screenshot({ clip: clipRect });
  const png = new PNG(pngBuffer);
  await decode(png).then(pixels => encoder.addFrame(pixels));
};

// 创建gif记录器
exports.initGifRecorder = (page, filename, filePath, interval = 300) => {
  const encoder = new GIFEncoder(1024, 768);
  encoder.createWriteStream().pipe(fs.createWriteStream(`${filePath}/${filename}.gif`));
  encoder.start();
  encoder.setRepeat(0);
  encoder.setDelay(300);
  encoder.setQuality(10);
  let timer = setInterval(() => gifAddFrame(page, encoder), interval);
  return () => {
    clearInterval(timer);
    encoder.finish();
  };
};

在需要记录的操作处调用

import { initGifRecorder } from '@helper/utils'
export default class DrawingRunner extends BaseRunner {
  // ...
  public async runStrokeDrawing(): Promise<void> {
    const { page, route } = this;
    const stopRecorder = initGifRecorder(page, 'stroke-drawing', 'e2e/gif') // 开始记录
    // 操作...
    stopRecorder(); // 停止记录
  }
}

其他

一个pptr与jest集成的例子

参考

  1. Using Puppeteer to Create an End-to-End Test Framework
  2. WebDriver
  3. Getting Started Using Puppeteer & Headless Chrome for End-to-End Testing
  4. Testing with mocha, chai, and puppeteer