jest로 비동기 함수 테스트하기

함수를 모킹하고, fake timer를 활용해 비동기 함수의 실행 시점을 제어해봅니다.

2023-03-26에 씀
프론트엔드 테스트 가이드 시리즈의 다른 글
  1. jest로 비동기 함수 테스트하기
  2. Storybook Interaction Test를 활용한 바텀시트 시각적 테스트
  3. React Testing Library로 테스트할 요소를 선택하는 방법
  4. 프론트엔드 TDD 튜토리얼 with React & Testing Library

아래와 같은 코드가 있다고 해보겠습니다.

1// index.js
2import { fetchList } from "./api";
3
4export const execute = async (onBeforeExecute, onAfterExecute) => {
5 onBeforeExecute();
6
7 try {
8 const result = await fetchList();
9 onAfterExecute(!!result);
10 } catch (e) {
11 onAfterExecute(false);
12 }
13};

여기서 호출하는 비동기 함수인 fetchList는, 외부 서버의 API를 호출해 데이터를 받아오는 역할을 합니다.

1// api.js
2export 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 함수가 항상 아래와 같이 동작했으면 합니다.

이를 보장하기 위해 jest로 단위 테스트를 작성해 보겠습니다.

Mock Functions

우선, "함수 실행이 완료되면, onBeforeExecuteonAfterExecute가 한 번씩 실행된다" 시나리오에 대한 테스트를 작성해보겠습니다. 이 테스트를 검증하려면 onBeforeExecute, onAfterExecute 함수가 호출되었는지를 추적할 수 있어야 합니다.

이를 위해 jest.fn()을 사용해 mock 함수를 얻어올 수 있습니다. mock 함수를 사용하면 기존 함수를 가짜 함수로 대체할 수도 있고, 함수가 몇 번 호출됐는지, 어떤 인자와 호출됐는지와 같이 호출 정보에 대한 것도 알 수 있습니다.

1test("onBeforeExecute와 onAfterExecute를 실행시킨다", async () => {
2 const onBeforeExecute = jest.fn();
3 const onAfterExecute = jest.fn();
4
5 await execute(onBeforeExecute, onAfterExecute);
6
7 expect(onBeforeExecute).toHaveBeenCalledTimes(1);
8 expect(onAfterExecute).toHaveBeenCalledTimes(1);
9});

이 테스트에서는 execute에 mock 함수를 인자로 넘겨주고, onBeforeExecuteonAfterExecute가 한 번씩 잘 불렸는지 확인했습니다.

실행해보면 테스트를 잘 통과합니다. 그런데 이렇게 테스트해도 괜찮을까요?

모듈의 일부 함수 모킹하기

단위 테스트란 작은 코드 조각을, 빠르게, 격리된 방식으로 검증하는 자동화된 테스트를 말합니다. 그런데 위 테스트는 '격리된' 환경에서 진행되지 않는 테스트입니다. execute가 실행하는 fetchList 함수가 외부 API를 호출하기 때문에, 외부 의존성이 생겨버렸기 때문입니다.

이 단위 테스트가 성공하기 위해서는 execute 코드에 결함이 없는 것 뿐만 아니라, 네트워크에 연결되어 있어야 하고, 외부 서버가 항상 응답에 성공해야 합니다. 즉, 이 단위 테스트는 코드에 결함이 없어도 실패할 가능성이 있는 테스트입니다.

외부 의존성을 끊기 위해 jest.spyOn으로 apiModule에 속한 fetchList를 모킹하고, 이 함수가 resolve할 값도 임의로 설정해 줄 수 있습니다.

1 test("비동기 작업을 성공하면 onAfterExecute 실행 시 true를 받는다", async () => {
2 jest.spyOn(apiModule, "fetchList").mockResolvedValue(true);
3
4 const onBeforeExecute = jest.fn();
5 const onAfterExecute = jest.fn();
6
7 await execute(onBeforeExecute, onAfterExecute);
8
9 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);
3
4 const onBeforeExecute = jest.fn();
5 const onAfterExecute = jest.fn();
6
7 await execute(onBeforeExecute, onAfterExecute);
8
9 expect(onAfterExecute).toBeCalledWith(false);
10 });

mock 정리하기

지금까지 세 개의 테스트를 작성했는데, 중복되는 부분을 발견하셨나요?

1 const onBeforeExecute = jest.fn();
2 const onAfterExecute = jest.fn();

