做者:黃浩羣node
Mocha 是目前最流行的 JavaScript 測試框架,理解 Mocha 的內部實現原理有助於咱們更深刻地瞭解和學習自動化測試。然而閱讀源碼一直是個讓人望而生畏的過程,大量的高級寫法常常是晦澀難懂,大量的邊緣狀況的處理也十分影響對核心代碼的理解,以致於寫一篇源碼解析事後每每是連本身都看不懂。因此,此次咱們不生啃 Mocha 源碼,換個方式,從零開始一步步實現一個簡易版的 Mocha。npm
Mocha 支持 BDD/TDD 等多種測試風格,默認使用 BDD 接口。BDD(行爲驅動開發)是一種以需求爲導向的敏捷開發方法,相比主張」測試先行「的 TDD(測試驅動開發)而言,它強調」需求先行「,從一個更加宏觀的角度去關注包括開發、QA、需求方在內的多方利益相關者的協做關係,力求讓開發者「作正確的事「。在 Mocha 中,一個簡單的 BDD 式測試用例以下:json
describe('Array', function() {
describe('#indexOf()', function() {
before(function() {
// ...
});
it('should return -1 when not present', function() {
// ...
});
it('should return the index when present', function() {
// ...
});
after(function() {
// ...
});
});
});
複製代碼
Mocha 的 BDD 測試主要包括如下幾個 API:數組
describe/context
:行爲描述,表明一個測試塊,是一組測試單元的集合;it/specify
:描述了一個測試單元,是最小的測試單位;before
:Hook 函數,在執行該測試塊以前執行;after
:Hook 函數,在執行該測試塊以後執行;beforeEach
:Hook 函數,在執行該測試塊中每一個測試單元以前執行;afterEach
:Hook 函數,在執行該測試塊中每一個測試單元以後執行。話很少說,咱們直接開始。promise
新建一個項目,命名爲 simple-mocha。目錄結構以下:瀏覽器
├─ mocha/
│ ├─ index.js
│ ├─ src/
│ ├─ interfaces/
│ └─ reporters/
├─ test/
└─ package.json
複製代碼
先對這個目錄結構做簡單解釋:bash
mocha/
:存放咱們即將實現的 simple-mocha 的源代碼mocha/index.js
:simple-mocha 入口mocha/src/
:simple-mocha 核心代碼mocha/interfaces/
:存放各種風格的測試接口,如 BDDmocha/reporters/
:存放用於輸出測試報告的各類 reporter,如 SPECtest/
:存放咱們編寫的測試用例package.json
其中 package.json 內容以下:微信
{
"name": "simple-mocha",
"version": "1.0.0",
"description": "a simple mocha for understanding the mechanism of mocha",
"main": "",
"scripts": {
"test": "node mocha/index.js"
},
"author": "hankle",
"license": "ISC"
}
複製代碼
執行 npm test
就能夠啓動執行測試用例。框架
Mocha 的 BDD 測試應該是一個」先定義後執行「的過程,這樣才能保證其 Hook 機制正確執行,而與代碼編寫順序無關,所以咱們把整個測試流程分爲兩個階段:收集測試用例(定義)和執行測試用例(執行)。咱們構造了一個 Mocha 類來完成這兩個過程,同時這個類也負責統籌協調其餘各模塊的執行,所以它是整個測試流程的核心。異步
// mocha/src/mocha.js
class Mocha {
constructor() {}
run() {}
}
module.exports = Mocha;
複製代碼
// mocha/index.js
const Mocha = require('./src/mocha');
const mocha = new Mocha();
mocha.run();
複製代碼
另外一方面咱們知道,describe 函數描述了一個測試集合,這個測試集合除包括若干測試單元外,還擁有着一些自身的 Hook 函數,維護了一套嚴格的執行流。it 函數描述了一個測試單元,它須要執行測試用例,而且接收斷言結果。這是兩個邏輯複雜的單元,同時須要維護必定的內部狀態,咱們用兩個類(Suite/Test)來分別構造它們。此外咱們能夠看出,BDD 風格的測試用例是一個典型的樹形結構,describe 定義的測試塊能夠包含測試塊,也能夠包含 it 定義的測試單元。因此 Suite/Test 實例還將做爲節點,構造出一棵 suite-test 樹。好比下邊這個測試用例:
describe('Array', function () {
describe('#indexOf()', function () {
it('should return -1 when not present', function () {
// ...
})
it('should return the index when present', function () {
// ...
})
})
describe('#every()', function () {
it('should return true when all items are satisfied', function () {
// ...
})
})
})
複製代碼
由它構造出來的 suite-test 樹是這樣的:
┌────────────────────────────────────────────────────────┐
┌─┤ test:"should return -1 when not present" │
┌────────────────────┐ │ └────────────────────────────────────────────────────────┘
┌─┤ suite:"#indexOf()" ├─┤
│ └────────────────────┘ │ ┌────────────────────────────────────────────────────────┐
┌───────────────┐ │ └─┤ test:"should return the index when present" │
│ suite:"Array" ├─┤ └────────────────────────────────────────────────────────┘
└───────────────┘ │
│ ┌────────────────────┐ ┌────────────────────────────────────────────────────────┐
└─┤ suite:"#every()" ├───┤ test:"should return true when all items are satisfied" │
└────────────────────┘ └────────────────────────────────────────────────────────┘
複製代碼
所以,Suite/Test 除了要可以表示 describe/it 以外,還應該可以詮釋這種樹狀結構的父子級關係:
// mocha/src/suite.js
class Suite {
constructor(props) {
this.title = props.title; // Suite名稱,即describe傳入的第一個參數
this.suites = []; // 子級suite
this.tests = []; // 包含的test
this.parent = props.parent; // 父suite
this._beforeAll = []; // before hook
this._afterAll = []; // after hook
this._beforeEach = []; // beforeEach hook
this._afterEach = []; // afterEach hook
if (props.parent instanceof Suite) {
props.parent.suites.push(this);
}
}
}
module.exports = Suite;
複製代碼
// mocha/src/test.js
class Test {
constructor(props) {
this.title = props.title; // Test名稱,即it傳入的第一個參數
this.fn = props.fn; // Test的執行函數,即it傳入的第二個參數
}
}
module.exports = Test;
複製代碼
咱們完善一下目錄結構:
├─ mocha/
│ ├─ index.js
│ ├─ src/
│ │ ├─ mocha.js
│ │ ├─ runner.js
│ │ ├─ suite.js
│ │ ├─ test.js
│ │ └─ utils.js
│ ├─ interfaces/
│ │ ├─ bdd.js
│ │ └─ index.js
│ └─ reporters/
│ ├─ spec.js
│ └─ index.js
├─ test/
└─ package.json
複製代碼
考慮到執行測試用例的過程較爲複雜,咱們把這塊邏輯單獨抽離到 runner.js
,它將在執行階段負責調度 suite 和 test 節點並運行測試用例,後續會詳細說到。
收集測試用例環節首先須要建立一個 suite 根節點,並把 API 掛載到全局,而後再執行測試用例文件 *.spec.js
進行用例收集,最終將生成一棵與之結構對應的 suite-test 樹。
咱們先建立一個 suite 實例,做爲整棵 suite-test 樹的根節點,同時它也是咱們收集和執行測試用例的起點。
// mocha/src/mocha.js
const Suite = require('./suite');
class Mocha {
constructor() {
// 建立一個suite根節點
this.rootSuite = new Suite({
title: '',
parent: null
});
}
// ...
}
複製代碼
在咱們使用 Mocha 編寫測試用例時,咱們不須要手動引入 Mocha 提供的任何模塊,就可以直接使用 describe、it 等一系列 API。那怎麼樣才能實現這一點呢?很簡單,把 API 掛載到 global 對象上就行。所以,咱們須要在執行測試用例文件以前,先將 BDD 風格的 API 所有做全局掛載。
// mocha/src/mocha.js
// ...
const interfaces = require('../interfaces');
class Mocha {
constructor() {
// 建立一個根suite
// ...
// 使用bdd測試風格,將API掛載到global對象上
const ui = 'bdd';
interfaces[ui](global, this.rootSuite);
}
// ...
}
複製代碼
// mocha/interfaces/index.js
module.exports.bdd = require('./bdd');
複製代碼
// mocha/interfaces/bdd.js
module.exports = function (context, root) {
context.describe = context.context = function (title, fn) {}
context.it = context.specify = function (title, fn) {}
context.before = function (fn) {}
context.after = function (fn) {}
context.beforeEach = function (fn) {}
context.afterEach = function (fn) {}
}
複製代碼
咱們先看看 describe 函數怎麼實現。
describe 傳入的 fn 參數是一個函數,它描述了一個測試塊,測試塊包含了若干子測試塊和測試單元。所以咱們須要執行 describe 傳入的 fn 函數,纔可以獲知到它的子層結構,從而構造出一棵完整的 suite-test 樹。而逐層執行 describe 的 fn 函數,本質上就是一個深度優先遍歷的過程,所以咱們須要利用一個棧(stack)來記錄 suite 根節點到當前節點的路徑。
// mocha/interfaces/bdd.js
const Suite = require('../src/suite');
const Test = require('../src/test');
module.exports = function (context, root) {
// 記錄 suite 根節點到當前節點的路徑
const suites = [root];
context.describe = context.context = function (title, fn) {
const parent = suites[0];
const suite = new Suite({
title,
parent
});
suites.unshift(suite);
fn.call(suite);
suites.shift(suite);
}
}
複製代碼
每次處理一個 describe 時,咱們都會構建一個 Suite 實例來表示它,而且在執行 fn 前入棧,執行 fn 後出棧,保證 suites[0]
始終是當前正在處理的 suite 節點。利用這個棧列表,咱們能夠在遍歷過程當中構建出 suite 的樹級關係。
一樣的,其餘 API 也都須要依賴這個棧列表來實現:
// mocha/interfaces/bdd.js
module.exports = function (context, root) {
// 記錄 suite 根節點到當前節點的路徑
const suites = [root];
// context.describe = ...
context.it = context.specify = function (title, fn) {
const parent = suites[0];
const test = new Test({
title,
fn
});
parent.tests.push(test);
}
context.before = function (fn) {
const cur = suites[0];
cur._beforeAll.push(fn);
}
context.after = function (fn) {
const cur = suites[0];
cur._afterAll.push(fn);
}
context.beforeEach = function (fn) {
const cur = suites[0];
cur._beforeEach.push(fn);
}
context.afterEach = function (fn) {
const cur = suites[0];
cur._afterEach.push(fn);
}
}
複製代碼
一切準備就緒,咱們開始 require
測試用例文件。要完成這個步驟,咱們須要一個函數來協助完成,它負責解析 test 路徑下的資源,返回一個文件列表,而且可以支持 test 路徑爲文件和爲目錄的兩種狀況。
// mocha/src/utils.js
const path = require('path');
const fs = require('fs');
module.exports.lookupFiles = function lookupFiles(filepath) {
let stat;
// 假設路徑是文件
try {
stat = fs.statSync(`${filepath}.js`);
if (stat.isFile()) {
// 確實是文件,直接以數組形式返回
return [filepath];
}
} catch(e) {}
// 假設路徑是目錄
let files = []; // 存放目錄下的全部文件
fs.readdirSync(filepath).forEach(function(dirent) {
let pathname = path.join(filepath, dirent);
try {
stat = fs.statSync(pathname);
if (stat.isDirectory()) {
// 是目錄,進一步遞歸
files = files.concat(lookupFiles(pathname));
} else if (stat.isFile()) {
// 是文件,補充到待返回的文件列表中
files.push(pathname);
}
} catch(e) {}
});
return files;
}
複製代碼
// mocha/src/mocha.js
// ...
const path = require('path');
const utils = require('./utils');
class Mocha {
constructor() {
// 建立一個根suite
// ...
// 使用bdd測試風格,將API掛載到global對象上
// ...
// 執行測試用例文件,構建suite-test樹
const spec = path.resolve(__dirname, '../../test');
const files = utils.lookupFiles(spec);
files.forEach(file => {
require(file);
});
}
// ...
}
複製代碼
在這個環節中,咱們須要經過遍歷 suite-test 樹來遞歸執行 suite 節點和 test 節點,並同步地輸出測試報告。
Mocha 的測試用例和 Hook 函數是支持異步執行的。異步執行的寫法有兩種,一種是函數返回值爲一個 promise 對象,另外一種是函數接收一個入參 done
,並由開發者在異步代碼中手動調用 done(error)
來向 Mocha 傳遞斷言結果。因此,在執行測試用例以前,咱們須要一個包裝函數,將開發者傳入的函數 promise 化:
// mocha/src/utils.js
// ...
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);
}
})
}
複製代碼
這個工具函數傳入一個函數 fn 並返回另一個函數,執行返回的函數可以以 promise 的形式去運行 fn。這樣一來,咱們須要稍微修改一下以前的代碼:
// mocha/interfaces/bdd.js
// ...
const { adaptPromise } = require('../src/utils');
module.exports = function (context, root) {
// ...
context.it = context.specify = function (title, fn) {
// ...
const test = new Test({
title,
fn: adaptPromise(fn)
});
// ...
}
context.before = function (fn) {
// ...
cur._beforeAll.push(adaptPromise(fn));
}
context.after = function (fn) {
// ...
cur._afterAll.push(adaptPromise(fn));
}
context.beforeEach = function (fn) {
// ...
cur._beforeEach.push(adaptPromise(fn));
}
context.afterEach = function (fn) {
// ...
cur._afterEach.push(adaptPromise(fn));
}
}
複製代碼
執行測試用例須要調度 suite 和 test 節點,所以咱們須要一個執行器(runner)來統一負責執行過程。這是執行階段的核心,咱們先直接貼代碼:
// 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 {
constructor() {
super();
// 記錄 suite 根節點到當前節點的路徑
this.suites = [];
}
/* * 主入口 */
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;
}
}
}
// 路徑棧推入當前節點
this.suites.unshift(suite);
// 2)執行test
if (suite.tests.length) {
for (const test of suite.tests) {
await this.runTest(test);
}
}
// 3)執行子級suite
if (suite.suites.length) {
for (const child of suite.suites) {
await this.runSuite(child);
}
}
// 路徑棧推出當前節點
this.suites.shift(suite);
// 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);
}
/* * 執行suite */
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
複製代碼
代碼很長,咱們稍微捋一下。
首先,咱們構造一個 Runner 類,利用兩個 async 方法來完成對 suite-test 樹的遍歷:
runSuite
:負責執行 suite 節點。它不只須要調用 runTest 執行該 suite 節點上的若干 test 節點,還須要調用 runSuite 執行下一級的若干 suite 節點來實現遍歷,同時,before/after 也將在這裏獲得調用。執行順序依次是:before -> runTest -> runSuite -> after
。runTest
:負責執行 test 節點,主要是執行該 test 對象上定義的測試用例。另外,beforeEach/afterEach 的執行有一個相似瀏覽器事件捕獲和冒泡的過程,咱們須要沿節點路徑向當前 suite 節點方向和向 suite 根節點方向分別執行各 suite 的 beforeEach/afterEach 鉤子函數。執行順序依次是:beforeEach -> run test case -> afterEach
。在遍歷過程當中,咱們依然是利用一個棧列表來維護 suite 根節點到當前節點的路徑。同時,這兩個流程都用 async/await 寫法來組織,保證全部任務在異步場景下依然是按序執行的。
其次,測試結論是「邊執行邊輸出」的。爲了在執行過程當中能向 reporter 實時通知執行結果和執行狀態,咱們讓 Runner 類繼承自 EventEmitter 類,使其具有訂閱/發佈事件的能力,這個後續會細講。
最後,咱們在 Mocha 實例的 run 方法中去實例化 Runner 並調用它:
// mocha/src/mocha.js
// ...
const Runner = require('./runner');
class Mocha {
// ...
run() {
const runner = new Runner();
runner.run(this.rootSuite);
}
}
複製代碼
reporter 負責測試報告輸出,這個過程是在執行測試用例的過程當中同步進行的,所以咱們利用 EventEmitter 讓 reporter 和 runner 保持通訊。在 runner 中咱們已經在各個關鍵節點都做了 event emit,因此咱們只須要在 reporter 中加上相應的事件監聽便可:
// mocha/reporters/index.js
module.exports.spec = require('./spec');
複製代碼
// mocha/reporters/spec.js
const constants = require('../src/runner').constants;
module.exports = function (runner) {
// 執行開始
runner.on(constants.EVENT_RUN_BEGIN, function() {});
// suite執行開始
runner.on(constants.EVENT_SUITE_BEGIN, function(suite) {});
// suite執行結束
runner.on(constants.EVENT_SUITE_END, function() {});
// 用例經過
runner.on(constants.EVENT_PASS, function(title) {});
// 用例失敗
runner.on(constants.EVENT_FAIL, function(title) {});
// 執行結束
runner.once(constants.EVENT_RUN_END, function() {});
}
複製代碼
Mocha 類中引入 reporter,執行事件訂閱,就能讓 runner 將測試的狀態結果實時推送給 reporter 了:
// mocha/src/mocha.js
const reporters = require('../reporters');
// ...
class Mocha {
// ...
run() {
const runner = new Runner();
reporters['spec'](runner);
runner.run(this.rootSuite);
}
}
複製代碼
reporter 中能夠任意構造你想要的報告樣式輸出,例如這樣:
// mocha/reporters/spec.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;
function indent(i = 0) {
return Array(indents + i).join(' ');
}
// 執行開始
runner.on(constants.EVENT_RUN_BEGIN, function() {
console.log();
});
// suite執行開始
runner.on(constants.EVENT_SUITE_BEGIN, function(suite) {
console.log();
++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);
console.log(color('fail', ' %d failing'), failures);
});
}
複製代碼
到這裏,咱們的 simple-mocha 就基本完成了,咱們能夠編寫一個測試用例來簡單驗證一下:
// test/test.spec.js
const assert = require('assert');
describe('Array', function () {
describe('#indexOf()', function () {
it('should return -1 when not present', function () {
assert.equal(-1, [1, 2, 3].indexOf(4))
})
it('should return the index when present', function () {
assert.equal(-1, [1, 2, 3].indexOf(3))
})
})
describe('#every()', function () {
it('should return true when all items are satisfied', function () {
assert.equal(true, [1, 2, 3].every(item => !isNaN(item)))
})
})
})
describe('Srting', function () {
describe('#replace', function () {
it('should return a string that has been replaced', function () {
assert.equal('hey Hankle', 'hey Densy'.replace('Densy', 'Hankle'))
})
})
})
複製代碼
這裏咱們用 node 內置的 assert 模塊來執行斷言測試。下邊是執行結果:
npm test
> simple-mocha@1.0.0 test /Documents/simple-mocha
> node mocha
Array
#indexOf()
✓ should return -1 when not present
× should return the index when present
#every()
✓ should return true when all items are satisfied
String
#replace
✓ should return a string that has been replaced
3 passing
1 failing
複製代碼
測試用例執行成功。附上完整的流程圖:
若是你看到了這裏,看完並看懂了上邊實現 simple-mocha 的整個流程,那麼很高興地告訴你,你已經掌握了 Mocha 最核心的運行機理。simple-mocha 的整個實現過程其實就是 Mocha 實現的一個簡化。而爲了讓你們在看完這篇文章後再去閱讀 Mocha 源碼時可以更快速地理解,我在簡化和淺化 Mocha 實現流程的同時,也儘量地保留了其中的一些命名和實現細節。有差異的地方,如執行測試用例環節,Mocha 源碼利用了一個複雜的 Hook 機制來實現異步測試的依序執行,而我爲了方便理解,用 async/await 來替代實現。固然這不是說 Mocha 實現得繁瑣,在更加複雜的測試場景下,這套 Hook 機制是十分必要的。因此,這篇文章僅僅但願可以幫助咱們攻克 Mocha 源碼閱讀的第一道陡坡,而要理解 Mocha 的精髓,光看這篇文章是遠遠不夠的,還得深刻閱讀 Mocha 源碼。
若是你以爲這篇內容對你有價值,請點贊,並關注咱們的官網和咱們的微信公衆號(WecTeam),每週都有優質文章推送: