項(xiàng)目Git地址:https://github.com/deqwin/simple-mocha.git
前言
Mocha 是目前最流行的 JavaScript 測(cè)試框架,理解 Mocha 的內(nèi)部實(shí)現(xiàn)原理有助于我們更深入地了解和學(xué)習(xí)自動(dòng)化測(cè)試。然而閱讀源碼一直是個(gè)讓人望而生畏的過程,大量的高級(jí)寫法經(jīng)常是晦澀難懂,大量的邊緣情況的處理也十分影響對(duì)核心代碼的理解,以至于寫一篇源碼解析過后往往是連自己都看不懂。所以,這次我們不生啃 Mocha 源碼,換個(gè)方式,從零開始一步步實(shí)現(xiàn)一個(gè)簡(jiǎn)易版的 Mocha。
我們將實(shí)現(xiàn)什么?
- 實(shí)現(xiàn) Mocha 框架的 BDD 風(fēng)格測(cè)試,能通過 describe/it 函數(shù)定義一組或單個(gè)的測(cè)試用例;
- 實(shí)現(xiàn) Mocha 框架的 Hook 機(jī)制,包括 before、after、beforeEach、afterEach;
- 實(shí)現(xiàn)簡(jiǎn)單格式的測(cè)試報(bào)告輸出。
Mocha 的 BDD 測(cè)試
Mocha 支持 BDD/TDD 等多種測(cè)試風(fēng)格,默認(rèn)使用 BDD 接口。BDD(行為驅(qū)動(dòng)開發(fā))是一種以需求為導(dǎo)向的敏捷開發(fā)方法,相比主張”測(cè)試先行“的 TDD(測(cè)試驅(qū)動(dòng)開發(fā))而言,它強(qiáng)調(diào)”需求先行“,從一個(gè)更加宏觀的角度去關(guān)注包括開發(fā)、QA、需求方在內(nèi)的多方利益相關(guān)者的協(xié)作關(guān)系,力求讓開發(fā)者“做正確的事“。在 Mocha 中,一個(gè)簡(jiǎn)單的 BDD 式測(cè)試用例如下:
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 測(cè)試主要包括以下幾個(gè) API:
-
describe/context:行為描述,代表一個(gè)測(cè)試塊,是一組測(cè)試單元的集合; -
it/specify:描述了一個(gè)測(cè)試單元,是最小的測(cè)試單位; -
before:Hook 函數(shù),在執(zhí)行該測(cè)試塊之前執(zhí)行; -
after:Hook 函數(shù),在執(zhí)行該測(cè)試塊之后執(zhí)行; -
beforeEach:Hook 函數(shù),在執(zhí)行該測(cè)試塊中每個(gè)測(cè)試單元之前執(zhí)行; -
afterEach:Hook 函數(shù),在執(zhí)行該測(cè)試塊中每個(gè)測(cè)試單元之后執(zhí)行。
開始
話不多說,我們直接開始。
一、目錄設(shè)計(jì)
新建一個(gè)項(xiàng)目,命名為 simple-mocha。目錄結(jié)構(gòu)如下:
├─ mocha/
│ ├─ index.js
│ ├─ src/
│ ├─ interfaces/
│ └─ reporters/
├─ test/
└─ package.json
先對(duì)這個(gè)目錄結(jié)構(gòu)作簡(jiǎn)單解釋:
-
mocha/:存放我們即將實(shí)現(xiàn)的 simple-mocha 的源代碼 -
mocha/index.js:simple-mocha 入口 -
mocha/src/:simple-mocha 核心代碼 -
mocha/interfaces/:存放各類風(fēng)格的測(cè)試接口,如 BDD -
mocha/reporters/:存放用于輸出測(cè)試報(bào)告的各種 reporter,如 SPEC -
test/:存放我們編寫的測(cè)試用例 package.json
其中 package.json 內(nèi)容如下:
{
"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"
}
執(zhí)行 npm test 就可以啟動(dòng)執(zhí)行測(cè)試用例。
二、模塊設(shè)計(jì)
Mocha 的 BDD 測(cè)試應(yīng)該是一個(gè)”先定義后執(zhí)行“的過程,這樣才能保證其 Hook 機(jī)制正確執(zhí)行,而與代碼編寫順序無(wú)關(guān),因此我們把整個(gè)測(cè)試流程分為兩個(gè)階段:收集測(cè)試用例(定義)和執(zhí)行測(cè)試用例(執(zhí)行)。我們構(gòu)造了一個(gè) Mocha 類來完成這兩個(gè)過程,同時(shí)這個(gè)類也負(fù)責(zé)統(tǒng)籌協(xié)調(diào)其他各模塊的執(zhí)行,因此它是整個(gè)測(cè)試流程的核心。
// 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 函數(shù)描述了一個(gè)測(cè)試集合,這個(gè)測(cè)試集合除包括若干測(cè)試單元外,還擁有著一些自身的 Hook 函數(shù),維護(hù)了一套嚴(yán)格的執(zhí)行流。it 函數(shù)描述了一個(gè)測(cè)試單元,它需要執(zhí)行測(cè)試用例,并且接收斷言結(jié)果。這是兩個(gè)邏輯復(fù)雜的單元,同時(shí)需要維護(hù)一定的內(nèi)部狀態(tài),我們用兩個(gè)類(Suite/Test)來分別構(gòu)造它們。此外我們可以看出,BDD 風(fēng)格的測(cè)試用例是一個(gè)典型的樹形結(jié)構(gòu),describe 定義的測(cè)試塊可以包含測(cè)試塊,也可以包含 it 定義的測(cè)試單元。所以 Suite/Test 實(shí)例還將作為節(jié)點(diǎn),構(gòu)造出一棵 suite-test 樹。比如下邊這個(gè)測(cè)試用例:
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 () {
// ...
})
})
})
由它構(gòu)造出來的 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 之外,還應(yīng)該能夠詮釋這種樹狀結(jié)構(gòu)的父子級(jí)關(guān)系:
// mocha/src/suite.js
class Suite {
constructor(props) {
this.title = props.title; // Suite名稱,即describe傳入的第一個(gè)參數(shù)
this.suites = []; // 子級(jí)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傳入的第一個(gè)參數(shù)
this.fn = props.fn; // Test的執(zhí)行函數(shù),即it傳入的第二個(gè)參數(shù)
}
}
module.exports = Test;
因此,我們需要完善一下目錄結(jié)構(gòu):
├─ 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
考慮到執(zhí)行測(cè)試用例的過程較為復(fù)雜,我們把這塊邏輯單獨(dú)抽離到 runner.js,它將在執(zhí)行階段負(fù)責(zé)調(diào)度 suite 和 test 節(jié)點(diǎn)并運(yùn)行測(cè)試用例,后續(xù)會(huì)詳細(xì)說到。
三、收集測(cè)試用例
收集測(cè)試用例環(huán)節(jié)首先需要?jiǎng)?chuàng)建一個(gè) suite 根節(jié)點(diǎn),并將 API 掛載到全局,然后再執(zhí)行測(cè)試用例文件 *.spec.js 進(jìn)行用例收集,最終將生成一棵與之結(jié)構(gòu)對(duì)應(yīng)的 suite-test 樹。

1、suite 根節(jié)點(diǎn)
我們先創(chuàng)建一個(gè) suite 實(shí)例,作為整棵 suite-test 樹的根節(jié)點(diǎn),同時(shí)它也是我們收集和執(zhí)行測(cè)試用例的起點(diǎn)。
// mocha/src/mocha.js
const Suite = require('./suite');
class Mocha {
constructor() {
// 創(chuàng)建一個(gè)suite根節(jié)點(diǎn)
this.rootSuite = new Suite({
title: '',
parent: null
});
}
// ...
}
2、BDD API 的全局掛載
在我們使用 Mocha 編寫測(cè)試用例時(shí),我們不需要手動(dòng)引入 Mocha 提供的任何模塊,就能夠直接使用 describe、it 等一系列 API。那怎么樣才能實(shí)現(xiàn)這一點(diǎn)呢?很簡(jiǎn)單,把 API 掛載到 global 對(duì)象上就行。因此,我們需要在執(zhí)行測(cè)試用例文件之前,先將 BDD 風(fēng)格的 API 全部作全局掛載。
// mocha/src/mocha.js
// ...
const interfaces = require('../interfaces');
class Mocha {
constructor() {
// 創(chuàng)建一個(gè)根suite
// ...
// 使用bdd測(cè)試風(fēng)格,將API掛載到global對(duì)象上
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) {}
}
3、BDD API 的具體實(shí)現(xiàn)
我們先看看 describe 函數(shù)怎么實(shí)現(xiàn)。
describe 傳入的 fn 參數(shù)是一個(gè)函數(shù),它描述了一個(gè)測(cè)試塊,測(cè)試塊包含了若干子測(cè)試塊和測(cè)試單元。因此我們需要執(zhí)行 describe 傳入的 fn 函數(shù),才能夠獲知到它的子層結(jié)構(gòu),從而構(gòu)造出一棵完整的 suite-test 樹。而逐層執(zhí)行 describe 的 fn 函數(shù),本質(zhì)上就是一個(gè)深度優(yōu)先遍歷的過程,因此我們需要利用一個(gè)棧(stack)來記錄 suite 根節(jié)點(diǎn)到當(dāng)前節(jié)點(diǎn)的路徑。
// mocha/interfaces/bdd.js
const Suite = require('../src/suite');
const Test = require('../src/test');
module.exports = function (context, root) {
// 記錄 suite 根節(jié)點(diǎn)到當(dāng)前節(jié)點(diǎn)的路徑
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);
}
}
每次處理一個(gè) describe 時(shí),我們都會(huì)構(gòu)建一個(gè) Suite 實(shí)例來表示它,并且在執(zhí)行 fn 前入棧,執(zhí)行 fn 后出棧,保證 suites[0] 始終是當(dāng)前正在處理的 suite 節(jié)點(diǎn)。利用這個(gè)棧列表,我們可以在遍歷過程中構(gòu)建出 suite 的樹級(jí)關(guān)系。
同樣的,其他 API 也都需要依賴這個(gè)棧列表來實(shí)現(xiàn):
// mocha/interfaces/bdd.js
module.exports = function (context, root) {
// 記錄 suite 根節(jié)點(diǎn)到當(dāng)前節(jié)點(diǎn)的路徑
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);
}
}
4、執(zhí)行測(cè)試用例文件
一切準(zhǔn)備就緒,我們開始 require 測(cè)試用例文件。要完成這個(gè)步驟,我們需要一個(gè)函數(shù)來協(xié)助完成,它負(fù)責(zé)解析 test 路徑下的資源,返回一個(gè)文件列表,并且能夠支持 test 路徑為文件和為目錄的兩種情況。
// mocha/src/utils.js
const path = require('path');
const fs = require('fs');
module.exports.lookupFiles = function lookupFiles(filepath) {
let stat;
// 假設(shè)路徑是文件
try {
stat = fs.statSync(`${filepath}.js`);
if (stat.isFile()) {
// 確實(shí)是文件,直接以數(shù)組形式返回
return [filepath];
}
} catch(e) {}
// 假設(shè)路徑是目錄
let files = []; // 存放目錄下的所有文件
fs.readdirSync(filepath).forEach(function(dirent) {
let pathname = path.join(filepath, dirent);
try {
stat = fs.statSync(pathname);
if (stat.isDirectory()) {
// 是目錄,進(jìn)一步遞歸
files = files.concat(lookupFiles(pathname));
} else if (stat.isFile()) {
// 是文件,補(bǔ)充到待返回的文件列表中
files.push(pathname);
}
} catch(e) {}
});
return files;
}
// mocha/src/mocha.js
// ...
const path = require('path');
const utils = require('./utils');
class Mocha {
constructor() {
// 創(chuàng)建一個(gè)根suite
// ...
// 使用bdd測(cè)試風(fēng)格,將API掛載到global對(duì)象上
// ...
// 執(zhí)行測(cè)試用例文件,構(gòu)建suite-test樹
const spec = path.resolve(__dirname, '../../test');
const files = utils.lookupFiles(spec);
files.forEach(file => {
require(file);
});
}
// ...
}
四、執(zhí)行測(cè)試用例
在這個(gè)環(huán)節(jié)中,我們需要通過遍歷 suite-test 樹來遞歸執(zhí)行 suite 節(jié)點(diǎn)和 test 節(jié)點(diǎn),并同步地輸出測(cè)試報(bào)告。

1、異步執(zhí)行
Mocha 的測(cè)試用例和 Hook 函數(shù)是支持異步執(zhí)行的。異步執(zhí)行的寫法有兩種,一種是函數(shù)返回值為一個(gè) promise 對(duì)象,另一種是函數(shù)接收一個(gè)入?yún)?done,并由開發(fā)者在異步代碼中手動(dòng)調(diào)用 done(error) 來向 Mocha 傳遞斷言結(jié)果。所以,在執(zhí)行測(cè)試用例之前,我們需要一個(gè)包裝函數(shù),將開發(fā)者傳入的函數(shù) promise 化:
// mocha/src/utils.js
// ...
module.exports.adaptPromise = function(fn) {
return () => new Promise(resolve => {
if (fn.length == 0) { // 不使用參數(shù) done
try {
const ret = fn();
// 判斷是否返回promise
if (ret instanceof Promise) {
return ret.then(resolve, resolve);
} else {
resolve();
}
} catch (error) {
resolve(error);
}
} else { // 使用參數(shù) done
function done(error) {
resolve(error);
}
fn(done);
}
})
}
這個(gè)工具函數(shù)傳入一個(gè)函數(shù) fn 并返回另外一個(gè)函數(shù),執(zhí)行返回的函數(shù)能夠以 promise 的形式去運(yùn)行 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));
}
}
2、測(cè)試用例執(zhí)行器
執(zhí)行測(cè)試用例需要調(diào)度 suite 和 test 節(jié)點(diǎn),因此我們需要一個(gè)執(zhí)行器(runner)來統(tǒng)一負(fù)責(zé)執(zhí)行過程。這是執(zhí)行階段的核心,我們先直接貼代碼:
// mocha/src/runner.js
const EventEmitter = require('events').EventEmitter;
// 監(jiān)聽事件的標(biāo)識(shí)
const constants = {
EVENT_RUN_BEGIN: 'EVENT_RUN_BEGIN', // 執(zhí)行流程開始
EVENT_RUN_END: 'EVENT_RUN_END', // 執(zhí)行流程結(jié)束
EVENT_SUITE_BEGIN: 'EVENT_SUITE_BEGIN', // 執(zhí)行suite開始
EVENT_SUITE_END: 'EVENT_SUITE_END', // 執(zhí)行suite開始
EVENT_FAIL: 'EVENT_FAIL', // 執(zhí)行用例失敗
EVENT_PASS: 'EVENT_PASS' // 執(zhí)行用例成功
}
class Runner extends EventEmitter {
constructor() {
super();
// 記錄 suite 根節(jié)點(diǎn)到當(dāng)前節(jié)點(diǎn)的路徑
this.suites = [];
}
/*
* 主入口
*/
async run(root) {
this.emit(constants.EVENT_RUN_BEGIN);
await this.runSuite(root);
this.emit(constants.EVENT_RUN_END);
}
/*
* 執(zhí)行suite
*/
async runSuite(suite) {
// suite執(zhí)行開始
this.emit(constants.EVENT_SUITE_BEGIN, suite);
// 1)執(zhí)行before鉤子函數(shù)
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執(zhí)行結(jié)束
this.emit(constants.EVENT_SUITE_END);
return;
}
}
}
// 路徑棧推入當(dāng)前節(jié)點(diǎn)
this.suites.unshift(suite);
// 2)執(zhí)行test
if (suite.tests.length) {
for (const test of suite.tests) {
await this.runTest(test);
}
}
// 3)執(zhí)行子級(jí)suite
if (suite.suites.length) {
for (const child of suite.suites) {
await this.runSuite(child);
}
}
// 路徑棧推出當(dāng)前節(jié)點(diǎn)
this.suites.shift(suite);
// 4)執(zhí)行after鉤子函數(shù)
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執(zhí)行結(jié)束
this.emit(constants.EVENT_SUITE_END);
return;
}
}
}
// suite結(jié)束
this.emit(constants.EVENT_SUITE_END);
}
/*
* 執(zhí)行suite
*/
async runTest(test) {
// 1)由suite根節(jié)點(diǎn)向當(dāng)前suite節(jié)點(diǎn),依次執(zhí)行beforeEach鉤子函數(shù)
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)執(zhí)行測(cè)試用例
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)由當(dāng)前suite節(jié)點(diǎn)向suite根節(jié)點(diǎn),依次執(zhí)行afterEach鉤子函數(shù)
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
代碼很長(zhǎng),我們稍微捋一下。
首先,我們構(gòu)造一個(gè) Runner 類,利用兩個(gè) async 方法來完成對(duì) suite-test 樹的遍歷:
-
runSuite:負(fù)責(zé)執(zhí)行 suite 節(jié)點(diǎn)。它不僅需要調(diào)用 runTest 執(zhí)行該 suite 節(jié)點(diǎn)上的若干 test 節(jié)點(diǎn),還需要調(diào)用 runSuite 執(zhí)行下一級(jí)的若干 suite 節(jié)點(diǎn)來實(shí)現(xiàn)遍歷,同時(shí),before/after 也將在這里得到調(diào)用。執(zhí)行順序依次是:before -> runTest -> runSuite -> after。 -
runTest:負(fù)責(zé)執(zhí)行 test 節(jié)點(diǎn),主要是執(zhí)行該 test 對(duì)象上定義的測(cè)試用例。另外,beforeEach/afterEach 的執(zhí)行有一個(gè)類似瀏覽器事件捕獲和冒泡的過程,我們需要沿節(jié)點(diǎn)路徑向當(dāng)前 suite 節(jié)點(diǎn)方向和向 suite 根節(jié)點(diǎn)方向分別執(zhí)行各 suite 的 beforeEach/afterEach 鉤子函數(shù)。執(zhí)行順序依次是:beforeEach -> run test case -> afterEach。
在遍歷過程中,我們依然是利用一個(gè)棧列表來維護(hù) suite 根節(jié)點(diǎn)到當(dāng)前節(jié)點(diǎn)的路徑。同時(shí),這兩個(gè)流程都用 async/await 寫法來組織,保證所有任務(wù)在異步場(chǎng)景下依然是按序執(zhí)行的。
其次,測(cè)試結(jié)論是“邊執(zhí)行邊輸出”的。為了在執(zhí)行過程中能向 reporter 實(shí)時(shí)通知執(zhí)行結(jié)果和執(zhí)行狀態(tài),我們讓 Runner 類繼承自 EventEmitter 類,使其具備了事件訂閱/發(fā)布的能力,這個(gè)后續(xù)會(huì)細(xì)講。
最后,我們?cè)?Mocha 實(shí)例的 run 方法中去實(shí)例化 Runner 并調(diào)用它:
// mocha/src/mocha.js
// ...
const Runner = require('./runner');
class Mocha {
// ...
run() {
const runner = new Runner();
runner.run(this.rootSuite);
}
}
3、輸出測(cè)試報(bào)告
reporter 負(fù)責(zé)測(cè)試報(bào)告輸出,這個(gè)過程是在執(zhí)行測(cè)試用例的過程中同步進(jìn)行的,因此我們利用 EventEmitter 讓 reporter 和 runner 保持通信。在 runner 中我們已經(jīng)在各個(gè)關(guān)鍵節(jié)點(diǎn)都作了 event emit,所以我們只需要在 reporter 中加上相應(yīng)的事件監(jiān)聽即可:
// mocha/reporters/index.js
module.exports.spec = require('./spec');
// mocha/reporters/spec.js
const constants = require('../src/runner').constants;
module.exports = function (runner) {
// 執(zhí)行開始
runner.on(constants.EVENT_RUN_BEGIN, function() {});
// suite執(zhí)行開始
runner.on(constants.EVENT_SUITE_BEGIN, function(suite) {});
// suite執(zhí)行結(jié)束
runner.on(constants.EVENT_SUITE_END, function() {});
// 用例通過
runner.on(constants.EVENT_PASS, function(title) {});
// 用例失敗
runner.on(constants.EVENT_FAIL, function(title) {});
// 執(zhí)行結(jié)束
runner.once(constants.EVENT_RUN_END, function() {});
}
Mocha 類中引入 reporter,執(zhí)行事件訂閱,就能讓 runner 將測(cè)試的狀態(tài)結(jié)果實(shí)時(shí)推送給 reporter 了:
// mocha/src/mocha.js
const reporters = require('../reporters');
// ...
class Mocha {
// ...
run() {
const runner = new Runner();
reporters['spec'](runner);
runner.run(this.rootSuite);
}
}
reporter 中可以任意構(gòu)造你想要的報(bào)告樣式輸出,例如這樣:
// 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(' ');
}
// 執(zhí)行開始
runner.on(constants.EVENT_RUN_BEGIN, function() {
console.log();
});
// suite執(zhí)行開始
runner.on(constants.EVENT_SUITE_BEGIN, function(suite) {
console.log();
++indents;
console.log(indent(), suite.title);
});
// suite執(zhí)行結(jié)束
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);
});
// 執(zhí)行結(jié)束
runner.once(constants.EVENT_RUN_END, function() {
console.log(color('green', ' %d passing'), passes);
console.log(color('fail', ' %d failing'), failures);
});
}
五、驗(yàn)證
到這里,我們的 simple-mocha 就基本完成了,我們可以編寫一個(gè)測(cè)試用例來簡(jiǎn)單驗(yàn)證一下:
// 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 內(nèi)置的 assert 模塊來執(zhí)行斷言測(cè)試。下邊是執(zhí)行結(jié)果:
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
測(cè)試用例執(zhí)行成功。附上完整的流程圖:

結(jié)尾
如果你看到了這里,看完并看懂了上邊實(shí)現(xiàn) simple-mocha 的整個(gè)流程,那么很高興地告訴你,你已經(jīng)掌握了 Mocha 最核心的運(yùn)行機(jī)理。simple-mocha 的整個(gè)實(shí)現(xiàn)過程其實(shí)就是 Mocha 實(shí)現(xiàn)的一個(gè)簡(jiǎn)化。而為了讓大家在看完這篇文章后再去閱讀 Mocha 源碼時(shí)能夠更快速地理解,我在簡(jiǎn)化和淺化 Mocha 實(shí)現(xiàn)流程的同時(shí),也盡可能地保留了其中的一些命名和實(shí)現(xiàn)細(xì)節(jié)。有差別的地方,如執(zhí)行測(cè)試用例環(huán)節(jié),Mocha 源碼構(gòu)造了一個(gè)復(fù)雜的 Hook 機(jī)制來實(shí)現(xiàn)異步測(cè)試的依序執(zhí)行,而我為了方便理解,用 async/await 來替代實(shí)現(xiàn)。當(dāng)然這不是說 Mocha 實(shí)現(xiàn)得繁瑣,在更加復(fù)雜的測(cè)試場(chǎng)景下,這套 Hook 機(jī)制是十分必要的。所以,這篇文章僅僅希望能夠幫助我們攻克 Mocha 源碼閱讀的第一道陡坡,而要理解 Mocha 的精髓,光看這篇文章是遠(yuǎn)遠(yuǎn)不夠的,還得深入閱讀 Mocha 源碼。