当前位置: 首页 > news >正文

(一)Mocha源码阅读: 项目结构及命令行启动

前言

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.

  1. 是个js测试框架
  2. 可以在Node.js和浏览器里面运行
  3. 支持异步测试用例
  4. 报告错误准确

为什么要看

  1. 我需要定制一套测试框架,想借鉴Mocha
  2. Mocha很轻量,结构足够清晰
  3. 从使用者角度了解它的原理,解决很多疑问
  4. 学习写Node, 开发一个接口友好的命令行工具

问题

以下我使用Mocha时的疑问,在看完源码之后都得到了解答并有额外的收获。 相信带着问题去看会更有效率和效果

  1. 如何读取的test文件?
  2. describe,it等为何直接可用?
  3. 和assert库结合怎么判断出失败的?
  4. 为什么在钩子或者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测试的主流程

转载于:https://juejin.im/post/5cbeef1f6fb9a0322b5bfa83

相关文章:

  • 3D印表機 零件採購資訊
  • 源码泄露到底是裁员报复,还是程序员反抗 996?
  • Oracle求日期的格式的用法
  • 网路安全论文
  • 正则01
  • IOS流媒体播放
  • 云安全概述与发展趋势
  • Android存储访问及目录
  • 单任务现象
  • Houdini中总结Volume Lattice的方法
  • 添加树莓派python程序自启动的方法
  • 开源Math.NET基础数学类库使用(01)综合介绍
  • webapi返回json字符串
  • Hash小结
  • css 清除浮动样式
  • JS 中的深拷贝与浅拷贝
  • 【vuex入门系列02】mutation接收单个参数和多个参数
  • Apache Zeppelin在Apache Trafodion上的可视化
  • CentOS学习笔记 - 12. Nginx搭建Centos7.5远程repo
  •  D - 粉碎叛乱F - 其他起义
  • Docker: 容器互访的三种方式
  • eclipse(luna)创建web工程
  • java中具有继承关系的类及其对象初始化顺序
  • JS正则表达式精简教程(JavaScript RegExp 对象)
  • Laravel 中的一个后期静态绑定
  • Python3爬取英雄联盟英雄皮肤大图
  • Redis字符串类型内部编码剖析
  • Spark in action on Kubernetes - Playground搭建与架构浅析
  • Transformer-XL: Unleashing the Potential of Attention Models
  • 从tcpdump抓包看TCP/IP协议
  • 日剧·日综资源集合(建议收藏)
  • 使用阿里云发布分布式网站,开发时候应该注意什么?
  • 问:在指定的JSON数据中(最外层是数组)根据指定条件拿到匹配到的结果
  • 用Node EJS写一个爬虫脚本每天定时给心爱的她发一封暖心邮件
  • 做一名精致的JavaScripter 01:JavaScript简介
  • ​TypeScript都不会用,也敢说会前端?
  • $GOPATH/go.mod exists but should not goland
  • ()、[]、{}、(())、[[]]命令替换
  • (16)Reactor的测试——响应式Spring的道法术器
  • (8)STL算法之替换
  • (C#)if (this == null)?你在逗我,this 怎么可能为 null!用 IL 编译和反编译看穿一切
  • (c语言版)滑动窗口 给定一个字符串,只包含字母和数字,按要求找出字符串中的最长(连续)子串的长度
  • (板子)A* astar算法,AcWing第k短路+八数码 带注释
  • (编程语言界的丐帮 C#).NET MD5 HASH 哈希 加密 与JAVA 互通
  • (二十三)Flask之高频面试点
  • (附源码)springboot社区居家养老互助服务管理平台 毕业设计 062027
  • (附源码)ssm基于jsp高校选课系统 毕业设计 291627
  • (力扣记录)235. 二叉搜索树的最近公共祖先
  • (四)鸿鹄云架构一服务注册中心
  • (转)C语言家族扩展收藏 (转)C语言家族扩展
  • .NET Core WebAPI中封装Swagger配置
  • .NET/C# 使用反射调用含 ref 或 out 参数的方法
  • .secret勒索病毒数据恢复|金蝶、用友、管家婆、OA、速达、ERP等软件数据库恢复
  • /var/spool/postfix/maildrop 下有大量文件
  • @Async注解的坑,小心