深入理解 Mocha 测试框架:从零实现一个 Mocha

357次阅读  |  发布于2年以前

本文为来自飞书 aPaaS Growth 研发团队成员的文章。

aPaaS Growth 团队专注在用户可感知的、宏观的 aPaaS 应用的搭建流程,及租户、应用治理等产品路径,致力于打造 aPaaS 平台流畅的 “应用交付” 流程和体验,完善应用构建相关的生态,加强应用搭建的便捷性和可靠性,提升应用的整体性能,从而助力 aPaaS 的用户增长,与基础团队一起推进 aPaaS 在企业内外部的落地与提效。

前言

什么是自动化测试

什么是Mocha

准备

了解mocha

// mocha-demo/index.js
const toString = Object.prototype.toString;

function getTag(value) {
  if (value == null) {
    return value === undefined ? '[object Undefined]' : '[object Null]'
  }
  return toString.call(value)
}

module.exports = {
  getTag,
};

上述代码使用了Object.prototype.toString来判断了数据类型,我们针对上述代码的测试用例(此处断言使用node原生的assert方法,采用BDD的测试风格):

// test/getTag.spec.js
const assert = require('assert');
const { getTag } = require('../index');

describe('检查:getTag函数执行', function () {
  before(function() {
    console.log('before钩子触发');
  });
  describe('测试:正常流', function() {
    it('类型返回: [object JSON]', function (done) {
      setTimeout(() => {
        assert.equal(getTag(JSON), '[object JSON]');
        done();
      }, 1000);
    });
    it('类型返回: [object Number]', function() {
      assert.equal(getTag(1), '[object Number]');
    });
  });
  describe('测试:异常流', function() {
    it('类型返回: [object Undefined]', function() {
      assert.equal(getTag(undefined), '[object Undefined]');
    });
  });
  after(function() {
    console.log('after钩子触发');
  });
});

mocha提供的api语义还是比较强的,即使没写过单元测试代码,单看这段代码也不难理解这段代码干了啥,而这段测试代码页会作为我们最后验证简易Mocha的样例,我们先来看下使用mocha运行该测试用例的执行结果:

如上图所示,即我们前面测试代码的执行结果,我们来拆分下当前mocha实现的一些功能点。

注:mocha更多使用方法可参考Mocha - the fun, simple, flexible JavaScript test framework[1]

核心函数

测试风格

钩子函数

支持异步

it('类型返回: [object JSON]', function (done) {
  setTimeout(() => {
    assert.equal(getTag(JSON), '[object JSON]');
    done();
  }, 1000);
});

这种异步代码在我们实际业务中也是十分常见的,比如某一部分代码依赖接口数据的返回,或是对某些定时器进行单测用例的编写。mocha支持两种方式的异步代码,一种是回调函数直接返回一个Promise,一种是支持在回调函数中传参数done,手动调用done函数来结束用例。

执行结果和执行顺序

设计

目录结构设计

├── index.js            #待测试代码(业务代码)
├── mocha               #简易mocha所在目录
│   ├── index.js       #简易mocha入口文件
│   ├── interfaces     #存放不同的测试风格
│   │   ├── bdd.js    #BDD 测试风格的实现
│   │   └── index.js  #方便不同测试风格的导出
│   ├── reporters      #生成测试报告
│   │   ├── index.js  
│   │   └── spec.js  
│   └── src            #简易mocha核心目录
│       ├── mocha.js   #存放Mocha类控制整个流程
│       ├── runner.js  #Runner类,辅助Mocha类执行测试用例
│       ├── suite.js   #Suite类,处理describe函数
│       ├── test.js    #Test类,处理it函数
│       └── utils.js   #存放一些工具函数
├── package.json
└── test               #测试用例编写
    └── getTag.spec.js

上面的mocha文件夹就是我们将要实现的简易版mocha目录,目录结构参考的mocha源码,但只采取了核心部分目录结构。

总体流程设计

class Mocha {
  constructor() {}
  run() {}
}
module.exports = Mocha;

入口文件更新为:

// mocha-demo/mocha/index.js
const Mocha = require('./src/mocha');
const mocha = new Mocha();
mocha.run();

测试用例的执行过程顺序尤其重要,前面说过用例的执行遵循从外到里,从上到下的顺序,对于describeit的回调函数处理很容易让我们想到这是一个树形结构,而且是深度优先的遍历顺序。简化下上面的用例代码:

describe('检查:getTag函数执行', function () {
  describe('测试:正常流', function() {
   it('类型返回: [object JSON]', function (done) {
      setTimeout(() => {
        assert.equal(getTag(JSON), '[object JSON]');
        done();
      }, 1000);
    });
    it('类型返回: [object Number]', function() {
      assert.equal(getTag(1), '[object Number]');
    });
  });
  describe('测试:异常流', function() {
    it('类型返回: [object Undefined]', function() {
      assert.equal(getTag(undefined), '[object Undefined]');
    });
  });
});

针对这段代码结构如下:

image.png

整个树的结构如上,而我们在处理具体的函数的时候则可以定义Suite/Test两个类来分别描述describe/it两个函数。可以看到describe函数是存在父子关系的,关于Suite类的属性我们定义如下:

 // mocha/src/suite.js
class  Suite {
  /**
*
* @param { * } parent 父节点
* @param { * } title Suite名称,即describe传入的第一个参数
*/  
 constructor ( parent, title ) {
 this . title = title; // Suite名称,即describe传入的第一个参数
 this . parent = parent // 父suite
this . suites = [];  // 子级suite
 this . tests = []; // 包含的it 测试用例方法
 this . _beforeAll = []; // before 钩子
 this . _afterAll = []; // after 钩子
 this . _beforeEach = []; // beforeEach钩子
 this . _afterEach = []; // afterEach 钩子
    // 将当前Suite实例push到父级的suties数组中
 if (parent instanceof  Suite ) {
parent. suites . push ( this );
}
}
}

module . exports = Suite ; 

而Test类代表it就可以定义的较为简单:

 // mocha/src/test.js
class Test {
  constructor(props) {
    this.title = props.title;  // Test名称,it传入的第一个参数
    this.fn = props.fn;        // Test的执行函数,it传入的第二个参数
  }
}

module.exports = Test;

此时我们整个流程就出来了:

1 . 收集用例(通过Suite和Test类来构造整棵树);

2 . 执行用例(遍历这棵树,执行所有的用例函数);

3 . 收集测试用例的执行结果。

a . 此时我们整个的流程如下(其中执行测试用例和收集执行结果已简化):

image.png

OK,思路已经非常清晰,实现一下具体的代码吧

实现

创建根节点

// mocha/src/mocha.js
const Suite = require('./suite');
class Mocha {
  constructor() {
    // 创建根节点
    this.rootSuite = new Suite(null, '');
  }
  run() { }
}
module.exports = Mocha;

api全局挂载

// mocha/interfaces/bdd.js
// context是我们的上下文环境,root是我们的树的根节点
module.exports = function (context, root) {
  // context是describe的别名,主要目的是处于测试用例代码的组织和可读性的考虑
  context.describe = context.context = function(title, fn) {}
  // specify是it的别名
  context.it = context.specify = function(title, fn) {}
  context.before = function(fn) {}
  context.after = function(fn) {}
  context.beforeEach = function(fn) {}
  context.afterEach = function(fn) {}
}

为方便支持各种测试风格接口我们进行统一的导出:

// mocha/interfaces/index.js
'use strict';
exports.bdd = require('./bdd');

然后在Mocha类中进行bdd接口的全局挂载:

// mocha/src/mocha.js
const interfaces = require('../interfaces');
class Mocha {
  constructor() {
    // this.rootSuite = ...
    // 注意第二个参数是我们的前面创建的根节点,此时
    interfaces['bdd'](global, this.rootSuite "'bdd'");
  }
  run() {}
}

module.exports = Mocha;

此时我们已经完成了api的全局挂载,可以放心导入测试用例文件让函数执行了。

导入测试用例文件

// mocha/src/utils.js
const path = require('path');
const fs = require('fs');

/**
*
* @param { * } filepath 文件或是文件夹路径
* @returns 所有测试文件路径数组
*/
module.exports.findCaseFile = function (filepath) {
  function readFileList(dir, fileList = []) {
    const files = fs.readdirSync(dir);
    files.forEach((item, _ ) => {
        var fullPath = path.join(dir, item);
        const stat = fs.statSync(fullPath);
        if (stat.isDirectory()) {      
            readFileList(path.join(dir, item), fileList);  // 递归读取文件
        } else {                
            fileList.push(fullPath);                     
        }        
    });
    return fileList;
  }
  let fileList = [];
  // 路径如果是文件则直接返回
  try {
    const stat = fs.statSync(filepath);
    if (stat.isFile()) {
      fileList = [filepath];
      return fileList;
    }
    readFileList(filepath, fileList);
  } catch(e) {console.log(e)}

  return fileList;
}

