Jest의 Mock 함수를 구현하는 3가지 방법에 대한 설명을 번역한 글입니다. 어느 상황에 어떤 Mock 함수를 써야할지 몰라서 헤매던 와중에 발견했습니다. 원문에 달려있는 공식문서보다 이해하기 잘 쓰여진 글이다
라는 댓글에 백번 공감하며 번역해보았습니다.
원문은 https://medium.com/@rickhanlonii/understanding-jest-mocks-f0046c68e53c 에서 확인하실 수 있습니다.
틀린 부분은 편하게 댓글 달아주시면 감사하겠습니다~!
Mocking은 테스트를 독립시키기 위해 의존성을 개발자가 컨트롤하고 검사할 수 있는 오브젝트로 변환하는 테크닉입니다. 의존성은 무엇이든 될 수 있지만, 일반적으로 import 하는 모듈입니다.
자바스크립트에는 testdouble 과 sinon처럼 훌륭한 mocking 라이브러리가 있고, Jest는 기본적으로 제공하는 기능입니다.
최근에 저는 Jest의 이슈 트래커를 돕기위해 Collaborator로서 Jest팀에 참여했습니다. 거기서 많은 이슈들이 Jest에서 어떻게 mocking 하는지에 관한 질문이라는 것을 깨닫고, 이 것들을 한번에 설명하는 가이드를 작성하기로 했습니다.
우리가 Jest에서 Mocking을 이야기할 때, 일반적으로 의존성을 Mock Function으로 대체하는 것에 대해 말합니다. 이 글에서 Mock 함수에 대해 리뷰해보고, 의존성을 대체하는 여러가지 방법으로 deep dive 해보겠습니다.
The Mock Function
Mocking의 목적은 우리가 컨트롤할 수 없는 무엇인가를 대체하는 것이기 때문에, 우리가 대체하는 것이 필요로하는 모든 기능을 갖고 있는게 중요합니다.
Mock 함수는 다음 기능을 제공합니다.
- 함수호출 Capture
- Return Value 설정
- 구현 변경하기
Mock 함수 인스턴스를 만드는 가장 간단한 방법은 jest.fn()
을 쓰는 것입니다.
이 것과 Jest Expect를 쓰면, 함수호출을 Capture해서 쉽게 테스트할 수 있습니다.
test("returns undefined by default", () => { | |
const mock = jest.fn(); | |
let result = mock("foo"); | |
expect(result).toBeUndefined(); | |
expect(mock).toHaveBeenCalled(); | |
expect(mock).toHaveBeenCalledTimes(1); | |
expect(mock).toHaveBeenCalledWith("foo"); | |
}); |
그리고 Return Value, 구현, Promise Resolution을 바꿀 수도 있습니다.
test("mock implementation", () => { | |
const mock = jest.fn(() => "bar"); | |
expect(mock("foo")).toBe("bar"); | |
expect(mock).toHaveBeenCalledWith("foo"); | |
}); | |
test("also mock implementation", () => { | |
const mock = jest.fn().mockImplementation(() => "bar"); | |
expect(mock("foo")).toBe("bar"); | |
expect(mock).toHaveBeenCalledWith("foo"); | |
}); | |
test("mock implementation one time", () => { | |
const mock = jest.fn().mockImplementationOnce(() => "bar"); | |
expect(mock("foo")).toBe("bar"); | |
expect(mock).toHaveBeenCalledWith("foo"); | |
expect(mock("baz")).toBe(undefined); | |
expect(mock).toHaveBeenCalledWith("baz"); | |
}); | |
test("mock return value", () => { | |
const mock = jest.fn(); | |
mock.mockReturnValue("bar"); | |
expect(mock("foo")).toBe("bar"); | |
expect(mock).toHaveBeenCalledWith("foo"); | |
}); | |
test("mock promise resolution", () => { | |
const mock = jest.fn(); | |
mock.mockResolvedValue("bar"); | |
expect(mock("foo")).resolves.toBe("bar"); | |
expect(mock).toHaveBeenCalledWith("foo"); | |
}); |
Mock 함수가 무엇이고 이 것으로 무엇을 할 수 있는지 알아봤습니다. 이제 어떻게 사용할지 알아봅시다.
의존성 주입
Mock 함수를 사용하는 일반적인 방법 중 하나는 테스트하려는 함수로 arguments를 직접 전달하는 방식입니다. 이 것은 테스트를 실행시키고, Mock 함수가 어떤 arguments와 어떻게 실행됐는지 assert구문으로 확인해 볼 수 있습니다.
const doAdd = (a, b, callback) => { | |
callback(a + b); | |
}; | |
test("calls callback with arguments added", () => { | |
const mockCallback = jest.fn(); | |
doAdd(1, 2, mockCallback); | |
expect(mockCallback).toHaveBeenCalledWith(3); | |
}); |
이 전략은 견고한 테스트를 만들지만 테스트코드가 의존성주입을 허용하도록 요구합니다. 종종 그럴 수 없는 경우에, 우리는 실제로 존재하는 모듈과 함수를 Mocking 해야 합니다.
모듈과 함수를 Mocking하기
Jest에서 모듈과 함수를 Mocking 하는 3가지 방법이 있습니다.
- jest.fn: Mock a function
- jest.mock: Mock a module
- jest.spyOn: Spy or mock a function
이 것들은 각각의 방식으로 Mock 함수를 만드는데, 어떻게 동작하는지 설명을 하기 위해 다음과 같은 폴더구조로 만들어 보겠습니다.
├ example/
| └── app.js
| └── app.test.js
| └── math.js
이 설정에서는 math.js
함수를 실제로 호출하지 않고 app.js
를 테스트하면서, 함수가 예상대로 호출되는지 확인하기 위해 Spy를 하는 것이 일반적입니다. 예시들은 진부하지만 math.js
의 함수들이 복잡한 계산을 하거나 개발자가 피하고싶은 IO를 만드는 요청이라고 상상해주세요.
export const add = (a, b) => a + b; | |
export const subtract = (a, b) => b - a; | |
export const multiply = (a, b) => a * b; | |
export const divide = (a, b) => b / a; |
import * as math from './math.js'; | |
export const doAdd = (a, b) => math.add(a, b); | |
export const doSubtract = (a, b) => math.subtract(a, b); | |
export const doMultiply = (a, b) => math.multiply(a, b); | |
export const doDivide = (a, b) => math.divide(a, b); |
jest.fn으로 Mocking 하기
가장 기본적인 전략은 함수를 Mock 함수로 재할당하는 것입니다. 재할당된 함수가 쓰이는 어디서든지 Mock 함수가 원래의 함수 대신 호출될 것입니다.
import * as app from "./app"; | |
import * as math from "./math"; | |
math.add = jest.fn(); | |
math.subtract = jest.fn(); | |
test("calls math.add", () => { | |
app.doAdd(1, 2); | |
expect(math.add).toHaveBeenCalledWith(1, 2); | |
}); | |
test("calls math.subtract", () => { | |
app.doSubtract(1, 2); | |
expect(math.subtract).toHaveBeenCalledWith(1, 2); | |
}); |
이렇게 Mocking 하는 방식은 몇 가지 이유로 덜 쓰입니다.
-
jest.mock
은 자동적으로 모듈의 모든 함수를 Mocking 해줍니다. -
jest.spyOn
도 마찬가지로 모든 함수를 Mocking 해주면서 원래의 함수를 다시 복원할 수도 있습니다.
jest.mock으로 Mocking 하기
좀 더 일반적인 접근법은 자동적으로 모듈이 exports하는 모든 것들을 Mocking 해주는 jest.mock
을 쓰는 것입니다. 따라서 jest.mock('./math.js')
를 해주면 본질적으로 math.js
를 다음처럼 설정하는 것입니다.
export const add = jest.fn(); | |
export const subtract = jest.fn(); | |
export const multiply = jest.fn(); | |
export const divide = jest.fn(); |
여기서부터 모듈이 exports 하는 모든 것들에 Mock 함수 기능을 쓸 수 있습니다.
import * as app from "./app"; | |
import * as math from "./math"; | |
// Set all module functions to jest.fn | |
jest.mock("./math.js"); | |
test("calls math.add", () => { | |
app.doAdd(1, 2); | |
expect(math.add).toHaveBeenCalledWith(1, 2); | |
}); | |
test("calls math.subtract", () => { | |
app.doSubtract(1, 2); | |
expect(math.subtract).toHaveBeenCalledWith(1, 2); | |
}); |
이것은 가장 쉽고 일반적인 Mocking 방법입니다. (Jest의 automock: true
설정 방식이기도 합니다)
이 전략의 유일한 단점은 모듈의 원래 구현에 접근하기 어렵다는 것입니다. 이런 경우를 대비해 spyOn
이 있습니다.
jest.spyOn으로 Spy 혹은 Mocking하기
때로 우리는 메소드가 실행되는 것을 지켜보길 원할뿐만 아니라, 기존의 구현은 보존하길 바랍니다. 구현을 Mocking하고 차후에 테스트구문에서 원본을 복원할 수 있습니다.
이 경우에 jest.spyOn
을 쓸 수 있습니다.
단순히 math 함수에 "Spy"를 호출하고 원본 구현은 그대로 둘 수 있습니다.
import * as app from "./app"; | |
import * as math from "./math"; | |
test("calls math.add", () => { | |
const addMock = jest.spyOn(math, "add"); | |
// calls the original implementation | |
expect(app.doAdd(1, 2)).toEqual(3); | |
// and the spy stores the calls to add | |
expect(addMock).toHaveBeenCalledWith(1, 2); | |
}); |
이것은 실제로 함수를 대체하지 않고, 특정한 사이드 이펙트가 발생하는지 테스트하는 몇몇 시나리오에 유용합니다.
함수를 Mocking하고 다시 원래 구현을 복원할 수도 있습니다.
import * as app from "./app"; | |
import * as math from "./math"; | |
test("calls math.add", () => { | |
const addMock = jest.spyOn(math, "add"); | |
// override the implementation | |
addMock.mockImplementation(() => "mock"); | |
expect(app.doAdd(1, 2)).toEqual("mock"); | |
// restore the original implementation | |
addMock.mockRestore(); | |
expect(app.doAdd(1, 2)).toEqual(3); | |
}); |
Jest는 각각의 테스트 파일이 샌드박스화 되어 있기 때문에, afterAll
훅을 불필요하게 사용하지 않도록 하는 경우에 유용합니다.
jest.spyOn
는 기본적으로 jest.fn()
의 사용에 대한 Sugar(일반적으로 말하는 Syntactic Sugar를 말하는 것 같습니다: 역자 주)라는 것이 키포인트 입니다. 우리는 기존의 구현을 저장하고, Mocking 했다가, 기존 구현을 재할당하는 방식으로 똑같은 목표를 달성할 수 있습니다.
import * as app from "./app"; | |
import * as math from "./math"; | |
test("calls math.add", () => { | |
// store the original implementation | |
const originalAdd = math.add; | |
// mock add with the original implementation | |
math.add = jest.fn(originalAdd); | |
// spy the calls to add | |
expect(app.doAdd(1, 2)).toEqual(3); | |
expect(math.add).toHaveBeenCalledWith(1, 2); | |
// override the implementation | |
math.add.mockImplementation(() => "mock"); | |
expect(app.doAdd(1, 2)).toEqual("mock"); | |
expect(math.add).toHaveBeenCalledWith(1, 2); | |
// restore the original implementation | |
math.add = originalAdd; | |
expect(app.doAdd(1, 2)).toEqual(3); | |
}); |
이 것이 실제로 jest.spyOn
이 구현된 방식입니다.
결론
이 글에서 우리는 Mock 함수가 무엇인지와 모듈과 함수 호출을 트래킹하고, 구현과 return value를 바꾸는 방법을 배웠습니다.
저는 여러분이 Jest Mock을 쉽게 이해하고 고통없이 테스트를 작성하는데 더 많은 시간을 쓸 수 있도록 돕기를 바랍니다. Mocking에 대한 더 많은 정보와 best practice들은 Justin Searls에 의해 Don't Mock Me 라고 이름 붙여진 발표와 700장이 넘는 슬라이드를 확인해보세요.
트위터와 스택오버플로우, 디스코드 채널로 무엇이든 물어보세요.
Top comments (0)