qunit、mocha、jest是javascript单元测试框架中比较流行的几个。
单元测试强调的是“独立性”,验证这个单元自身是否能正常工作。测试用例尽量不要依赖外部条件,必须依赖的外部条件不具备时自己mock模拟,不需要等待别的同事提供条件给你。
集成测试强调的是“协作”,在正式工作环境中,验证全部单元是否能彼此匹配的正常工作。
但在实践中,单元对外部的依赖是常常发生的,如果都要自己mock一个环境,工作量就比较大,因此想在集成的环境中来做单元测试。即,问题是:qunit、mocha、jest能做集成测试吗? 可能要关注的几个问题点:
- 1、因为集成测试环境(webserver)是在主进程运行,要求测试用例不能再开子进程运行。
- 2、测试用例不要在vm这种沙箱中运行,因为沙箱内外环境很难保持完全一致。写一个简单代码验证一下这种差异:
let vm=require('vm');
let ctx=vm.createContext({
emptyArray:[],
console
});
vm.runInContext(`
console.log([] instanceof Array);//......true
console.log(emptyArray instanceof Array);//......false
`,ctx);
- 3、测试用例中的外部依赖自己显式引用。
- 4、测试前如何等待你的集成环境运行起来。
假设我们写了一个函数库yjUtils.js:
var yjUtils = {
/**
* 简单对象,普通对象,即:通过 "{}" 或者 "new Object" 创建的对象。有原型。
* 不包括pure object
* @param {*} value
* @returns
*/
isPlainObject(value) {
if (!value) return false;
if (typeof value !== 'object') return false;
let proto = value;
while (Object.getPrototypeOf(proto) !== null) {
proto = Object.getPrototypeOf(proto);
}
return Object.getPrototypeOf(value) === proto;
},
/**
* 纯粹对象,用Object.create(null)创建的对象,无原型。
* @param {any} value
*/
isPureObject(value){
if (!value) return false;
return (typeof value==='object') && !Object.getPrototypeOf(value);
},
isObject(value){
/**
* class的typeof为'function'
* 如果要包含pure object,用typeof value=='object'
* 否则用value instanceof Object
* 注意:Array.isArray(value)比value instanceof Array更可靠。
* 注意:value instanceof Date不可靠:尽管 instanceof 可以很好地工作,但在某些情况下,Object.prototype.toString.call 更可靠。特别是在跨窗口或跨 iframe 的环境中,不同窗口的 Date 构造函数可能是不同的,这会导致 instanceof 失败。
* 注意:value instanceof RegExp不可靠。
*/
console.log('...yjUtils.js,inner [] is Array?',process.pid,[] instanceof Array);
console.log('...yjUtils.js,outer [] is Array?',value,value instanceof Array);
if (!value) return false;
return (typeof value==='object') &&
!(Array.isArray(value)) &&
(Object.prototype.toString.call(value) !== '[object Date]') &&
(Object.prototype.toString.call(value) !== '[object RegExp]');
},
/**
* 是否是引用类型,如:object,array,function,class,regexp,date
* @param {any} value
* @returns
*/
isRefType(value){
if (!value) return false;
return (typeof value==='object') || (typeof value==='function');
},
......
}
module.exports = yjUtils;
我们用jest编写一个测试用例testcase_yjUtils.js:
//jest测试用例
function sum(a,b){}
class YJDemo{}
class YJDate extends Date{}
let sep='.........................................................................';
function getTestInfo(what,deal,toBe){
let s=what;
s=s+sep.substring(0,30-s.length);
s=s+deal;
s=s+sep.substring(0,60-s.length);
s=s+toBe;
return s;
}
let testFuncs=['isPureObject','isPlainObject','isObject','isRefType'];
let testCases=[
['new Object()' ,new Object() ,false,true ,true ,true],
['Object.create(null)',Object.create(null),true ,false,true ,true],
['{}' ,{} ,false,true ,true ,true],
['null' ,null ,false,false,false,false],
['undefined' ,undefined ,false,false,false,false],
['[]' ,[] ,false,false,false,true],
['array:[1,2,3]' ,[1,2,3] ,false,false,false,true],
['string:"abc"' ,'abc' ,false,false,false,false],
['string:""' ,'' ,false,false,false,false],
['number:189' ,189 ,false,false,false,false],
['number:0' ,0 ,false,false,false,false],
['number:-1' ,-1 ,false,false,false,false],
['boolean:false' ,false ,false,false,false,false],
['boolean:true' ,false ,false,false,false,false],
['function:sum' ,sum ,false,false,false,true],
['class:YJDemo' ,YJDemo ,false,false,false,true],
['new YJDemo()' ,new YJDemo() ,false,false,true ,true],
['Object' ,Object ,false,false,false,true],
['String' ,String ,false,false,false,true],
['RegExp' ,RegExp ,false,false,false,true],
['RegExp:/ a/' ,/ a/ ,false,false,false,true],
['Symbol' ,Symbol ,false,false,false,true],
['Symbol()' ,Symbol() ,false,false,false,false],
['new Date()' ,new Date() ,false,false,false,true],
['new YJDate()' ,new YJDate() ,false,false,false,true],
]
for(let i=0;i<testCases.length;i++){
let testCase=testCases[i];
describe(testCase[0],function(){
for(let j=0;j<testFuncs.length;j++){
let result=yjUtils[testFuncs[j]](testCase[1]);
let toBe=testCase[2+j];
let info=getTestInfo(testCase[0],testFuncs[j],toBe);
test(info,function(){
expect(result).toBe(toBe);
})
}
});
}
qunit写法稍微不同:
//qunit测试用例
......
for(let i=0;i<testCases.length;i++){
let testCase=testCases[i];
QUnit.module(testCase[0]);
for(let j=0;j<testFuncs.length;j++){
let result=yjUtils[testFuncs[j]](testCase[1]);
let toBe=testCase[2+j];
let info=getTestInfo(testCase[0],testFuncs[j],toBe);
QUnit.test(info,function(assert){
assert.equal(result,toBe);
})
}
}
mocha写法又稍微不同:
//mocha测试用例
var assert = require("assert");
......
for(let i=0;i<testCases.length;i++){
let testCase=testCases[i];
describe(testCase[0],function(){
for(let j=0;j<testFuncs.length;j++){
let result=yjUtils[testFuncs[j]](testCase[1]);
let toBe=testCase[2+j];
let info=getTestInfo(testCase[0],testFuncs[j],toBe);
it(info,function(){
assert.equal(result,toBe);
})
}
});
}
我们看到,在测试用例testcase_yjUtils.js中,这里有2个特别注意的地方:
- 没有引用来源的隐式使用了测试框架的函数:describe、test、expect、QUnit、it;
- 隐式使用了yjUtils这个变量。测试用例中应该是自己解决yjUtils的引用?还是要依赖外部?单元测试的时候依赖外部(外部供给会有多种方法,如在浏览器中<script src=''/>方式引入;nodejs中require引入;mock模拟引入);集成测试的时候,最好自己显式引用。
1、测试框架如何处理公共变量
测试框架是如何让这些没有引用来源的函数正确执行的呢?不同的框架有不同的实现方法,可能的方式是:
1. 挂在global上;
• qunit v2.22.0,是在\qunit\src\cli\run.js中:
async function run (args, options) {
......
QUnit = requireQUnit();
......
// TODO: Enable mode where QUnit is not auto-injected, but other setup is
// still done automatically.
global.QUnit = QUnit;
......
• mocha v10.8.2,是在\mocha\lib\interfaces\bdd.js中,context传入的就是global对象:
module.exports = function bddInterface(suite) {
......
suite.on(EVENT_FILE_PRE_REQUIRE, function (context, file, mocha) {
......
context.it = context.specify = function (title, fn) {
var suite = suites[0];
if (suite.isPending()) {
fn = null;
}
var test = new Test(title, fn);
test.file = file;
suite.addTest(test);
return test;
};
• jest v29.7.0,是在\jest\node_modules\jest-environment-node\build\index.js中:
class NodeEnvironment {
......
constructor(config, _context) {
const {projectConfig} = config;
this.context = (0, _vm().createContext)();
const global = (0, _vm().runInContext)(
'this',
Object.assign(this.context, projectConfig.testEnvironmentOptions)
);
this.global = global;
const contextGlobals = new Set(Object.getOwnPropertyNames(global));
for (const [nodeGlobalsKey, descriptor] of nodeGlobals) {
if (!contextGlobals.has(nodeGlobalsKey)) {
if (descriptor.configurable) {
Object.defineProperty(global, nodeGlobalsKey, {
configurable: true,
enumerable: descriptor.enumerable,
get() {
const value = globalThis[nodeGlobalsKey];
// override lazy getter
Object.defineProperty(global, nodeGlobalsKey, {
configurable: true,
enumerable: descriptor.enumerable,
value,
writable: true
});
return value;
},
set(value) {
// override lazy getter
Object.defineProperty(global, nodeGlobalsKey, {
configurable: true,
enumerable: descriptor.enumerable,
value,
writable: true
});
}
});
} else if ('value' in descriptor) {
Object.defineProperty(global, nodeGlobalsKey, {
configurable: false,
enumerable: descriptor.enumerable,
value: descriptor.value,
writable: descriptor.writable
});
} else {
Object.defineProperty(global, nodeGlobalsKey, {
configurable: false,
enumerable: descriptor.enumerable,
get: descriptor.get,
set: descriptor.set
});
}
}
}
// @ts-expect-error - Buffer and gc is "missing"
global.global = global;
2. 定义为局部变量,用eval函数执行测试用例代码;
3. 使用vm建立沙箱;
• jest v29.7.0,把一些函数挂在了global后,同时用vm建立了沙箱,用runInContext执行代码,在\jest\node_modules\jest-runtime\build\index.js中:
class Runtime {
......
_execModule(localModule, options, moduleRegistry, from, moduleName) {
......
const transformedCode = this.transformFile(filename, options);
let compiledFunction = null;
const script = this.createScriptFromCode(transformedCode, filename);
let runScript = null;
const vmContext = this._environment.getVmContext();
if (vmContext) {
runScript = script.runInContext(vmContext, {
filename
});
}
if (runScript !== null) {
compiledFunction = runScript[EVAL_RESULT_VARIABLE];
}
if (compiledFunction === null) {
this._logFormattedReferenceError(
'You are trying to `import` a file after the Jest environment has been torn down.'
);
process.exitCode = 1;
return;
}
......
从代码看,jest的测试用例没办法绕过vm沙箱,只能在vm沙箱中执行,这里要特别注意沙箱内外的细微差异。
2、如何在测试前启动你的集成环境
• qunit v2.22.0,用命令执行:qunit ./foil-run.cjs
//foil-run.cjs
QUnit.begin(function(data){
console.log('......qunit begin......',data);
/**
* 只有QUnit.begin会等待异步函数执行完毕
*/
return new Promise(function(resolve){
let foilStart=require('../_start.js');
//启动foil webserver
foilStart(function(){
//foil-webserver启动完成后,开始执行测试用例
//testCase_yjUtils.js内部自己显式引用yjUtils.js
require('./testcase/testCase_yjUtils.js');
resolve();
});
});
});
QUnit.done(function(data){
process.exit();
});
• mocha v10.8.2,用命令行执行:mocha foil-run.js --delay
注意:一定要加--delay参数。
//foil-run.js
let foilStart=require('../_start.js');
foilStart(function(){
require('./testcase/testcase_yjUtils.js');
run();
after(function(){
process.exit();
});
});
• jest v29.7.0,用命令行执行:jest
//jest.config.js
module.exports = {
maxWorkers:1,//设置为1,在主进程执行
testMatch:['**/testcase/testcase_yjUtils.js'],
globalSetup:'./foil-setup.js',
globalTeardown:'./foil-teardown.js'
} //foil-setup.js
module.exports = async function (globalConfig, projectConfig) {
let start=require('../_start.js');
await new Promise(function(resolve){
start(resolve);
});
}
总结:qunit、mocha可以用工作环境作集成测试,jest因为运行在vm沙箱中,直接用工作环境作集成测试时要特别注意沙箱带来的的微小差异。