(二)Mocha源码阅读: 测试执行流程一之引入用例

1,285 阅读9分钟

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

(二)Mocha源码阅读: 测试执行流程一之引入用例

(三)Mocha源码阅读: 测试执行流程一执行用例

介绍

测试流程我把它分为引入用例和执行用例两部分。引入用例就是把需要执行的用例函数归归类,命好名字。执行用例就是执行这些函数并收集结果。

正文

1. pre-require

在加载用例文件前的准备阶段。 上一篇我们看到最后调用了mocha.run()触发了流程,run也就是入口了。但run之前先来看一下Mocha的constructor。

constructor

// lib/mocha.js
function Mocha(options) {
  ...
  // suite就是包含用例test的一个组,suite可以嵌套suite
  this.suite = new exports.Suite('', new exports.Context());
  this.ui(options.ui);
  ...
  this.reporter(options.reporter, options.reporterOptions);
  ...
}

this.suite是Suite类new出来,第一个参数为空字符串的suite,下面看Suite的构造函数得知 this.suite就是根suite。

// lib/suite.js
function Suite(title, parentContex){
   ...
  this.title = title;
  this.root = !title;
  ...
}

回到mocha.js,下面调用了this.ui(options.ui)。Mocha提供了几种编写测试用例的风格bdd|tdd|qunit|exports, 这几种风格只是写法上有所区别, 这里以默认的bdd来分析:

Mocha.prototype.ui = function(name) {
  name = name || 'bdd';
  // exports.interfaces是require interface文件夹下的几个js文件的模块, exports.interfaces[name]同this._ui就是其中一个模块默认bdd,
  由于调用了this._ui(this.suite)可以知道bdd导出了一个function 
  this._ui = exports.interfaces[name];
  if (!this._ui) {
    try {
      this._ui = require(name);
    } catch (err) {
      throw new Error('invalid interface "' + name + '"');
    }
  }
  this._ui = this._ui(this.suite);

  this.suite.on('pre-require', function(context) {
    exports.afterEach = context.afterEach || context.teardown;
    exports.after = context.after || context.suiteTeardown;
    exports.beforeEach = context.beforeEach || context.setup;
    exports.before = context.before || context.suiteSetup;
    exports.describe = context.describe || context.suite;
    ...
  });

  return this;
};

bdd导出的function

//lib/interfaces/bdd.js
module.exports = function bddInterface(suite) {
  // suite就是传过来的根suite
  var suites = [suite];

  suite.on('pre-require', function(context, file, mocha) {
    ...
  });
};

函数主要监听了suite的pre-require事件,我们在这里还有mocha.ui里面都看到监听了这个事件。它其实就是一个订阅发布模式,我们看Suite是如何实现它的:

// lib/suite.js

var EventEmitter = require('events').EventEmitter;
...
inherits(Suite, EventEmitter);

直接继承了events模块的EventEmitter,inherits就是调用的node util模块的inherits

如果看node关于util.inherits的文档, 可以发现它是不推荐用这方法而是推荐es6的class和extends。原因在这里, 我总结一下大意是inherits的实现是通过Object.setPrototypeOf(ctor.prototype, superCtor.prototype);这样存在一个情况是如果用isPrototypeOf来判断子类和父类的constructor是会返回false的, 也就是ctor.isPrototypeOf(superCtor) === false. 而用class extends出来的是true

回到bdd.js我们根据这个on可以推测出有什么地方肯定调用了suite.emit('pre-require')从而触发了这个回调,回调的内容我们后面看

我们再往回到mocha.ui发现监听完pre-require事件后就执行完了,再往上到Mocha的constructor,可以看到执行了this.reporter()

