cover_image

从Three.js测试源码探索前端可视化项目的E2E测试方案

淘系-笙海 大淘宝前端技术
2021年04月08日 09:03
图片

前言

最近在工作中开发Canvas相关功能的工具库,需要写测试用例保证每次迭代不影响最终渲染效果。用关键词“canvas e2e test” 在线搜索相关资料后,看到网友们E2E测试方法八仙过海,各显神通,但都满足不了验证渲染结果的目的。后来换个角度去思考,研究了Three.js的E2E在github源码里测试用例,总结出该篇文章。

探索的背景

在浏览了网上大部分关于前端E2E测试文章后,归纳出方案分为两种。

  • Node环境纯数据验证:
    • 记录Canvas的API操作记录,验证执行记录,例如: jest-mock-canvas
  • 浏览器环境比对验证
    • 需要真实浏览器渲染结果,例如: cypress

很明显第一种不能彻底验证渲染结果,第二种比较重,还需要开启浏览器验证,对后续自动化集成有些不友好。思来想去后,觉得可能是搜索的姿势不对,Canvas的E2E测试应该是存在比较稳定丝滑的方案,要不然开源社区那些基于Canvas的大型可视化项目怎么保证渲染结果的质量呢?

Three.js的E2E方案

提起社区基于Canvas的大型Web项目,很容易可以想出 Three.js ,是基于 Canvas 来执行WebGL的3D渲染。

翻了一下 Three.js 在Github上的官方仓库 https://github.com/mrdoob/three.js ,找到e2e的测试源码的目录 ./test/e2e 发现 Three.js 的E2E 的测试步骤主要有两步

1. 确定正确期待值: 创建期望正确渲染的截图快照。

  • 启动一个静态文件的HTTP服务,加载 Three.js 本地的示例。
  • 用无头浏览器(Headless browser) 访问示例,并保存3D正确效果的截图快照。
  • 关闭HTTP服务。

图片

2. 比对迭代前后差异:

  • 启动一个静态文件的HTTP服务,加载 Three.js 本地的示例。
  • 用无头浏览器(Headless browser) 访问迭代后的示例,并将原有正确效果的图片做图片像素的差异匹配。如果匹配结果像素差异度大于 0.5% 就是测试用例失败。
  • 关闭静态HTTP服务。

图片

涉及到的npm包

  • puppeteer  提供无头浏览器(Headless browser)
  • jimp 提供图像处理程序
  • pixelmatch 提供图片像素级别的比较
  • serve-handler 提供静态服务操作

实现E2E测试用例

1. 制作正确期待结果截图

const path = require('path');
const http = require('http');
const jimp = require('jimp');
const puppeteer = require('puppeteer');
const serveHandler = require('serve-handler');

const port = 3001;
const width = 400;
const height = 400;
const snapshotPicPath = path.join(__dirname, 'snapshot''expect.png');

