Sinon으로 ES6 모듈 테스트하기

✍🏼 작성일 2019년 11월 10일   
❗️ 참고: 이 글이 작성된 지 이미 일이 지났습니다. 시의성에 유의하세요
🖥  설명:최근 일주일 동안 선임자가 작성한 테스트 코드를 수정하면서 Sinon을 사용하게 되었고, 몇 가지 문제점을 마주했습니다. 이를 기록해 둡니다.

서문

아래에서 설명하는 내용은 Sinon의 spy 인터페이스에 한정되지만, stub 등의 인터페이스에도 동일하게 적용됩니다.

테스트에는 Mocha Sinon Chai 라이브러리/프레임워크가 사용되었으며, 여기서는 소개하지 않습니다.

单独로 내보낸 모듈을 spy하기

다음과 같은 foo.js 모듈이 있고, foo 함수를 내보냅니다.

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

다음과 같은 bar.js 모듈이 이 foo 함수를 가져옵니다.

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

다음과 같은 테스트 케이스 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; // 失败
});
});

여기서 실패하는 이유는 bar.test.js에서 가져온 bar가 함수를 포함하는 변수인데, Sinon은 이 변수 bar만 spy하고 bar에 해당하는 함수는 spy하지 않기 때문입니다. 아래 테스트 케이스도 같은 상황입니다:

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; // 失败
});
});

이런 경우는 다음 섹션의 내용으로 해결할 수 있습니다:

全部로 내보낸 모듈을 spy하기

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; // 成功
});
});

그렇다면 내보내지 않은 함수는 어떻게 테스트할까요? 예를 들어 위의 첫 번째 테스트 케이스에서 foo가 호출되었는지 어떻게 테스트할 수 있을까요?

여기에는 두 가지 방법이 있습니다. 첫 번째는 순수 ES6 방식으로, 가져온 foo 함수를 bar에서 다시 내보내는 것뿐입니다(이렇게 하면 测试未导出函数 전제 조건을 위반하게 됩니다).

두 번째 방법은 babel 플러그인을 사용하는 것입니다. 이 방법의 핵심은 ES6를 ES5로 변환한 후 테스트하는 것입니다. 사용할 플러그인 이름은 babel-plugin-rewire이며 프리셋 타입의 플러그인입니다.

rewire는 말 그대로 다시 연결한다는 의미로, 이 플러그인은 可以将某个模块中导入的但是并未导出却在该模块中调用的函数进行重新导出以方便测试 설명이 다소 복잡할 수 있으니 예제를 보겠습니다(완전판):

foo.js 파일이 있습니다:

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

foo.js를 가져오지만 foo를 내보내지 않는 bar.js 파일이 있습니다:

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

테스트 파일 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; // 成功
});
});

주석에서 언급했듯이, 기본 내보내기는 __Rewire__ 속성을 통해서만 재rewire할 수 있기 때문에 중요합니다. 즉, 아래와 같은 방식으로 테스트할 수 없습니다:

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; // 失败
});
});

앞서 말한 全部导出와 같은 경우도 마찬가지로 불가능합니다:

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; // 失败
});
});

만약 全部导出을 사용해 내보내지 않은 함수를 테스트하고 싶다면, 테스트 대상 파일이 해당 함수를 根作用域 조건으로 포함해야 합니다. 예를 들어:

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

그러면 테스트 파일은 다음과 같이 작성할 수 있습니다:

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

마무리

참고로 이 방법은 함수 테스트뿐만 아니라 기본 내보내기가 함수/클래스인 React 컴포넌트에도 적용됩니다. 이들의 내보내기 본질은 동일하게 함수나 객체이기 때문입니다. React 컴포넌트를 테스트할 때는 enzyme 라이브러리를 사용할 수 있습니다.

- EOF -
이 글의 최초 게시: Sinon으로 ES6 모듈 테스트하기 - Xheldon Blog