아래와 같은 코드가 있다고 해보겠습니다.
1// index.js2import { fetchList } from "./api";34export const execute = async (onBeforeExecute, onAfterExecute) => {5 onBeforeExecute();67 try {8 const result = await fetchList();9 onAfterExecute(!!result);10 } catch (e) {11 onAfterExecute(false);12 }13};
여기서 호출하는 비동기 함수인 fetchList
는, 외부 서버의 API를 호출해 데이터를 받아오는 역할을 합니다.
1// api.js2export const fetchList = async () => {3 const result = await fetch(4 "https://www.themealdb.com/api/json/v1/1/search.php?f=a"5 );6 return result.json();7};
위의 execute
함수가 항상 아래와 같이 동작했으면 합니다.
- 함수 실행이 완료되면,
onBeforeExecute
와onAfterExecute
가 한 번씩 실행된다 - 비동기 작업이 성공하면
onAfterExecute
에 인자로true
를 넘겨준다 - 비동기 작업이 실패하면
onAfterExecute
에 인자로false
를 넘겨준다 - 비동기 작업이 완료되기 이전에는
onBeforeExecute
는 실행되지만,onAfterExecute
는 실행되지 않는다. - 비동기 작업이 완료된 후에는
onBeforeExecute
와onAfterExecute
가 모두 실행된다.
이를 보장하기 위해 jest로 단위 테스트를 작성해 보겠습니다.
Mock Functions
우선, "함수 실행이 완료되면, onBeforeExecute
와 onAfterExecute
가 한 번씩 실행된다" 시나리오에 대한 테스트를 작성해보겠습니다. 이 테스트를 검증하려면 onBeforeExecute
, onAfterExecute
함수가 호출되었는지를 추적할 수 있어야 합니다.
이를 위해 jest.fn()
을 사용해 mock 함수를 얻어올 수 있습니다. mock 함수를 사용하면 기존 함수를 가짜 함수로 대체할 수도 있고, 함수가 몇 번 호출됐는지, 어떤 인자와 호출됐는지와 같이 호출 정보에 대한 것도 알 수 있습니다.
1test("onBeforeExecute와 onAfterExecute를 실행시킨다", async () => {2 const onBeforeExecute = jest.fn();3 const onAfterExecute = jest.fn();45 await execute(onBeforeExecute, onAfterExecute);67 expect(onBeforeExecute).toHaveBeenCalledTimes(1);8 expect(onAfterExecute).toHaveBeenCalledTimes(1);9});
이 테스트에서는 execute
에 mock 함수를 인자로 넘겨주고, onBeforeExecute
와 onAfterExecute
가 한 번씩 잘 불렸는지 확인했습니다.
실행해보면 테스트를 잘 통과합니다. 그런데 이렇게 테스트해도 괜찮을까요?
모듈의 일부 함수 모킹하기
단위 테스트란 작은 코드 조각을, 빠르게, 격리된 방식으로 검증하는 자동화된 테스트를 말합니다. 그런데 위 테스트는 '격리된' 환경에서 진행되지 않는 테스트입니다. execute
가 실행하는 fetchList
함수가 외부 API를 호출하기 때문에, 외부 의존성이 생겨버렸기 때문입니다.
이 단위 테스트가 성공하기 위해서는 execute
코드에 결함이 없는 것 뿐만 아니라, 네트워크에 연결되어 있어야 하고, 외부 서버가 항상 응답에 성공해야 합니다. 즉, 이 단위 테스트는 코드에 결함이 없어도 실패할 가능성이 있는 테스트입니다.
외부 의존성을 끊기 위해 jest.spyOn
으로 apiModule
에 속한 fetchList
를 모킹하고, 이 함수가 resolve할 값도 임의로 설정해 줄 수 있습니다.
1 test("비동기 작업을 성공하면 onAfterExecute 실행 시 true를 받는다", async () => {2 jest.spyOn(apiModule, "fetchList").mockResolvedValue(true);34 const onBeforeExecute = jest.fn();5 const onAfterExecute = jest.fn();67 await execute(onBeforeExecute, onAfterExecute);89 expect(onBeforeExecute).toHaveBeenCalledTimes(1);10 expect(onAfterExecute).toHaveBeenCalledTimes(1);11 });
비동기 작업에 실패하면 fetchList
에서 reject가 일어날 것입니다. 이런 상황을 만들기 위해 mockRejectedValue
를 사용할 수 있습니다.
1 test("비동기 작업을 실패하면 onAfterExecute 실행 시 false를 받는다", async () => {2 jest.spyOn(apiModule, "fetchList").mockRejectedValue(false);34 const onBeforeExecute = jest.fn();5 const onAfterExecute = jest.fn();67 await execute(onBeforeExecute, onAfterExecute);89 expect(onAfterExecute).toBeCalledWith(false);10 });
mock 정리하기
지금까지 세 개의 테스트를 작성했는데, 중복되는 부분을 발견하셨나요?
1 const onBeforeExecute = jest.fn();2 const onAfterExecute = jest.fn();
호출 정보를 알아오기 위해 모킹 함수로 onBeforeExecute
와 onAfterExecute
를 만드는 부분이 중복되고 있습니다. 이 부분은 매 테스트가 시작되기 전에 처리해줘도 좋을 것 같습니다. 그래서 이 테스트들을 감싸는 describe
의 콜백이 만드는 블럭으로 코드를 옮겨주었습니다.
1describe("execute", () => {2 const onBeforeExecute = jest.fn();3 const onAfterExecute = jest.fn();45 // ...6})
이렇게 하면 모킹 함수가 잘 동작할까요? 아래와 같은 상황을 생각해봅시다.
1describe("execute", () => {2 const onBeforeExecute = jest.fn();3 const onAfterExecute = jest.fn();4 jest.spyOn(apiModule, "fetchList").mockImplementation(() => new Promise(resolve => setTimeout(resolve, 200)));56 test("fetchList는 200ms가 걸린다. 1", async () => {7 await execute(onBeforeExecute, onAfterExecute);89 expect(onAfterExecute).toHaveBeenCalledTimes(1);10 });1112 test("fetchList는 200ms가 걸린다. 2", async () => {13 await execute(onBeforeExecute, onAfterExecute);1415 expect(onAfterExecute).toHaveBeenCalledTimes(1);16 });1718 test("fetchList는 200ms가 걸린다. 3", async () => {19 await execute(onBeforeExecute, onAfterExecute);2021 expect(onAfterExecute).toHaveBeenCalledTimes(1);22 });23})
이 테스트에서 fetchList
는 0.2초 뒤에 resolve 되는 비동기 함수입니다. 각 테스트에서 onAfterExecute는 한 번씩만 호출돼야 합니다. describe 하위에 있는 모든 테스트를 한번에 실행했을 때, 테스트는 모두 성공할까요?
첫 테스트만 성공했고, 나머지 두 테스트는 실패했습니다. 각각 2번, 3번씩 호출되었다는 결과가 남았습니다. 이처럼 mock을 공유해서 사용하게 되면 테스트가 다른 테스트에 영향을 줄 수 있게 됩니다.
이를 방지하기 위해, mock을 아래와 같은 방법으로 초기화해 줄 수 있습니다.
- 매 테스트마다 새로 할당해주기
1 let onBeforeExecute;2 let onAfterExecute;34 beforeEach(() => {5 onBeforeExecute = jest.fn();6 onAfterExecute = jest.fn();7 })
- 테스트가 끝난 후에 정리해주기
1 const onBeforeExecute = jest.fn();2 const onAfterExecute = jest.fn();34 afterEach(() => {5 onBeforeExecute.mockClear();6 onAfterExecute.mockClear();7 })
2-1. clearAllMocks()
clearAllMocks()
를 사용해, 함수를 하나하나 clear 시키는 것이 아니라 모든 mock 함수를 한 번에 clear 시킬 수 있습니다.
1 const onBeforeExecute = jest.fn();2 const onAfterExecute = jest.fn();34 afterEach(() => {5 jest.clearAllMocks();6 })
2-2. jest.config.js
jest config에 clearMocks 옵션을 켜두면, 따로 clear 해주지 않아도 매 테스트마다 clearAllMocks()
가 실행됩니다.
1// jest.config.js2module.exports = {3 clearMocks: true,4}56// index.test.js7// 매 테스트마다 알아서 clearAllMocks()가 실행됨8 const onBeforeExecute = jest.fn();9 const onAfterExecute = jest.fn();
fakeTimers
마지막으로 아래 시나리오를 테스트해보겠습니다.
- 비동기 작업인
fetchList
가 완료되기 이전에는onBeforeExecute
는 실행되고,onAfterExecute
는 실행되지 않아야 한다
비동기 작업이 끝나는 시점을 조정하기 위해 fetchList 함수의 구현을 모킹해서, 3초 뒤에 resolve되는 Promise를 반환하게 해보겠습니다.
1test("비동기 작업이 완료되기 이전에는 onBeforeExecute는 실행되고, onAfterExecute는 실행되지 않아야 한다", async () => {2 jest.useFakeTimers();34 jest5 .spyOn(apiModule, "fetchList")6 .mockImplementation(() => new Promise((resolve) => setTimeout(() => resolve(true), 3000)));78 execute(onBeforeExecute, onAfterExecute);910 expect(onBeforeExecute).toHaveBeenCalledTimes(1);11 expect(onAfterExecute).toHaveBeenCalledTimes(0);1213 jest.advanceTimersByTime(3100);1415 expect(onBeforeExecute).toHaveBeenCalledTimes(1);16 expect(onAfterExecute).toHaveBeenCalledTimes(1);1718 jest.useRealTimers();19});
jest.useFakeTimer
를 사용하면, setTimeout
, setInterval
등의 네이티브 타이머 함수들을 제어할 수 있게 해줍니다. 위 예시에서는 비동기 작업을 완료시키기 위해 타이머의 시간을 3.1초 뒤로 돌려주었습니다.
결과는 어떨까요? onAfterExecute
가 호출된 횟수가 한 번 뿐이라서 테스트에 실패합니다.
왜 이렇게 되었을까요?
- execute를 실행하면, 자바스크립트 콜 스택에 execute 함수가 쌓이고, 주도권이 execute 함수로 넘어갑니다.
- 이때
await fetchList
를 만나면, WebAPI가 setTimeout의 처리를 맡게 되고 콜 스택에서 execute는 빠져나옵니다. - 주도권이 테스트로 넘어옵니다.
jest.advanceTimersByTime
으로 3.1초가 흐르고, execute는 태스크 큐로 이동해 실행 대기 상태가 됩니다.
그러나, 콜 스택이 비어있지 않기 때문에 실행되지는 않습니다.- 테스트가 마저 실행됩니다.
execute
가 실행되지 않았기 때문에 당연히onAfterExecute
의 호출 횟수는 0회입니다. - 테스트 실행이 완료된 후에야
execute
가 실행되어onAfterExecute
도 실행됩니다.
따라서, 태스크 큐에 있는 execute를 꺼내서 실행해주는 과정이 필요합니다. 이를 위해 setImmediate를 사용해 보겠습니다.
1const flushPromises = () => new Promise(jest.requireActual("timers").setImmediate);23test("비동기 작업이 완료되기 이전에는 onBeforeExecute는 실행되고, onAfterExecute는 실행되지 않아야 한다", async () => {4 jest.useFakeTimers('setTimeout');56 jest7 .spyOn(apiModule, "fetchList")8 .mockImplementation(() => new Promise((resolve) => setTimeout(() => resolve(true), 3000)));910 void execute(onBeforeExecute, onAfterExecute);1112 expect(onBeforeExecute).toHaveBeenCalledTimes(1);13 expect(onAfterExecute).toHaveBeenCalledTimes(0);1415 jest.advanceTimersByTime(3100);16 await flushPromises();1718 expect(onBeforeExecute).toHaveBeenCalledTimes(1);19 expect(onAfterExecute).toHaveBeenCalledTimes(1);2021 jest.useRealTimers();22});
이번에는 jest.advanceTimersByTime
으로 3.1초를 보낸 다음 await flushPromises()
를 호출하게 했습니다. 이 flushPromises
는 setImmediate
를 resolve 하는 헬퍼 함수입니다.
함수 실행 흐름을 다시 따라가보겠습니다.
- execute를 실행하면, 자바스크립트 콜 스택에 execute 함수가 쌓이고, 주도권이 execute 함수로 넘어갑니다.
- 이때
await fetchList
를 만나면, WebAPI가 setTimeout의 처리를 맡게 되고 콜 스택에서 execute는 빠져나옵니다. - 주도권이 테스트로 넘어옵니다.
- (여기까지 동일)
jest.advanceTimersByTime
으로 3.1초가 흐르고, execute는 태스크 큐로 이동해 실행 대기 상태가 됩니다. await flushPromises()
처리로 인해, 테스트도 콜 스택에서 빠져나와 태스크 큐로 이동합니다.- 콜 스택이 비었기 때문에, 태스크 큐에서 더 앞에 있던
execute
가 콜 스택으로 들어가 마저 실행됩니다. 이 시점에onAfterExecute
가 실행됩니다. - execute 실행이 모두 끝나면, 다시 테스트를 실행합니다. 이때는
expect(onAfterExecute).toHaveBeenCalledTimes(1);
를 통과할 수 있습니다.
주의할 점은, jest 버전에 따라 setImmediate를 사용하는 방법이 다르다는 것입니다.
1// Jest < v272function flushPromises() {3 return new Promise(resolve => setImmediate(resolve));4}56// Jest >= v277function flushPromises() {8 return new Promise(jest.requireActual("timers").setImmediate)9}
참고한 글
- <단위 테스트>, 블라디미르 코리코프 저 / 임준혁 역 / 에이콘출판사 / 2021
- Mock Functions - jest
- flushPromises가 작동하는 이유 - imch.dev
- https://stackoverflow.com/a/58716087