Testing ES6 Modules with Sinon

✍🏼 Written on Nov 10, 2019   
❗️ Note: it has been days since this article was written, please be aware of its timeliness
🖥  Note:Over the past week, while fixing legacy tests, I encountered some issues when using Sinon, so I decided to document them here.

Preface

Although the following only discusses Sinon’s spy interface, it equally applies to interfaces like stub.

The tests utilize the Mocha Sinon Chai libraries/frameworks, which won’t be introduced here.

Spying on a 单独-exported module

Consider the following module foo.js, which exports a foo function:

1
2
3
export function foo {
return 'foo';
}

Here’s a module bar.js that imports this foo function:

1
2
3
4
import { foo } from './foo';
export function bar {
return 'bar' + foo();
}

And here’s a test case bar.test.js:

1
2
3
4
5
6
7
8
9
import { bar } from './bar';
describe('ES6 导出模块测试-单独导出', () => {
it('应该能够 spy bar', () => {
const spy = sinon.spy(bar);
const result = bar();

expect(spy.called).to.equal.true; // 失败
});
});

The reason this fails is that the imported bar in bar.test.js is a variable containing a function, whereas Sinon only spies on the variable bar itself, not the function it references. The following test case exhibits the same issue:

1
2
3
4
5
6
7
8
9
import { bar as baz } from './bar';
describe('ES6 导出模块测试-单独导出', () => {
it('应该能够 spy bar', () => {
const spy = sinon.spy(baz);
const result = baz();

expect(spy.called).to.equal.true; // 失败
});
});

This scenario can be resolved using the approach described in the next section:

Spying on a 全部-exported module

1
2
3
4
5
6
7
8
9
import * as allBar from './bar';
describe('ES6 导出模块测试-全部导出', () => {
it('应该能够 spy bar', () => {
const spy = sinon.spy(allBar, 'bar');
const result = allBar.bar();

expect(spy.called).to.equal.true; // 成功
});
});

But how do we test non-exported functions? For example, in the first test case above, how can we verify whether foo was called?

There are two methods. The first, purely ES6 approach, would require re-exporting the imported foo function within bar (which violates the 测试未导出函数 precondition).

The second method involves using a Babel plugin. This approach essentially converts ES6 to ES5 for testing. The plugin is called babel-plugin-rewire and is a preset-type plugin.

“Rewire,” as the name suggests, means to reconnect or rewire. In other words, this plugin 可以将某个模块中导入的但是并未导出却在该模块中调用的函数进行重新导出以方便测试. It’s a bit convoluted to explain, so let’s look at a complete example:

Here’s a foo.js:

1
2
3
export function foo() {
return 'foo';
}

Here’s a bar.js that imports foo.js but doesn’t export foo:

1
2
3
4
5
import { foo } from './foo';
export default function bar() {
// 注意此处 默认导出 export default 很重要, 原因下面说
return 'bar' + foo();
}

And the test file bar.test.js:

1
2
3
4
5
6
7
8
9
10
import bar from './bar'; // 这里叫 bar , 其实叫任何名字都可以, 因为是默认导出
describe('ES6 导出模块测试-默认导出', () => {
it('应该能够 spy bar', () => {
const spy = sinon.spy();
bar.__Rewire__('foo', spy); // 注意这里的用法和 __Rewire__ 方法
const result = bar();

expect(spy.called).to.equal.true; // 成功
});
});

As mentioned in the comments, default exports are important because re-rewire can only be performed through the __Rewire__ property on the default export, meaning testing cannot be done as shown below:

1
2
3
4
5
6
7
8
9
10
import { bar } from './bar'; // 假设 bar.js 中 bar 函数不是默认导出
describe('ES6 导出模块测试-默认导出', () => {
it('应该能够 spy bar', () => {
const spy = sinon.spy();
bar.__Rewire__('foo', spy); // 这里会报 __Rewire__ 不是函数
const result = bar();

expect(spy.called).to.equal.true; // 失败
});
});

Even something like the previously mentioned 全部导出 cannot be achieved:

1
2
3
4
5
6
7
8
9
10
11
12
import * as allBar from './bar'; // 假设 bar.js 中 bar 函数不是默认导出
describe('ES6 导出模块测试-默认导出', () => {
it('应该能够 spy bar', () => {
const spy = sinon.spy();
allBar.__Rewire__('foo', spy); // 这里不会报错, 但是测试不通过, 因为 allBar 上并没有 foo 方法(因为是在 bar 函数中调用的)
// 或者下面也不行会报 __Rewire__ 不是函数错误, 因为 __Rewire__ 并不重新 rewire 全部导出对象上的属性
// allBar.bar.__Rewire__('foo', spy);
const result = bar();

expect(spy.called).to.equal.true; // 失败
});
});

If you want to use 全部导出 to test unexported functions, the file being tested must meet the condition that the function is 根作用域, for example:

1
2
3
4
5
import { foo } from './foo';
export foo;
export function bar () {
return 'bar' + foo();
}

Then the test file can be written like this:

1
2
3
4
import * as allBar from './bar';
// 省略无关部分
const spy = sinon.spy();
allBar.__Rewire__('foo', spy); // 这么做就对了, foo 函数位于 allBar 的根作用域中

Conclusion

Note that this applies not only to function testing but also to React components with default exports as functions/classes, since their export nature is the same—both are functions or objects. For testing React components, you might use the enzyme library.

- EOF -
Originally published at: Testing ES6 Modules with Sinon - Xheldon Blog