IO 작업을 수행한다거나 API 호출 제한이 걸리는 종류의 함수들은 실제 테스트에서 호출하기 부담스럽다. 따라서 테스트하기 어려운 함수들의 경우 모의 함수(Mock Function)으로 만들어(이를 Mocking
이라고 한다) 테스트를 진행할 수 있다.
이번 포스트에서는 Jest에서 모킹하는 방법을 알아본다
const mockFn = jest.fn()
이렇게 생성된 모의 함수는 호출 시 undefined
를 반환한다. 만약 인자로 함수를 넘긴다면 해당 함수가 실행된 결과를 반환한다
모의 함수는 특별한 .mock
프로퍼티를 가지고 함수가 호출된 방법과 반환된 함수에 대한 데이터가 담긴 객체가 저장되어 있다
모의 함수가 호출될 때 넘겨진 매개변수들이 저장 배열에 저장되어 있다
mockFn(1, 2)
mockFn('1', '2')
위와 같은 순서로 모의 함수를 실행시켰을 때 calls 의 값은 아래와 같다
mock.calls = [[1, 2], ['1', '2']]
따라서 모의 함수에 어떤 매개변수가 넘겨져 실행되었는지를 확인할 수 있고, 배열이기 때문에 length
프로퍼티를 이용해 모의 함수가 실행되었는지를 확인할 수도 있다
모의 함수가 반환한 결과를 담은 객체가 저장된 배열이다. 결과 객체는 { type: 'return' | 'throw', value: any }
이와 같다
[
{
type: 'return',
value: 'result1',
},
{
type: 'throw',
value: {
/* Error instance */
},
},
{
type: 'return',
value: 'result2',
},
];
모의 함수가 호출되었던 컨텍스트들이 배열에 저장되어 있다
const context1 = {
a: 1
}
const context2 = {
a: 2
}
const mockFn = jest.fn(function(this: any){
console.log(this.a)
})
mockFn.call(context1) // 1
mockFn.call(context2) // 2
expect(mockFn.mock.contexts[0]).toEqual(context1)
expect(mockFn.mock.contexts[1]).toEqual(context2)
모의 함수가 new
키워드로 초기화(인스턴스화) 되었을 떄의 객체를 배열에 담고 있다
const mockFn = jest.fn()
const a = new mockFn()
const b = new mockFn()
mockFn.mock.instances[0] === a; // true
mockFn.mock.instances[1] === b; // true
모의 함수가 마지막으로 호출될 때의 매개변수를 저장한 배열이다. 즉,calls[-1]
모의 함수가 특정한 동작을 하도록 로직을 전달할 수도 있지만 단순히 특정한 결과만 반환하게 하는 것이 필요할 때가 있다. 이를 stub
이라고 하단
동기 함수를 stub
하는데 사용한다. mockReturnValueOnce
를 사용하면 첫 호출 시에만 지정된 결과를 반환하고 체이닝을 통해 n번째 호출 결과를 지정할 수 있다
test('stub', () => {
const mockFn = jest.fn()
const mockFnReturnOnce = jest.fn()
mockFn.mockReturnValue('return value')
mockFnReturnOnce.mockReturnValueOnce('return once1') // 체이닝을 통해 지정 가능
.mockReturnValueOnce('return once2')
.mockReturnValueOnce('return once3')
console.log(mockFn()) // return value
console.log(mockFnReturnOnce()) // return once1
console.log(mockFnReturnOnce()) // return once2
console.log(mockFnReturnOnce()) // return once3
})
비동기 함수를 stub
하는데 사용한다. 마찬가지로 mockResolvedValueOnce
함수가 존재한다. 프로미스를 반환하기 때문에 비동기 처리가 필요하다
test('stub', () => {
// 타입스크립트에서는 jest.fn()의 반환값을 명시해주어야 에러 발생 X
const asyncMockFn = jest.fn<() => Promise<string>>().mockResolvedValue('async return')
const result = await asyncMockFn()
expect(result).toBe('async return')
})
테스트를 작성할 때 외부 모듈이나 모듈 전체를 모킹할 필요할 때가 있다. jest.mock()
함수를 통해 모듈을 한번에 모킹할 수 있다
import { jest } from '@jest/globals'
import axios from "axios";
jest.mock('axios')
test('module mocking', async () => {
axios.get.mockResolvedValue('mock return')
const resp = await mockAxios.get('/')
expect(resp).toBe('mock return')
})
자바스크립트라면 문제가 생기지 않지만 타입스크립트에서 위의 코드는 에러가 발생한다. 그 이유는 jest.mock
을 통해 외부 모듈을 모킹하기는 했지만 타입 캐스팅이 일어나지 않아 타입스크립트는 원래의 타입으로 알고 있기 때문에 mockResolvedValue
함수가 존재하지 않다는 에러를 발생시킨다
이러한 문제를 해결하기 위해서는 별도의 변수에 모킹한 모듈을 타입 캐스팅해주어야만 테스트가 정상적으로 진행된다
import { jest } from '@jest/globals'
import axios from "axios";
jest.mock('axios')
const mockAxios = axios as jest.Mocked<typeof axios> // 타입 캐스팅
test('module mocking', async () => {
axios.get.mockResolvedValue('mock return') // 에러 발생 X
const resp = await mockAxios.get('/')
expect(resp).toBe('mock return')
})
하지만 매번 모듈을 타입캐스팅해주는 것은 불편하고 번거롭다. jest-mock
라이브러리의 mocked(module, { shallow?: boolean })
함수를 사용하면 편하게 문제를 해결할 수 있다
$ npm install -D jest-mock
import { jest } from '@jest/globals'
import { mocked } from 'jest-mock'
import axios from "axios";
jest.mock('axios')
const mockAxios = mocked(axios)
test('module mocking', async () => {
axios.get.mockResolvedValue('mock return')
const resp = await mockAxios.get('/')
expect(resp).toBe('mock return')
})
mocked() 함수는 원래 ts-jest 라이브러리의 27.x <= version 에 존재하는 함수였다. 하지만 28버전으로 업데이트 되면서 삭제되었고 jest-mock 라이브러리로 옮겨졌다. 만약 27버전 이하의 ts-jest를 사용하고 있다면
import { mocked } from 'ts-jest/utils'
를 사용하면 된다
추가
jest.mocked(moduleName: string, deep: boolean | undefined = false)
라는 메소드가jest-mock
라이브러리의mocked
와 같은 역할을 한다. 굳이 라이브러리를 설치할 필요가 없으므로jest.mocked()
를 사용하는 것이 나아보인다
모듈의 모든 부분을 모킹하는 것이 아니라 일부만 모킹하고 싶을 수 있다. 이 때도 마찬가지로 jest.mock(moduleName, callback)
함수를 사용하면 된다
// foo.ts
export const foo = 'foo';
export const bar = () => 'bar';
export default () => 'baz';
// module.test.ts
import exModule, { foo, bar } from './foo'
jest.mock('./foo', () => {
const original = jest.requireActual('./foo') as object
return {
__esModule: true,
...original,
default: () => 'mock baz',
foo: 'mocked foo',
}
})
test('mock partial', () => {
expect(exModule()).toBe('mock baz')
expect(foo).toBe('mocked foo')
expect(bar()).toBe('bar')
})
commonJS 모듈이 아닌 ES 모듈을 사용할 경우
export default
로 내보낸 것은
jest.mock(moduleName)
함수로 모킹되지 않는다. 테스트를 해야한다면 모듈의 기본
export 를 사용하기 보단 개별 export 하는 것이 좋을 것 같다.하지만 기본 export의 모킹이 필요하다면 위의 부분 모듈 모킹처럼
__esModule
을
true
로 설정하고 default 프로퍼티로 모의 함수나 구현을 만들어 넘기면 된다.
테스트에 모킹하는 것이 아니라 실제 로직이 실행되면서 함수가 단순히 어떻게, 몇번 호출되었는지만 확인하고 싶은 경우가 있다. 그런 경우에는 특정 함수를 추적하는 spyInstance
를 만들어 해결할 수 있다.
spyOn
함수는 mock
객체를 반환하지만 특정 함수를 추적한다는 차이점이 있다.
import * as moduleObj, { bar } from './foo'
import { bar } from './foo';
test('spyOn es6 module', () => {
const spyBar = jest.spyOn(moduleObj, 'bar')
console.log(bar())
expect(spyBar).toHaveBeenCalledTimes(1)
})
spyOn
의 첫번째 매개변수는 객체이다. 일반적인 ES 모듈의 import 는 객체없이 함수만 반환하기 때문에 첫번째 매개변수에 넘길 객체가 없다. 이런 경우에는import * as moduleName
을 사용하여 모듈을 객체로 감싸 import 하고 매개변수로 넘기면 된다
일반 메소드가 아닌 게터와 세터를 spy
하는데 사용한다
// foo.ts
export class Foo{
_bar: string
get bar() {
return this._bar
}
}
// test.ts
import { Foo } from './foo'
test('spyOn - getter/setter', () => {
const spyFooGetter = jest.spyOn(Foo, 'bar', 'get')
console.log(Foo.bar)
expect(spyFooGetter).toHaveBeenCalledTimes(1)
})
매 테스트 수트마다 모킹 및 스텁을 다르게 해주어야 하는 경우가 많다. 그러한 경우 매 테스트 실행시 마다 모의 함수를 초기화 해주는 것이 필요하다. Jest
는 이러한 경우에 필요한 메소드를 제공한다
'.mock' 프로퍼티에 담긴 객체를 초기화한다. 단 객체 값을 덮어 씌우는 것이 아니라 새로운 객체로 대체하는 것이기 때문에 초기화 전 변수에 .mock
프로퍼티에 저장된 객체를 저장해놨다면 이전의 모의 함수의 정보에 접근할 수 있기 때문에 주의가 필요하다
mockClear
+ mockReturnValues
등 stub
한 결과들을 기본 상태인 jest.fn()
으로 초기화한다
공식 문서에 따르면 mockRestore()
는 mockReset()
에 더해 원래 구현을 되돌린다고 설명한다. 일반적인 모의 함수는 원래 구현이 없기 때문에 mockReset()
과 다를바가 없으나 spyOn()
을 통해 만들어진 목 객체의 기본 구현을 되돌리는 역할을 한다
// foo.ts
export const bar = () => 'bar';
// test.ts
import * as moduleObj from './foo'
import { bar } from './foo';
test('spyOn - restore', () => {
const spy = jest.spyOn(moduleObj, 'bar')
console.log(bar()) // 'bar'
spy.mockImplementation(() => 'mocking spy bar')
console.log(bar()) // 'mocking spy bar'
spy.mockRestore()
console.log(bar()) // 'bar'
})
jest
객체에 접근해 모든 목 객체에 영향을 주는 초기화 함수가 존재한다
상황에 따라 훅 함수와 연계해 사용하면 된다