(一)Mocha源碼閱讀: 項目結構及命令行啓動

(一)Mocha源碼閱讀: 項目結構及命令行啓動css

(二)Mocha源碼閱讀: 測試執行流程一之引入用例html

(三)Mocha源碼閱讀: 測試執行流程一執行用例node

前言

Mocha是什麼

官網介紹 Mocha is a feature-rich JavaScript test framework running on Node.js and in the browser, making asynchronous testing simple and fun. Mocha tests run serially, allowing for flexible and accurate reporting, while mapping uncaught exceptions to the correct test cases.git

  1. 是個js測試框架
  2. 能夠在Node.js和瀏覽器裏面運行
  3. 支持異步測試用例
  4. 報告錯誤準確

爲何要看

  1. 我須要定製一套測試框架,想借鑑Mocha
  2. Mocha很輕量,結構足夠清晰
  3. 從使用者角度瞭解它的原理,解決不少疑問
  4. 學習寫Node, 開發一個接口友好的命令行工具

問題

如下我使用Mocha時的疑問,在看完源碼以後都獲得瞭解答並有額外的收穫。 相信帶着問題去看會更有效率和效果es6

  1. 如何讀取的test文件?
  2. describe,it等爲什麼直接可用?
  3. 和assert庫結合怎麼判斷出失敗的?
  4. 爲何在鉤子或者test裏傳個done就知道是異步調用了?

使用過mocha再看會更有幫助, 若是沒用過對着官方文檔複製代碼跑一下也很快github

正文

這一篇咱們主要看下運行時的目錄結構和初始化正則表達式

目錄結構

下面的目錄結構並非真正源碼工程的結構,只是npm上面包的結構,但因爲主流程和發佈的包代碼一致沒有作什麼轉換或打包,因此拋去構建的代碼後能夠更直觀的看到運行結構npm

mocha@5.2.0json

├─ CHANGELOG.md
├─ LICENSE
├─ README.md
├─ bin           命令行運行目錄
│ ├─ _mocha        執行主程序
│ ├─ mocha         bin中mocha命令入口,調用_mocha
│ └─ options.js
├─ browser-entry.js     瀏覽器入口
├─ index.js         導出主模塊Mocha
├─ lib           主程序目錄
│ ├─ browser
│ │ ├─ growl.js
│ │ ├─ progress.js      瀏覽器中顯示進度
│ │ └─ tty.js
│ ├─ context.js       做爲Runnable的context
│ ├─ hook.js        繼承Runnable,執行各鉤子函數
│ ├─ interfaces       test文件中調用接口
│ │ ├─ bdd.js
│ │ ├─ common.js
│ │ ├─ exports.js
│ │ ├─ index.js
│ │ ├─ qunit.js
│ │ └─ tdd.js
│ ├─ mocha.js        主模塊
│ ├─ ms.js          毫秒
│ ├─ pending.js       跳過
│ ├─ reporters        報告
│ │ ├─ base.js
│ │ ├─ base.js.orig
│ │ ├─ doc.js
│ │ ├─ dot.js
│ │ ├─ html.js
│ │ ├─ index.js
│ │ ├─ json-stream.js
│ │ ├─ json.js
│ │ ├─ json.js.orig
│ │ ├─ landing.js
│ │ ├─ list.js
│ │ ├─ markdown.js
│ │ ├─ min.js
│ │ ├─ nyan.js
│ │ ├─ progress.js
│ │ ├─ spec.js
│ │ ├─ tap.js
│ │ └─ xunit.js
│ ├─ runnable.js       處理test中執行函數的類,test/hook繼承它
│ ├─ runner.js        處理整個測試流程,包括調用hooks, tests終止測試等
│ ├─ suite.js         一組測試的組
│ ├─ template.html       瀏覽器模板
│ ├─ test.js          test類
│ └─ utils.js          工具
├─ mocha.css
├─ mocha.js         瀏覽器端
└─ package.json數組

通常咱們命令行調用

mocha xxx
複製代碼

執行的就是node, 代碼基本就在bin和lib目錄

命令行調用

bin中的文件對應package.json中的bin

"bin": {
    "mocha": "./bin/mocha",
    "_mocha": "./bin/_mocha"
  },
複製代碼

咱們平時調用mocha xxx就等於node ./bin/mocha xxx bin介紹文檔 先看文件mocha

# bin/mocha

#!/usr/bin/env node

'use strict';

/**
 * This tiny wrapper file checks for known node flags and appends them
 * when found, before invoking the "real" _mocha(1) executable.
 */

