内容简介:官网介绍 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 corre
官网介绍 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.
- 是个js测试框架
- 可以在Node.js和浏览器里面运行
- 支持异步测试用例
- 报告错误准确
为什么要看
- 我需要定制一套测试框架,想借鉴Mocha
- Mocha很轻量,结构足够清晰
- 从使用者角度了解它的原理,解决很多疑问
- 学习写Node, 开发一个接口友好的命令行工具
问题
以下我使用Mocha时的疑问,在看完源码之后都得到了解答并有额外的收获。 相信带着问题去看会更有效率和效果
- 如何读取的test文件?
- describe,it等为何直接可用?
- 和assert库结合怎么判断出失败的?
- 为什么在钩子或者test里传个done就知道是异步调用了?
使用过mocha再看会更有帮助, 如果没用过对着官方文档复制代码跑一下也很快
正文
这一篇我们主要看下运行时的目录结构和初始化
目录结构
下面的目录结构并不是真正源码工程的结构,只是npm上面包的结构,但由于主流程和发布的包代码一致没有做什么转换或打包,所以抛去构建的代码后可以更直观的看到运行结构
mocha@5.2.0
├─ 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测试的主流程
以上就是本文的全部内容,希望本文的内容对大家的学习或者工作能带来一定的帮助,也希望大家多多支持 码农网
猜你喜欢:- 命令源码文件
- 走近源码:Redis 如何执行命令
- Arthas 源码分析(三):命令执行过程
- Go Redigo 源码分析(三) 执行命令
- Cocoapods 源码浅析 - 从 pod 命令开始
- 熔断器 Hystrix 源码解析 —— 执行命令方式
本站部分资源来源于网络,本站转载出于传递更多信息之目的,版权归原作者或者来源机构所有,如转载稿涉及版权问题,请联系我们。