深入理解 Mocha 測試框架:從零實現一個 Mocha

本文爲來自飛書 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]');
    });
  });
});

針對這段代碼結構如下:

整個樹的結構如上,而我們在處理具體的函數的時候則可以定義 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. 收集測試用例的執行結果。

  4. 此時我們整個的流程如下 (其中執行測試用例和收集執行結果已簡化):

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 樹創建出來是這樣的結構:

支持異步

// 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來實現。此時我們的流程圖更新如下:

整個思路梳理下來就很簡單了,針對 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/

本文由 Readfog 進行 AMP 轉碼,版權歸原作者所有。
來源https://mp.weixin.qq.com/s/tpaCOMlVW8kV--s5SflvwQ