const spawn = require('child_process').spawn;
const path = require('path');
const getOptions = require('./options');
const args = [path.join(__dirname, '_mocha')];

// Load mocha.opts into process.argv
// Must be loaded here to handle node-specific options

//這個mocha文件實際上是對真正處理參數的_mocha文件作了些預處理,主要調用了這個方法
getOptions();
複製代碼
#bin/options.js
// 看看命令行有沒有傳入--opts參數
// 若是傳入了--opts參數,則讀取文件並把options合併到process.argv中,沒有的話讀取test/mocha.opts,這個文件通常是沒有的,因此會報錯而後被igonore,
const optsPath =
    process.argv.indexOf('--opts') === -1
      ? 'test/mocha.opts'
      : process.argv[process.argv.indexOf('--opts') + 1];
  try {
  // 嘗試讀取文件
    const opts = fs
      .readFileSync(optsPath, 'utf8')
      .replace(/^#.*$/gm, '')
      .replace(/\\\s/g, '%20')
      .split(/\s/)
      .filter(Boolean)
      .map(value => value.replace(/%20/g, ' '));
    // 合到process.argv中
    process.argv = process.argv
      .slice(0, 2)
      .concat(opts.concat(process.argv.slice(2)));
  } catch (ignore) {
    // NOTE: should console.error() and throw the error
  }
  // 設置環境變量, 這裏的目的是在_mocha文件中,若是監測到這個變量沒有,會調用getOptions方法,保證最後讀取到。
  process.env.LOADED_MOCHA_OPTS = true;
複製代碼
#bin/mocha
//下面調用child_process的spawn開一個子進程, process.execPath就是當前執行node的地址, args爲一個數組,第一個爲[path.join(__dirname, '_mocha')]_mocha文件的地址,後面跟着參數[_mochaPath, argv1, argv2...]
這句至關於在命令行敲 node ./_mocha --xx xx --xxx
const proc = spawn(process.execPath, args, {
  stdio: 'inherit'
});
複製代碼

下面看_mocha文件, 用了commander來處理命令行, 相似的還有yargs, 都是能夠方便的作命令行應用

const program = require('commander');
...
const Mocha = require('../');
const utils = Mocha.utils;
const interfaceNames = Object.keys(Mocha.interfaces);
...
const mocha = new Mocha();
複製代碼

這裏new Mocha()已經涉及了Mocha主模塊的調用,咱們先跳過

#bin/_mocha
program
  .version(
    JSON.parse(
      fs.readFileSync(path.join(__dirname, '..', 'package.json'), 'utf8')
    ).version
  )
  .usage('[debug] [options] [files]')
  .option(
    '-A, --async-only',
    'force all tests to take a callback (async) or return a promise'
  )
  ...
  .option(
    '-u, --ui <name>',
    `specify user-interface (${interfaceNames.join('|')})`,
    'bdd'
  )
  ...
  .option(
    '--watch-extensions <ext>,...',
    'additional extensions to monitor with --watch',
    list,
    ['js']
  )
複製代碼

上面代碼基本對mocha文檔裏面提供的選項列了出來,還多了文檔沒有的好比--exclude,猜想是廢棄但向後兼容的。 從代碼看program的option方法基本第一個匹配參數項,第二個參數是描述,第三個參數若是是function,則對參數進行轉換,若是不是則設爲默認值,第四個值是默認值。 從命令行得到的值value能夠經過program[value]獲取。好比命令行敲mocha --async-only, program.asyncOnly爲true, program.watchExtensions則默認爲['js'].

// init方法mocha文檔並無詳細介紹,但咱們能夠看到它會在指定的path複製一套完整的瀏覽器測試框架包括html,js,css。

program
  .command('init <path>')
  .description('initialize a client-side mocha setup at <path>')
  .action(path => {
    const mkdir = require('mkdirp');
    mkdir.sync(path);
    const css = fs.readFileSync(join(__dirname, '..', 'mocha.css'));
    const js = fs.readFileSync(join(__dirname, '..', 'mocha.js'));
    const tmpl = fs.readFileSync(join(__dirname, '..', 'lib/template.html'));
    fs.writeFileSync(join(path, 'mocha.css'), css);
    fs.writeFileSync(join(path, 'mocha.js'), js);
    fs.writeFileSync(join(path, 'tests.js'), '');
    fs.writeFileSync(join(path, 'index.html'), tmpl);
    process.exit(0);
  });
複製代碼
// module.paths是node尋找module路徑的數組,包含的是從當前目錄開始的/node_modules路徑一層層往上文件夾下的node_modules,一直到根目錄。 而--require的文件可能並不在任何一個依賴包內,參數的路徑通常也是相對當前工做路徑也就是cwd,這樣修改module.paths至關於增長了node調用require時查找文件夾的路徑。

module.paths.push(cwd, join(cwd, 'node_modules'));

// 若是須要對option做複雜的處理,能夠用on('option:[options]',fn)來處理
好比這裏的require, 例如mocha --require @babel/register 通常咱們會用babel的register模塊把使用es6 import/export模式加載的代碼轉爲commonjs形式,這樣mocha就能夠讀取了
program.on('option:require', mod => {
  const abs = exists(mod) || exists(`${mod}.js`);
  if (abs) {
    mod = resolve(mod);
  }
  requires.push(mod);
});
複製代碼

變量requires是保存全部經過require參數傳進路徑來的數組,後面會循環依次require裏面的文件

requires.forEach(mod => {
  require(mod);
});
複製代碼

最後調一下解析

program.parse(process.argv);
複製代碼

而後是一連串根據命令行參數來設置mocha主模塊內部option的方法,這裏隨便列幾個

...
if (process.argv.indexOf('--no-diff') !== -1) {
  mocha.hideDiff(true);
}

// --slow <ms>

if (program.slow) {
  mocha.suite.slow(program.slow);
}

// --no-timeouts

if (!program.timeouts) {
  mocha.enableTimeouts(false);
}
...
複製代碼

對須要讀取的test文件的處理

/* 
這個program.args至關在後面沒有被option解析的參數
官方的Usage: mocha [debug] [options] [files] 
那個這個args就是後面files的一個數組
*/
const args = program.args;

// default files to test/*.{js,coffee}

if (!args.length) {
  args.push('test');
}
// 遍歷每一個文件
args.forEach(arg => {
  let newFiles;
  // 這裏的重點就是utils.lookupFiles方法了,主要做用是遞歸查找相應擴展名的文件,若是報錯或傳的是文件夾,或者glob表達式則返回路徑的數組,若是是文件,則直接返回文件路徑,後面貼代碼
  try {
    newFiles = utils.lookupFiles(arg, extensions, program.recursive);
  } catch (err) {
    if (err.message.indexOf('cannot resolve path') === 0) {
      console.error(
        `Warning: Could not find any test files matching pattern: ${arg}`
      );
      return;
    }

    throw err;
  }

  if (typeof newFiles !== 'undefined') {
    // 若是傳的自己就是一個文件路徑
    if (typeof newFiles === 'string') {
      newFiles = [newFiles];
    }
    newFiles = newFiles.filter(fileName =>
    // exclude其實已經不在文檔裏了,不過這個minimatch能夠看下,主要做用是能夠把glob表達式轉爲js的正則表達式來比較
      program.exclude.every(pattern => !minimatch(fileName, pattern))
    );
  }

  files = files.concat(newFiles);
});
// 找不到就退出
if (!files.length) {
  console.error('No test files found');
  process.exit(1);
}

// 這裏取得命令行--file傳的參數,感受略重複
let fileArgs = program.file.map(path => resolve(path));
files = files.map(path => resolve(path));

if (program.sort) {
  files.sort();
}
// 合併後面args和--file的文件路徑
// add files given through --file to be ran first
files = fileArgs.concat(files);
複製代碼

files包含了全部test文件的路徑,會在後面賦值到mocha實例上

接上面的utils.lookupFiles

function lookupFiles(filepath, extensions, recursive) {
  var files = [];
  // 當前路徑不存在
  if (!fs.existsSync(filepath)) {
  // 嘗試加上.js擴展名
    if (fs.existsSync(filepath + '.js')) {
      filepath += '.js';
    } else {
    // 不是js文件, 嘗試glob表達式匹配
      files = glob.sync(filepath);
      if (!files.length) {
        throw new Error("cannot resolve path (or pattern) '" + filepath + "'");
      }
      return files;
    }
  }

  try {
    當前路徑存在
    var stat = fs.statSync(filepath);
    if (stat.isFile()) {
    // 如果文件,直接返回路徑字符串
      return filepath;
    }
  } catch (err) {
    // ignore error
    return;
  }
  // 文件的狀況處理完,就剩文件夾的狀況
  fs.readdirSync(filepath).forEach(function(file) {
    file = path.join(filepath, file);
    try {
      var stat = fs.statSync(file);
      // 若是仍是文件夾,遞歸尋找
      if (stat.isDirectory()) {
        if (recursive) {
          files = files.concat(lookupFiles(file, extensions, recursive));
        }
        return;
      }
    } catch (err) {
      // ignore error
      return;
    }
    if (!extensions) {
      throw new Error(
        'extensions parameter required when filepath is a directory'
      );
    }
    // 匹配擴展名
    var re = new RegExp('\\.(?:' + extensions.join('|') + ')$');
    if (!stat.isFile() || !re.test(file) || path.basename(file)[0] === '.') {
      return;
    }
    files.push(file);
  });

  return files;
};
複製代碼

遞歸在mocha尋找文件,嵌套test/suite中用的不少。

下面開始主流程

// --watch

let runner;
let loadAndRun;
let purge;
let rerun;
// 熱更新 能夠往下看到else不熱更新的話就是調了mocha.run
if (program.watch) {
...
  // utils.files遞歸查找cwd下的全部文件,簡化版的utils.lookupFiles
  const watchFiles = utils.files(cwd, ['js'].concat(program.watchExtensions));
  let runAgain = false;
 // 定義loadAndRun函數
 /*
 這是首次和後面每次熱更新調用的入口
 */
  loadAndRun = () => {
    try {
      mocha.files = files;
      runAgain = false;
      // 這裏和非watch狀態下的區別是回調的不一樣,rerun是從新開始的入口
      runner = mocha.run(() => {
        runner = null;
        if (runAgain) {
          rerun();
        }
      });
    } catch (e) {
      console.log(e.stack);
    }
  };
  // 定義purge函數
  /*
  經過rerun調用,在loadAndRun以前刪除require進來的緩存
  由於require一次以後下次require就會直接讀取緩存的,對於熱更新來講不是咱們但願的
  */
  purge = () => {
    watchFiles.forEach(file => {
      delete require.cache[file];
    });
  };
// 這裏至關於沒watch的調用一次主流程
  loadAndRun();
  
  // 定義rerun函數
  rerun = () => {
    purge();
    ...
    /* 下面對mocha幾個屬性和方法的調用是初始化很關鍵的步驟,由於其實每次跑完suite和test,內部的引用是會被刪除的,mocha.suite.clone看似是克隆了上次的全部suite,但其實只是克隆了上次suite保存的options,而後生成一個空的根Suite,後面分析suite時會更容易理解。
    */
    mocha.suite = mocha.suite.clone();
    mocha.suite.ctx = new Mocha.Context();
    mocha.ui(program.ui);
    loadAndRun();
  };
/* utils.watch做用就是檢測watchFiles的變更而後回調
這裏有一點rerun的邏輯判斷,處理好才能保證咱們保存和跑測試的協調
utils.watch做用是檢測watchFiles的變化,只要文件變更,它就會觸發觸發。因爲檢測的是文件而不是文件夾,因此新增測試文件的話並不會重跑,須要從新啓動。
runAgain實際上是loadAndRun中會用到,這裏只要變更了咱們認爲確定須要重跑,這時候須要看程序所處的狀態。
若是沒有runner,說明以前的測試已經跑完了,直接rerun
若是runner還存在,說明以前的測試還沒跑完,先放棄當前的測試runner.abort,而後看loadAndRun中mocha.run,回調是會在結束當前測試後觸發,這裏若是發現變量runAgain爲true就會調用rerun了。
*/
  utils.watch(watchFiles, () => {
    runAgain = true;
    if (runner) {
      runner.abort();
    } else {
      rerun();
    }
  });
} else {
// 只運行一次
  mocha.files = files;
  runner = mocha.run(program.exit ? exit : exitLater);
}
複製代碼

最後看下utils.watch, 其實很是簡單,核心就是fs.watchFile方法,能夠監聽文件或文件夾的變更,設置interval是由於文件被access一樣會觸發,curr, prev是文件以前和當前變更的狀態,若是隻是access,則兩個mtime是相同的,因此咱們暫且認爲超過這個interval(100)的變更須要更新

exports.watch = function(files, fn) {
  var options = {interval: 100};
  files.forEach(function(file) {
    debug('file %s', file);
    fs.watchFile(file, options, function(curr, prev) {
      if (prev.mtime < curr.mtime) {
        fn(file);
      }
    });
  });
};
複製代碼

明白這些基本上本身也能夠實現一套熱更新了。

介紹完命令行初始化,後面兩篇將介紹Mocha測試的主流程

相關文章
相關標籤/搜索