(一)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测试的主流程

相关文章
相关标签/搜索