var reporters = require('./reporters');
...
Mocha.prototype.reporter = function(reporter, reporterOptions) {
//代码很容易看懂,都贴出来是对这段代码所体现出的设计完整性,友好性和错误提示很值得借鉴。
  if (typeof reporter === 'function') {
    this._reporter = reporter;
  } else {
    reporter = reporter || 'spec';
    var _reporter;
    // Try to load a built-in reporter.
    if (reporters[reporter]) {
      _reporter = reporters[reporter];
    }
    // Try to load reporters from process.cwd() and node_modules
    if (!_reporter) {
      try {
        _reporter = require(reporter);
      } catch (err) {
        if (err.message.indexOf('Cannot find module') !== -1) {
          // Try to load reporters from a path (absolute or relative)
          try {
            _reporter = require(path.resolve(process.cwd(), reporter));
          } catch (_err) {
            err.message.indexOf('Cannot find module') !== -1
              ? console.warn('"' + reporter + '" reporter not found')
              : console.warn(
                  '"' +
                    reporter +
                    '" reporter blew up with error:\n' +
                    err.stack
                );
          }
        } else {
          console.warn(
            '"' + reporter + '" reporter blew up with error:\n' + err.stack
          );
        }
      }
    }
    if (!_reporter && reporter === 'teamcity') {
      console.warn(
        'The Teamcity reporter was moved to a package named ' +
          'mocha-teamcity-reporter ' +
          '(https://npmjs.org/package/mocha-teamcity-reporter).'
      );
    }
    if (!_reporter) {
      throw new Error('invalid reporter "' + reporter + '"');
    }
    this._reporter = _reporter;
  }
  this.options.reporterOptions = reporterOptions;
  return this;
};

reporters是在lib/reporters引入的一堆reporter function,这里会匹配一个存入this._reporter。

constructor里面值得分析的就这么多,下面开始run

mocha.run

Mocha.prototype.run = function(fn) {
  if (this.files.length) {
    this.loadFiles();
  }
   ...
};

this.files在Mocha实例化当中没有看到赋值,它的赋值是在bin中做的,上一篇可以看到, 是找到所有files后 直接mocha.files=files。这里似乎有一点偷懒, mocha很多option的赋值都是个function比如:

Mocha.prototype.invert = function() {
  this.options.invert = true;
  return this;
};

这个files虽然不是属于实例下什么子属性的,但直接赋值看起来有些突兀的。

回到run,调用了loadFiles

Mocha.prototype.loadFiles = function(fn) {
  var self = this;
  var suite = this.suite;
  this.files.forEach(function(file) {
    file = path.resolve(file);
    suite.emit('pre-require', global, file, self);
    suite.emit('require', require(file), file, self);
    suite.emit('post-require', global, file, self);
  });
  fn && fn();
};

这里我们终于看到每个文件都依次从根suite触发了emit的'pre-require', 这一emit标志着流程的开始。刚才我们在lib/interfaces/bdd.js中看到一个绑定pre-require的,这个也是我们能直接调用describe, it的关键

//lib/interfaces/bdd.js
module.exports = function bddInterface(suite) {
  var suites = [suite];

  suite.on('pre-require', function(context, file, mocha) {
    var common = require('./common')(suites, context, mocha);
    ...
  });
};

首先我们就看看这个'./common'返回的函数做了什么

module.exports = function(suites, context, mocha) {
  return {
    runWithSuite: function runWithSuite(suite) {
      ...
    },

    before: function(name, fn) {
      ...
    },

    after: function(name, fn) {
      ...
    },

    beforeEach: function(name, fn) {
      ...
    },
    afterEach: function(name, fn) {
      ...
    },

    suite: {
      only: function only(opts) {
        ...
      },
      skip: function skip(opts) {
        ...
      },

      create: function create(opts) {
        ...
      }
    },
    test: {
    ...
    }
  };
};

它返回一个包含很多函数的对象,而之所以这么返回应该是为了保存传进来的三个参数,使得这些函数可以调用

//lib/interfaces/bdd.js
module.exports = function bddInterface(suite) {
  var suites = [suite];

  suite.on('pre-require', function(context, file, mocha) {
    var common = require('./common')(suites, context, mocha);
    给context加了一些钩子函数
    context.before = common.before;
    context.after = common.after;
    context.beforeEach = common.beforeEach;
    context.afterEach = common.afterEach;
    context.run = mocha.options.delay && common.runWithSuite(suite);
    ...
  });
};
//lib/interfaces/bdd.js
module.exports = function bddInterface(suite) {
  var suites = [suite];

  suite.on('pre-require', function(context, file, mocha) {
    ...
    context.afterEach = common.afterEach;
    context.run = mocha.options.delay && common.runWithSuite(suite);
    // 给context绑定了写用例包含的describe, it, skip等
    context.describe = context.context = function(title, fn) {
      ...
    };

    context.xdescribe = context.xcontext = context.describe.skip = function(
      title,
      fn
    ) {
      ...
    };

    context.describe.only = function(title, fn) {
      ...
    };

    context.it = context.specify = function(title, fn) {
      ...
    };

    context.it.only = function(title, fn) {
      ...
    };

    context.xit = context.xspecify = context.it.skip = function(title) {
      ...
    };

    context.it.retries = function(n) {
      ...
    };
  });
};