호출 정보를 알아오기 위해 모킹 함수로 onBeforeExecuteonAfterExecute를 만드는 부분이 중복되고 있습니다. 이 부분은 매 테스트가 시작되기 전에 처리해줘도 좋을 것 같습니다. 그래서 이 테스트들을 감싸는 describe의 콜백이 만드는 블럭으로 코드를 옮겨주었습니다.

1describe("execute", () => {
2 const onBeforeExecute = jest.fn();
3 const onAfterExecute = jest.fn();
4
5 // ...
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)));
5
6 test("fetchList는 200ms가 걸린다. 1", async () => {
7 await execute(onBeforeExecute, onAfterExecute);
8
9 expect(onAfterExecute).toHaveBeenCalledTimes(1);
10 });
11
12 test("fetchList는 200ms가 걸린다. 2", async () => {
13 await execute(onBeforeExecute, onAfterExecute);
14
15 expect(onAfterExecute).toHaveBeenCalledTimes(1);
16 });
17
18 test("fetchList는 200ms가 걸린다. 3", async () => {
19 await execute(onBeforeExecute, onAfterExecute);
20
21 expect(onAfterExecute).toHaveBeenCalledTimes(1);
22 });
23})

이 테스트에서 fetchList는 0.2초 뒤에 resolve 되는 비동기 함수입니다. 각 테스트에서 onAfterExecute는 한 번씩만 호출돼야 합니다. describe 하위에 있는 모든 테스트를 한번에 실행했을 때, 테스트는 모두 성공할까요?

첫 테스트만 성공했고, 나머지 두 테스트는 실패했습니다. 각각 2번, 3번씩 호출되었다는 결과가 남았습니다. 이처럼 mock을 공유해서 사용하게 되면 테스트가 다른 테스트에 영향을 줄 수 있게 됩니다.

이를 방지하기 위해, mock을 아래와 같은 방법으로 초기화해 줄 수 있습니다.

  1. 매 테스트마다 새로 할당해주기
1 let onBeforeExecute;
2 let onAfterExecute;
3
4 beforeEach(() => {
5 onBeforeExecute = jest.fn();
6 onAfterExecute = jest.fn();
7 })
  1. 테스트가 끝난 후에 정리해주기
1 const onBeforeExecute = jest.fn();
2 const onAfterExecute = jest.fn();
3
4 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();
3
4 afterEach(() => {
5 jest.clearAllMocks();
6 })

2-2. jest.config.js

jest config에 clearMocks 옵션을 켜두면, 따로 clear 해주지 않아도 매 테스트마다 clearAllMocks()가 실행됩니다.

1// jest.config.js
2module.exports = {
3 clearMocks: true,
4}
5
6// index.test.js
7// 매 테스트마다 알아서 clearAllMocks()가 실행됨
8 const onBeforeExecute = jest.fn();
9 const onAfterExecute = jest.fn();

fakeTimers

마지막으로 아래 시나리오를 테스트해보겠습니다.

비동기 작업이 끝나는 시점을 조정하기 위해 fetchList 함수의 구현을 모킹해서, 3초 뒤에 resolve되는 Promise를 반환하게 해보겠습니다.

1test("비동기 작업이 완료되기 이전에는 onBeforeExecute는 실행되고, onAfterExecute는 실행되지 않아야 한다", async () => {
2 jest.useFakeTimers();
3
4 jest
5 .spyOn(apiModule, "fetchList")
6 .mockImplementation(() => new Promise((resolve) => setTimeout(() => resolve(true), 3000)));
7
8 execute(onBeforeExecute, onAfterExecute);
9
10 expect(onBeforeExecute).toHaveBeenCalledTimes(1);
11 expect(onAfterExecute).toHaveBeenCalledTimes(0);
12
13 jest.advanceTimersByTime(3100);
14
15 expect(onBeforeExecute).toHaveBeenCalledTimes(1);
16 expect(onAfterExecute).toHaveBeenCalledTimes(1);
17
18 jest.useRealTimers();
19});

jest.useFakeTimer를 사용하면, setTimeout, setInterval 등의 네이티브 타이머 함수들을 제어할 수 있게 해줍니다. 위 예시에서는 비동기 작업을 완료시키기 위해 타이머의 시간을 3.1초 뒤로 돌려주었습니다.

결과는 어떨까요? onAfterExecute가 호출된 횟수가 한 번 뿐이라서 테스트에 실패합니다.

