在写单元测试的过程中,最痛苦的就是找“监控点”了。
什么是“监控点”?
举个栗子,现在有如下代码 source.js:
import { methodA , methodB } from 'third-party';
if( yourCondition ) {
methodA();
} else {
methodB();
}
其中 third-party.js 是这个样子的:
const input = document.createElement( 'input' );
document.body.appendChild( input );
export function methodA() {
window.alert( 'hello' );
}
export function methodB() {
input.focus();
}
现在我要开始写单元测试了。
为了让 source.js 的代码可被反复执行,我们首先需要将逻辑封装成一个函数(如果 source.js 本身就是一个模块并导出了一些方法,就不需要这一步了):
import { methodA , methodB } from 'third-party';
function main() {
if( yourCondition ) {
methodA();
} else {
methodB();
}
}
if( process.env.NODE_ENV !== 'test' ) {
main();
}
export default main;
然后,我开始写单元测试用例 test.js(这里使用 Jasmine 作为示例,你当然可以使用任何其它你喜欢的测试框架):
import main from 'source.js';
describe( 'source.js' , ()=>{
it( '当 yourCondition 为 true 时,会调用 methodA' );
it( '当 yourCondition 为 false 时,会调用 methodB' );
} );
现在问题来了,我如何知道 methodA 或 methodB 有没有被调用呢?
我的解决方案是,去查看 methodA 和 methodB 的源码,看看有没有什么“监控点”可以被我劫持。
比如说,methodA 里面会调用 window.alert(),那么单元测试就可以这么写:
it( '当 yourCondition 为 true 时,会调用 methodA' , ()=>{
const yourCondition = true;
spyOn( window , 'alert' );
main();
expect( window.alert ).toHaveBeenCalled(); // window.alert 调用了,就说明 methodA 被调用了
});
methodB 里面使用了测试代码访问不到的变量 input,那是不是就没法判断了呢?并不是。
首先我们需要知道的是,input 的 focus() 方法继承自 HTMLElement。当调用 input.focus() 时,其实等同于 HTMLElement.prototype.focus.call( input )。
所以判断 methodB 是否被调用的单元测试可以这样写:
it( '当 yourCondition 为 false 时,会调用 methodB' , ()=>{
const yourCondition = false;
spyOn( HTMLElement.prototype , 'focus' );
main();
expect( HTMLElement.prototype.focus ).toHaveBeenCalled();
});
从上面的例子可以看出,我所说的“监控点”,其实就是源码与测试代码都能访问到的作用域(通常是全局作用域)里的某个方法。我可以通过劫持这些方法判断程序的走向,从而完成单元测试。
即使某些情况下找不到监控点,我们也可以创造监控点——你可能已经注意到 source.js 里的 if( process.env.NODE_ENV !== 'test' ) { } 这段代码了。
依赖注入
我就是在创造监控点的过程中发现“依赖注入”这个名词的。
我在划词翻译中使用 Webpack 进行开发,为了给某一个单元测试创建一个监控点,我使用了 Webpack 的 DefinePlugin,而它被归类为 dependency injection,也就是依赖注入了。
但时间一长,我就发现这种方式的弊端了:我需要层层阅读源码去寻找监控点,如果找不到,还得想办法创建一个。
后来我发现,Webpack 还有一个依赖注入插件 RewirePlugin,它正是我想要的解决方案,但可惜的是,它不支持 ES2015 模块语法。
一个 Babel 插件声称支持 ES2015 模块语法,但直到目前(2016年1月21日)为止,它仍然不能正常使用。
自己动手做依赖注入
在找了很多次监控点之后,我发现我其实可以自己来注入那些依赖。这个方法比起监控点来说,麻烦程度不分上下。
我们可以把 source.js 改写成这个样子:
import { methodA , methodB } from 'third-party';
function main( methodA , methodB ) {
if( yourCondition ) {
methodA();
} else {
methodB();
}
}
if( process.env.NODE_ENV !== 'test' ) {
main( methodA , methodB );
}
export default main;
单元测试则可以这样写:
import main from 'source.js';
describe( 'source.js' , ()=>{
let methodA, methodB;
beforeEach(()=>{
methodA = jasmine.createSpy('methodA');
methodB = jasmine.createSpy('methodB');
});
it( '当 yourCondition 为 true 时,会调用 methodA',()=>{
const yourCondition = true;
main( methodA , methodB );
expect( methodA ).toHaveBeenCalled();
} );
it( '当 yourCondition 为 false 时,会调用 methodB' ,()=>{
const yourCondition = false;
main( methodA , methodB );
expect( methodB ).toHaveBeenCalled();
} );
} );
这种方法的优点是你不必再寻找“监控点”了,缺点就是,如果你的文件依赖过多,你需要创建的假模块也会很多——取决于程序的分支,你要创建的假模块会比你实际使用了的模块数量多得多。
实际编写单元测试的过程中,“监控点”的方式是用的最多的,但我准备逐步使用“依赖注入”来替代“监控点”了,因为“监控点”有一个致命的缺点:万一第三方库里的内部实现变了呢?