上面函数简单的实现了一个方法,用来递归的读取本地所有的测试用例文件,然后在Mocha类中使用该方法加载我们当前的测试用例文件:

// mocha/src/mocha.js
const path = require('path');
const interfaces = require('../interfaces');
const utils = require('./utils');
class Mocha {
  constructor() {
    // this.rootSuite = ...
    // interfaces['bdd'](global, this.rootSuite "'bdd'");
    // 写死我们本地测试用例所在文件夹地址
    const spec = path.resolve(__dirname, '../../test');
    const files = utils.findCaseFile(spec);
    // 加载测试用例文件
    files.forEach(file => require(file));
  }
  run() {}
}

module.exports = Mocha;

创建Suite-Test树

// mocha/interfaces/bdd.js

const Suite = require('../src/suite');
const Test = require('../src/test');

module.exports = function (context, root) {
  // 树的根节点进栈
  const suites = [root];
  // context是describe的别名,主要目的是处于测试用例代码的组织和可读性的考虑
  context.describe = context.context = function (title, callback) {
    // 获取当前栈中的当前节点
    const cur = suites[0];
    // 实例化一个Suite对象,存储当前的describe函数信息
    const suite = new Suite(cur, title);
    // 入栈
    suites.unshift(suite);
    // 执行describe回调函数
    callback.call(suite);
    // Suite出栈
    suites.shift();
  }
  context.it = context.specify = function (title, fn) {
    // 获取当前Suite节点
    const cur = suites[0];
    const test = new Test(title, fn);
    // 将Test实例对象存储在tests数组中
    cur.tests.push(test);
  }
  // ...
}

注意,上面的代码我们仅仅是通过执行describe的回调函数将树的结构创建了出来,里面具体的测试用例代码(it的回调函数)还未开始执行。基于以上代码,我们整个Suite-Test树就已经创建出来了,截止到目前的代码我们收集用例的过程已经实现完成。此时我们的Sute-Test树创建出来是这样的结构:

image.png

支持异步

// mocha/src/utils.js
const path = require('path');
const fs = require('fs');

// module.exports.findCaseFile = ...

module.exports.adaptPromise = function(fn) {
  return () => new Promise(resolve => {
    if (fn.length === 0) {
      // 不使用参数 done
      try {
        const ret = fn();
        // 判断是否返回promise
        if (ret instanceof Promise) {
          return ret.then(resolve, resolve);
        } else {
          resolve();
        }
      } catch (error) {
        resolve(error);
      }
    } else {
      // 使用参数 done
      function done(error) {
        resolve(error);
      }
      fn(done);
    }
  })
}

我们改造下之前创建的Suite-Test树,将it、before、after、beforeEach和afterEach的回调函数进行适配:

// mocha/interfaces/bdd.js
const Suite = require('../src/suite');
const Test = require('../src/test');
const { adaptPromise } = require('../src/utils');

module.exports = function (context, root) {
  const suites = [root];
  // context是describe的别名,主要目的是处于测试用例代码的组织和可读性的考虑
  // context.describe = context.context = ...
  context.it = context.specify = function (title, fn) {
    const cur = suites[0];
    const test = new Test(title, adaptPromise(fn));
    cur.tests.push(test);
  }
  context.before = function (fn) {
    const cur = suites[0];
    cur._beforeAll.push(adaptPromise(fn));
  }
  context.after = function (fn) {
    const cur = suites[0];
    cur._afterAll.push(adaptPromise(fn));
  }
  context.beforeEach = function (fn) {
    const cur = suites[0];
    cur._beforeEach.push(adaptPromise(fn));
  }
  context.afterEach = function (fn) {
    const cur = suites[0];
    cur._afterEach.push(adaptPromise(fn));
  }
}

执行测试用例

// mocha/src/runner.js
class Runner {}

此时梳理下测试用例的执行逻辑,基于以上创建的Suite-Test树,我们可以对树进行一个遍历从而执行所有的测试用例,而对于异步代码的执行我们可以借用async/await来实现。此时我们的流程图更新如下:

image.png

整个思路梳理下来就很简单了,针对Suite-Test树,从根节点开始遍历这棵树,将这棵树中所有的Test节点所挂载的回调函数进行执行即可。相关代码实现如下:

// mocha/src/runner.js
class Runner {
  constructor() {
    super();
    // 记录 suite 根节点到当前节点的路径
    this.suites = [];
  }
  /*
* 主入口
*/
  async run(root) {
    // 开始处理Suite节点
    await this.runSuite(root);
  }
  /*
* 处理suite
*/
  async runSuite(suite) {
    // 1.执行before钩子函数
    if (suite._beforeAll.length) {
      for (const fn of suite._beforeAll) {
        const result = await fn();
      }
    }
    // 推入当前节点
    this.suites.unshift(suite);

    // 2. 执行test
    if (suite.tests.length) {
      for (const test of suite.tests) {
        // 执行test回调函数
        await this.runTest(test);
      }
    }

    // 3. 执行子级suite
    if (suite.suites.length) {
      for (const child of suite.suites) {
        // 递归处理Suite
        await this.runSuite(child);
      }
    }

    // 路径栈推出节点
    this.suites.shift();

    // 4.执行after钩子函数
    if (suite._afterAll.length) {
      for (const fn of suite._afterAll) {
        // 执行回调
        const result = await fn();
      }
    }
  }

  /*
* 处理Test
*/
  async runTest(test) {
    // 1. 由suite根节点向当前suite节点,依次执行beforeEach钩子函数
    const _beforeEach = [].concat(this.suites).reverse().reduce((list, suite) => list.concat(suite._beforeEach), []);
    if (_beforeEach.length) {
      for (const fn of _beforeEach) {
        const result = await fn();
      }
    }
    // 2. 执行测试用例
    const result = await test.fn();
    // 3. 由当前suite节点向suite根节点,依次执行afterEach钩子函数
    const _afterEach = [].concat(this.suites).reduce((list, suite) => list.concat(suite._afterEach), []);
    if (_afterEach.length) {
      for (const fn of _afterEach) {
        const result = await fn();
      }
    }
  }
}
module.exports = Runner;

将Runner类注入到Mocha类中:

// mocha/src/mocha.js
const Runner = require('./runner');

class Mocha {
  // constructor()..
  run() {
    const runner = new Runner();
    runner.run(this.rootSuite);
  }
}

module.exports = Mocha;

简单介绍下上面的代码逻辑,Runner类包括两个方法,一个方法用来处理Suite,一个方法用来处理Test,使用栈的结构遍历Suite-Test树,递归处理所有的Suite节点,从而找到所有的Test节点,将Test中的回调函数进行处理,测试用例执行结束。但到这里我们会发现,只是执行了测试用例而已,测试用例的执行结果还没获取到,测试用例哪个通过了,哪个没通过我们也无法得知。

收集测试用例执行结果

我们需要一个中间人来记录下执行的结果,输出给我们,此时我们的流程图更新如下:

修改Runner类,让它继承EventEmitter,来实现事件的传递工作:

// mocha/src/runner.js
const EventEmitter = require('events').EventEmitter;

// 监听事件的标识
const constants = {
  EVENT_RUN_BEGIN: 'EVENT_RUN_BEGIN',      // 执行流程开始
  EVENT_RUN_END: 'EVENT_RUN_END',          // 执行流程结束
  EVENT_SUITE_BEGIN: 'EVENT_SUITE_BEGIN',  // 执行suite开始
  EVENT_SUITE_END: 'EVENT_SUITE_END',      // 执行suite结束
  EVENT_FAIL: 'EVENT_FAIL',                // 执行用例失败
  EVENT_PASS: 'EVENT_PASS'                 // 执行用例成功
}

class Runner extends EventEmitter {
  // ...
  /*
* 主入口
*/
  async run(root) {
    this.emit(constants.EVENT_RUN_BEGIN);
    await this.runSuite(root);
    this.emit(constants.EVENT_RUN_END);
  }

  /*
* 执行suite
*/
  async runSuite(suite) {
    // suite执行开始
    this.emit(constants.EVENT_SUITE_BEGIN, suite);

    // 1. 执行before钩子函数
    if (suite._beforeAll.length) {
      for (const fn of suite._beforeAll) {
        const result = await fn();
        if (result instanceof Error) {
          this.emit(constants.EVENT_FAIL, `"before all" hook in ${suite.title}: ${result.message}`);
          // suite执行结束
          this.emit(constants.EVENT_SUITE_END);
          return;
        }
      }
    }

    // ...

    // 4. 执行after钩子函数
    if (suite._afterAll.length) {
      for (const fn of suite._afterAll) {
        const result = await fn();
        if (result instanceof Error) {
          this.emit(constants.EVENT_FAIL, `"after all" hook in ${suite.title}: ${result.message}`);
          // suite执行结束
          this.emit(constants.EVENT_SUITE_END);
          return;
        }
      }
    }
    // suite结束
    this.emit(constants.EVENT_SUITE_END);
  }