async function main({

  const server = http.createServer((req, res) => serveHandler(req, res, {
    // 这里需要改成所需静态资源目录
    public: path.join(__dirname, '..''src'),
  }));
  server.listen(port, async () => {
    try {
      const browser = await puppeteer.launch();
      const page = await browser.newPage();
      await page.setViewport( { width: width, height: height } );
      await page.goto(`http://127.0.0.1:${port}/index.html`);
      const buf = await page.screenshot();
      await browser.close();
      server.close();
      
      (await jimp.read(buf)).scale(1).quality(100).write(snapshotPicPath);
      console.log('create snapshot of screen scuccess!')
    } catch (err) {
      server.close();
      console.error(err);
      process.exit(-1);
    }
  });
  server.on('SIGINT', () => process.exit(1) );
}


main();

2. 实现E2E测试


const fs = require('fs');
const path = require('path');
const http = require('http');
const assert = require('assert');

const jimp = require('jimp');
const pixelmatch = require('pixelmatch');
const puppeteer = require('puppeteer');
const serveHandler = require('serve-handler');

const port = 3001;
const width = 400;
const height = 400;
const snapshotPicPath = path.join(__dirname, 'snapshot''expect.png');

let server = null;
let browser = null;

describe('E2E Testing'function({
  
  before(function(done{
    server = http.createServer((req, res) => serveHandler(req, res, {
      // 这里需要改成所需静态资源目录
      public: path.join(__dirname, '..''src'),
    }));
    server.listen(port, done);
    server.on('SIGINT', () => process.exit(1) );
  });

  it('testing...'function(done){
    this.timeout(1000 * 60);

    const expectDiffRate = 0.005;

    new Promise(async (resolve) => {
      browser = await puppeteer.launch();
      const page = await browser.newPage();
      await page.setViewport( { width: width, height: height } );
      await page.goto(`http://127.0.0.1:${port}/index.html`);
      const buf = await page.screenshot();
      const actual = (await jimp.read(buf)).scale(1).quality(100).bitmap;
      const expected = (await jimp.read(fs.readFileSync(snapshotPicPath))).bitmap;
      const diff = actual;
      const failPixel = pixelmatch(expected.data, actual.data, diff.data, actual.width, actual.height);
      const failRate = failPixel / (width * height);
      
      resolve(failRate);
    }).then((failRate) => {
      assert.ok(failRate < expectDiffRate);
      done();
    }).catch(done);
  });

  after(function({
    browser && browser.close();
    server && server.close();
  });
});

3. 改进E2E测试,让差异可视化

  • 用 jimp 把匹配差异的像素标记给保存成图片,方便查看差异

图片

  • 第一张图片为预期正确结果。
  • 第二张图片加了蓝色的圆环,为错误结果。
  • 第三张图片是对比了预期和错误结果后,用红色像素标记出差异点。

修改后的E2E测试代码如下


const fs = require('fs');
const path = require('path');
const http = require('http');
const assert = require('assert');

const jimp = require('jimp');
const pixelmatch = require('pixelmatch');
const puppeteer = require('puppeteer');
const serveHandler = require('serve-handler');

const port = 3001;
const width = 400;
const height = 400;
const snapshotPicPath = path.join(__dirname, 'snapshot''expect.png');
const diffPicPath = path.join(__dirname, 'snapshot''diff.png');

let server = null;
let browser = null;

describe('E2E Testing'function({

  before(function(done{
    server = http.createServer((req, res) => serveHandler(req, res, {
      public: path.join(__dirname, '..''src'),
    }));
    server.listen(port, done);
    server.on('SIGINT', () => process.exit(1) );
  });

  it('testing...'function(done){
    this.timeout(1000 * 60);

    const expectDiffRate = 0.005;

    new Promise(async (resolve) => {
      browser = await puppeteer.launch();
      const page = await browser.newPage();
      await page.setViewport( { width: width, height: height } );
      await page.goto(`http://127.0.0.1:${port}/index.html`);
      const buf = await page.screenshot();
      const actual = (await jimp.read(buf)).scale(1).quality(100).bitmap;
      const expected = (await jimp.read(fs.readFileSync(snapshotPicPath))).bitmap;
      const diff = actual;
      const failPixel = pixelmatch(expected.data, actual.data, diff.data, actual.width, actual.height);
      const failRate = failPixel / (width * height);

      if (failRate >= expectDiffRate) {
        (await jimp.read(diff)).scale(1).quality(100).write(diffPicPath);
        console.log(`create diff image at: ${diffPicPath}`)
      }

      resolve(failRate);
    }).then((failRate) => {
      assert.ok(failRate < expectDiffRate);
      done();
    }).catch(done);
  });

  after(function({
    browser && browser.close();
    server && server.close();
  });
});

修改后的链路流程图大致如下

图片

完整的E2E测试链路

图片


其他注意点

  • 同样的Canvas代码在不同操作系统,通过puppeteer生成的截图会有少量的像素差异,如果要做多系统的E2E测试,需要将不同系统的E2E测试用例区分开来。

前端可视化质量测试的思考

经过上述一番折腾后,顺便学习了一下Three.js所有的测试用例,总结出前端可视化的质量验证一般有以下三个方面。

  • 代码质量验证
    • 单元测试,模块的颗粒化验证
    • 单元测试的代码覆盖率
    • 常见可使用工具或npm模块有:  jest, mocha 等
  • 可视化效果验证
    • E2E测试,渲染结果像素基本验证
    • 常见可使用工具或npm模块有:  puppeteer + pixelmatch,  jest-image-snapshot
  • 可视化性能验证
    • 基准测试,代码执行性能验证
    • 常见可使用工具或npm模块有:  benchmark.js

后记

  • 探索了一阵子后,发现通过puppeteer + pixelmatch 关键词结合一起搜索资料发现其实已经有个类似能力的jest插件 jest-image-snapshot ,最后总结搜索姿势很重要,o(╯□╰)o!
  • 下班后给自己业余开发H5图像处理小玩具加上测试用例 https://github.com/chenshenhai/pictool  在GitHub Actions试跑成功,再也不用担心图像处理渲染结果的质量啦。感兴趣的小伙伴可以去看看里面的测试用例。

✿   欢迎关注东半球最大的前端团队
继续滑动看下一个
大淘宝前端技术
向上滑动看下一个