这里这个context我们由emit看知道是global对象,这个对象如果在node下相当于browser下的window,作用就是我们现在给global加了这些属性,后面的程序执行时就可以直接调用相当于全局的属性和方法。 这也是为什么测试文件可以不用引入就直接写describe, it等。 由此也可以得出测试文件还没有执行,他们执行时就会走到这里绑定的函数里。 pre-require到这里就结束了,可以看到做的工作就是给全局绑定上各种钩子和其它方法。

2. require test

// lib/mocha.js
Mocha.prototype.loadFiles = function(fn) {
  var self = this;
  var suite = this.suite;
  this.files.forEach(function(file) {
    file = path.resolve(file);
    suite.emit('pre-require', global, file, self);
    suite.emit('require', require(file), file, self);
    suite.emit('post-require', global, file, self);
  });
  fn && fn();
};

回到emit, 'pre-require'结束后,接下来的两个事件'require'和'post-require'我在框架中并没有找到监听者。不过这里比较有用的是在'require'中调用了require(file)。也就是从这里开始每个文件依次require测试用例。

// some test file written by user
describe('suite1', function(){
  describe('suite2', function(){
    it('test title', function(){
      // test
    })
  })
})

假定一个测试文件是这样的结构。 可以看到我们并不需要这些文件export什么东西,它们只要调用了describe, it等在global绑定的函数,就会走到mocha的内部。而让它们执行只需要require就可以了。 下面看这些用例是如何存储到mocha内部的

describe

// lib/interfaces/bdd.js
...
    context.describe = context.context = function(title, fn) {
    //  title此处上例的为suite1, fn是嵌套的describe, it等
      return common.suite.create({
        title: title,
        file: file,
        fn: fn
      });
    };
...

这里回到bdd.js可以看到是调了common.suite.create

...
create: function create(opts) {
        var suite = Suite.create(suites[0], opts.title);
        ...
      }
     ...

上面讲到common通过把一些函数放到对象返回的形式保存了三个参数, suites是其中一个,我们以上面写的测试文件为例。opts.title就是第一个describe调用'suite1' suites暂时只有一个suite,就是根suite,下面看Suite.create

// lib/suite.js
...
exports.create = function(parent, title) {
  var suite = new Suite(title, parent.ctx);
  suite.parent = parent;
  title = suite.fullTitle(); // 这一行好像没什么用。。
  parent.addSuite(suite);
  return suite;
};
...

new基本上初始化了一堆实例属性, suite.parent指回了根suite, 然后parent也就是根suite调用了addSuite

Suite.prototype.addSuite = function(suite) {
  suite.parent = this;
  suite.timeout(this.timeout());
  //把parent的option如timeout, retries等设置到child suite上
  suite.retries(this.retries());
  suite.enableTimeouts(this.enableTimeouts());
  suite.slow(this.slow());
  suite.bail(this.bail());
  
  this.suites.push(suite);
  this.emit('suite', suite);
  return this;
};

这里注意两个: suite.parent = this其实和create当中suite.parent = parent是重复的。。 this.suites.push(suite),把子suite也就是title为suite1的suite放到根suite的suites里。 再回到common.suite.create:

// lib/interfaces/common.js
...
create: function create(opts) {
        var suite = Suite.create(suites[0], opts.title);
        suite.pending = Boolean(opts.pending);
        suite.file = opts.file;
        suites.unshift(suite);
        ...
      }
     ...

新创建的suite放在了suites的第一个

      create: function create(opts) {
        var suite = Suite.create(suites[0], opts.title);
        ...
        suites.unshift(suite);
        ...
        if (typeof opts.fn === 'function') {
          opts.fn.call(suite);
          suites.shift();
        } ...

        return suite;
      }