왜 이렇게 되었을까요?

  1. execute를 실행하면, 자바스크립트 콜 스택에 execute 함수가 쌓이고, 주도권이 execute 함수로 넘어갑니다.
  2. 이때 await fetchList를 만나면, WebAPI가 setTimeout의 처리를 맡게 되고 콜 스택에서 execute는 빠져나옵니다.
  3. 주도권이 테스트로 넘어옵니다.
  4. jest.advanceTimersByTime으로 3.1초가 흐르고, execute는 태스크 큐로 이동해 실행 대기 상태가 됩니다.
    그러나, 콜 스택이 비어있지 않기 때문에 실행되지는 않습니다.
  5. 테스트가 마저 실행됩니다. execute가 실행되지 않았기 때문에 당연히 onAfterExecute의 호출 횟수는 0회입니다.
  6. 테스트 실행이 완료된 후에야 execute가 실행되어 onAfterExecute도 실행됩니다.

따라서, 태스크 큐에 있는 execute를 꺼내서 실행해주는 과정이 필요합니다. 이를 위해 setImmediate를 사용해 보겠습니다.

1const flushPromises = () => new Promise(jest.requireActual("timers").setImmediate);
2
3test("비동기 작업이 완료되기 이전에는 onBeforeExecute는 실행되고, onAfterExecute는 실행되지 않아야 한다", async () => {
4 jest.useFakeTimers('setTimeout');
5
6 jest
7 .spyOn(apiModule, "fetchList")
8 .mockImplementation(() => new Promise((resolve) => setTimeout(() => resolve(true), 3000)));
9
10 void execute(onBeforeExecute, onAfterExecute);
11
12 expect(onBeforeExecute).toHaveBeenCalledTimes(1);
13 expect(onAfterExecute).toHaveBeenCalledTimes(0);
14
15 jest.advanceTimersByTime(3100);
16 await flushPromises();
17
18 expect(onBeforeExecute).toHaveBeenCalledTimes(1);
19 expect(onAfterExecute).toHaveBeenCalledTimes(1);
20
21 jest.useRealTimers();
22});

이번에는 jest.advanceTimersByTime으로 3.1초를 보낸 다음 await flushPromises()를 호출하게 했습니다. 이 flushPromisessetImmediate를 resolve 하는 헬퍼 함수입니다.

함수 실행 흐름을 다시 따라가보겠습니다.

  1. execute를 실행하면, 자바스크립트 콜 스택에 execute 함수가 쌓이고, 주도권이 execute 함수로 넘어갑니다.
  2. 이때 await fetchList를 만나면, WebAPI가 setTimeout의 처리를 맡게 되고 콜 스택에서 execute는 빠져나옵니다.
  3. 주도권이 테스트로 넘어옵니다.
  4. (여기까지 동일) jest.advanceTimersByTime으로 3.1초가 흐르고, execute는 태스크 큐로 이동해 실행 대기 상태가 됩니다.
  5. await flushPromises() 처리로 인해, 테스트도 콜 스택에서 빠져나와 태스크 큐로 이동합니다.
  6. 콜 스택이 비었기 때문에, 태스크 큐에서 더 앞에 있던 execute가 콜 스택으로 들어가 마저 실행됩니다. 이 시점에 onAfterExecute가 실행됩니다.
  7. execute 실행이 모두 끝나면, 다시 테스트를 실행합니다. 이때는 expect(onAfterExecute).toHaveBeenCalledTimes(1);를 통과할 수 있습니다.

주의할 점은, jest 버전에 따라 setImmediate를 사용하는 방법이 다르다는 것입니다.

1// Jest < v27
2function flushPromises() {
3 return new Promise(resolve => setImmediate(resolve));
4}
5
6// Jest >= v27
7function flushPromises() {
8 return new Promise(jest.requireActual("timers").setImmediate)
9}

참고한 글

프론트엔드 테스트 가이드 시리즈의 다른 글
  1. jest로 비동기 함수 테스트하기
  2. Storybook Interaction Test를 활용한 바텀시트 시각적 테스트
  3. React Testing Library로 테스트할 요소를 선택하는 방법
  4. 프론트엔드 TDD 튜토리얼 with React & Testing Library
프로필 사진

조예진

이전 포스트
이미지 많은 사이트 최적화하기
다음 포스트
반복자 패턴