  /*
* 处理Test
*/
  async runTest(test) {
    // 1. 由suite根节点向当前suite节点,依次执行beforeEach钩子函数
    const _beforeEach = [].concat(this.suites).reverse().reduce((list, suite) => list.concat(suite._beforeEach), []);
    if (_beforeEach.length) {
      for (const fn of _beforeEach) {
        const result = await fn();
        if (result instanceof Error) {
          return this.emit(constants.EVENT_FAIL, `"before each" hook for ${test.title}: ${result.message}`)
        }
      }
    }

    // 2. 执行测试用例
    const result = await test.fn();
    if (result instanceof Error) {
      return this.emit(constants.EVENT_FAIL, `${test.title}`);
    } else {
      this.emit(constants.EVENT_PASS, `${test.title}`);
    }

    // 3. 由当前suite节点向suite根节点,依次执行afterEach钩子函数
    const _afterEach = [].concat(this.suites).reduce((list, suite) => list.concat(suite._afterEach), []);
    if (_afterEach.length) {
      for (const fn of _afterEach) {
        const result = await fn();
        if (result instanceof Error) {
          return this.emit(constants.EVENT_FAIL, `"after each" hook for ${test.title}: ${result.message}`)
        }
      }
    }
  }
}

Runner.constants = constants;
module.exports = Runner

在测试结果的处理函数中监听执行结果的回调进行统一处理:

// mocha/reporter/sped.js
const constants = require('../src/runner').constants;
const colors = {
  pass: 90,
  fail: 31,
  green: 32,
}
function color(type, str) {
  return '\u001b[' + colors[type] + 'm' + str + '\u001b[0m';
}
module.exports = function (runner) {
  let indents = 0;
  let passes = 0;
  let failures = 0;
  let time = +new Date();
  function indent(i = 0) {
    return Array(indents + i).join('  ');
  }
  // 执行开始
  runner.on(constants.EVENT_RUN_BEGIN, function() {});
  // suite执行开始
  runner.on(constants.EVENT_SUITE_BEGIN, function(suite) {
    ++indents;
    console.log(indent(), suite.title);
  });
  // suite执行结束
  runner.on(constants.EVENT_SUITE_END, function() {
    --indents;
    if (indents == 1) console.log();
  });
  // 用例通过
  runner.on(constants.EVENT_PASS, function(title) {
    passes++;
    const fmt = indent(1) + color('green', '  ✓') + color('pass', ' %s');
    console.log(fmt, title);
  });
  // 用例失败
  runner.on(constants.EVENT_FAIL, function(title) {
    failures++;
    const fmt = indent(1) + color('fail', '  × %s');
    console.log(fmt, title);
  });
  // 执行结束
  runner.once(constants.EVENT_RUN_END, function() {
    console.log(color('green', '  %d passing'), passes, color('pass', `(${Date.now() - time}ms)`));
    console.log(color('fail', '  %d failing'), failures);
  });
}

上面代码的作用对代码进行了收集。

验证

我们再手动构造一个失败用例:

const assert = require('assert');
const { getTag } = require('../index');
describe('检查:getTag函数执行', function () {
  before(function() {
    console.log('before钩子触发');
  });
  describe('测试:正常流', function() {
    it('类型返回: [object JSON]', function (done) {
      setTimeout(() => {
        assert.equal(getTag(JSON), '[object JSON]');
        done();
      }, 1000);
    });
    it('类型返回: [object Number]', function() {
      assert.equal(getTag(1), '[object Number]');
    });
  });
  describe('测试:异常流', function() {
    it('类型返回: [object Undefined]', function() {
      assert.equal(getTag(undefined), '[object Undefined]');
    });
    it('类型返回: [object Object]', function() {
      assert.equal(getTag([]), '[object Object]');
    });
  });
  after(function() {
    console.log('after钩子触发');
  });
});

执行下:

一个精简版mocha就此完成!

后记

参考

https://github.com/mochajs/mocha

https://mochajs.org/

参考资料

[1]Mocha - the fun, simple, flexible JavaScript test framework: https://mochajs.org/

Copyright© 2013-2020

All Rights Reserved 京ICP备2023019179号-8