opts.fn就是我们调用describe时传到第二个参数的方法,这里对方法进行了调用, this指向包含的suite。

  • 注意: 一般我们的方法里面可能会调用describe创建子suite或调用it方法创建test用例,如果是describe的话相当于递归调用了create方法, 而此时suites由于unshift了新创建的suite,suites[0]就是新创建的suite, 这样递归调用中创建的child suite的parent就正确的指向了父suite而不是根suite。 it后面再看。 由于describe和it都是同步运行,所以如果有嵌套,suites.shift()会一直压在后面,这样一层层运行,等子suite和test运行完, suites就把suite shift走了。
      create: function create(opts) {
        ...
        if (typeof opts.fn === 'function') {
          ...
        } else if (typeof opts.fn === 'undefined' && !suite.pending) {
          throw new Error(
            'Suite "' +
              suite.fullTitle() +
              '" was defined but no callback was supplied. Supply a callback or explicitly skip the suite.'
          );
        } else if (!opts.fn && suite.pending) {
          suites.shift();
        }

        return suite;
      }

后面两个判断是针对suite跳过的情况。

以上是创建suite,再看创建test用到的it

    context.it = context.specify = function(title, fn) {
      var suite = suites[0];
      ...
      var test = new Test(title, fn);
      test.file = file;
      suite.addTest(test);
      return test;
    };

这里的suites其实就是传入common中的suites, 这里我们考虑在describe调用时调用了it, 则suites[0]就是父suite, 然后new了一个Test, 调用父suite的addTest方法。 先看new的Test

// lib/test.js
function Test(title, fn) {
  if (!isString(title)) {
    throw new Error(
      'Test `title` should be a "string" but "' +
        typeof title +
        '" was given instead.'
    );
  }
  Runnable.call(this, title, fn);
  this.pending = !fn;
  this.type = 'test';
}
utils.inherits(Test, Runnable);

Test通过utils.inherits(上文讲过)继承了Runnable类的原型,然后在构造函数中Runnable.call(this, title, fn)获得实例属性。此外Test还包含一个clone的原型方法,暂且不表。 那么Runnable是什么呢?为什么Test要继承它? Runnable字面看就是能运行的, test是包含了很多条语句的函数,而Mocha运行时还提供了很多钩子函数也就是Hook, hook同样也是些能运行的语句所以也会继承Runnable。 再来看Runnable

// lib/runnable.js

function Runnable(title, fn) {
  this.title = title;
  this.fn = fn;
  this.body = (fn || '').toString();
  this.async = fn && fn.length;
  this.sync = !this.async;
  this._timeout = 2000;
  this._slow = 75;
 ...
}

utils.inherits(Runnable, EventEmitter);

这里fn.toString()可以把一个function的内容取出来

  • this.async = fn && fn.length 可以说是一个黑科技了 Mocha在test中提供了异步调用的形式如 describe('async', it('async test', function(done){ // 异步 setTimeout(done, 1000) })) describe('sync', it('async test', function(){ // 同步 })) 如果用户调用done就算异步,done回调会等待调用后来知晓用例运行结束 那么我们是怎么知道里面的回调是否用了done呢?就是通过fn.length, 回调中接收几个参数fn.length就是几, 所以如果参数中接收了done, length就为1, async就为true。

Runnable还有很多其它原型方法,暂时先不需要管

回到it方法,下面调用了suite.addTest(test)

// lib/suite.js
Suite.prototype.addTest = function(test) {
  test.parent = this;
  test.timeout(this.timeout());
  test.retries(this.retries());
  test.enableTimeouts(this.enableTimeouts());
  test.slow(this.slow());
  test.ctx = this.ctx;
  this.tests.push(test);
  this.emit('test', test);
  return this;
};

这里主要把suite的配置和ctx执行上下文赋给了test, 同时把它放入了自己的tests中。

至此,主要的引入test用例流程算讲完了。 我们总结一下大概是

  1. 在global中加入些全局函数如describe, it
  2. 依次require文件
  3. 文件加载运行时调用describe, it
  4. 生成suite数据结构 Suite: { suites: [{ //suite tests: [{ // test }] }] }