Logo

Jest로 비동기 코드 테스트 작성하기

지난 포스팅에서 Jest로 기본적인 테스트 코드 작성하는 방법에 대해서 알아보았습니다. 자바스크립트 언어 특상 상 실제 프로젝트에서는 비동기(Asynchronous)로 돌아가는 코드를 테스트해야 할 일이 많은데요. Jest Runner가 비동기 코드에 대한 테스트라는 사실을 인지할 수 있도록 테스트를 작성해주지 않으면 예상치 못했던 테스트 결과에 당황할 수가 있습니다. 이번 포스팅에서는 이러한 비동기 코드에 대한 테스트를 작성할 때 흔히 하는 실수들과 적절한 대응 방법에 대해서 알아보겠습니다.

콜백 함수 테스트

먼저 콜백 함수를 이용해서 구현된 비동기 코드에 대한 테스트를 작성하는 방법에 대해서 알아보겠습니다.

예를 들어, 다음과 같은 콜백 함수를 인자로 받는 자바스크립트 함수가 있다고 가정해보겠습니다. 이 함수는 인자로 넘어온 id를 가진 사용자 객체를 다시 인자로 넘어온 콜백 함수(cb)의 인자로 넘겨서 호출해줍니다. (실제 코드라면, DB를 조회하거나 API를 호출하거 하겠지만 간단한 예제를 위해서 사용자 객체를 임의로 만들어주었습니다.) 이 함수는 setTimeout() 이라는 자바스크립트 내장 함수를 사용해서 0.1초의 지연 후에 콜백 함수를 호출해줍니다.

function fetchUser(id, cb) {
  setTimeout(() => {
    console.log("wait 0.1 sec.");
    const user = {
      id: id,
      name: "User" + id,
      email: id + "@test.com",
    };
    cb(user);
  }, 100);
}

자바스크립트의 콜백 함수에 대한 자세한 설명은 관련 포스팅를 참고 바랍니다.

이 함수에 대한 테스트를 지난 포스팅에서 다루었던 데로 기본적인 방법으로 작성해보겠습니다.

test("fetch a user", () => {
  fetchUser(1, (user) => {
    expect(user).toEqual({
      id: 1,
      name: "User1",
      email: "1@test.com",
    });
  });
});

테스트를 실행해보면 다음과 같이 테스트가 통과합니다.

> npm test

> my-jest@1.0.0 test /my-jest
> jest

 PASS  ./async.test.js
  ✓ fetch a user (1ms)

Test Suites: 1 passed, 1 total
Tests:       1 passed, 1 total
Snapshots:   0 total
Time:        0.756s, estimated 1s
Ran all test suites.

그런데 자세히 보면 테스트가 실행되는데는 1ms가 걸렸던 것을 알 수 있습니다. (PC 성능에 따라 틀리겠지요.) 그렇다면, 0.1초, 즉 100ms의 지연이 제대로 효과를 발휘할 수 있었을까요? 게다가 콘솔에 wait 0.1 sec.라는 메시지도 출력되지 않았기 때문에 테스트가 통과되었어도 상당히 뭔가 수상합니다.

테스트가 제대로 실패하는지 확인하기 위해서 테스트 코드를 다음과 같이 id1 대신에 2를 넘기도록 수정해보겠습니다. 이 테스트는 실패를 해야합니다. 왜냐하면 id 2를 넘기면서 id1인 사용자 객체가 콜백 함수에 넘어오길 기대하고 있기 때문입니다.

test("fetch a user", () => {
  fetchUser(2, (user) => {
    expect(user).toEqual({
      id: 1,
      name: "User1",
      email: "1@test.com",
    });
  });
});

하지만 테스트를 실행해보면 이 테스트도 통과합니다. 뭔가 확실히 잘못되고 있다는 것을 알 수 있습니다.

> npm test

> my-jest@1.0.0 test /my-jest
> jest

 PASS  ./async.test.js
  ✓ fetch a user (1ms)

Test Suites: 1 passed, 1 total
Tests:       1 passed, 1 total
Snapshots:   0 total
Time:        0.88s, estimated 1s
Ran all test suites.

Jest Runner는 기본적으로 테스트 함수를 그저 가능한한 최대한 빨리 호출해줍니다. 그래야 실행한 테스트가 많을 때도 성능이 좋겠죠? 따라서 위 경우에, 콜백 함수는 호출될 기회조차 얻지 못하고, 콜백 함수 내의 toEqual() Matcher 함수도 호출되지 못했던 것입니다.

해결 방법은 간단합니다. Jest Runner에게 명시적으로 이 테스트 함수는 비동기 코드를 테스트 하니 콜백 함수가 호출되는지도 좀 봐주라고 알려주는 것입니다. 다음과 같이 실행할 테스트 함수가 done이라는 함수 인자를 받도록 수정하고, 이 done 함수를 콜백 함수의 제일 마지막에 호출하도록 수정합니다.

test("fetch a user", (done) => {
  fetchUser(2, (user) => {
    expect(user).toEqual({
      id: 1,
      name: "User1",
      email: "1@test.com",
    });
    done();
  });
});

수정된 테스트를 실행해보면 다음과 같이 예상했던 바와 같이 테스트가 실패하는 것을 알 수 있습니다. 대신에 테스트 수행 시간도 124ms도 대폭 증가되었습니다. 100ms의 지연을 고려해보면 자연스러운 현상입니다.

> npm test

> my-jest@1.0.0 test /my-jest
> jest

 FAIL  ./async.test.js
  ✕ fetch a user (124ms)

  ● fetch a user

    expect(received).toEqual(expected)

    Difference:

    - Expected
    + Received

      Object {
    -   "email": "1@test.com",
    -   "id": 1,
    -   "name": "User1",
    +   "email": "2@test.com",
    +   "id": 2,
    +   "name": "User2",
      }

      13 | test('fetch a user', done => {
      14 |   fetchUser(2, user => {
    > 15 |     expect(user).toEqual({
         |                  ^
      16 |       id: 1,
      17 |       name: 'User1',
      18 |       email: '1@test.com'

      at toEqual (async.test.js:15:18)
      at cb (async.test.js:9:5)

  console.log async.test.js:3
    wait 0.1 sec.

Test Suites: 1 failed, 1 total
Tests:       1 failed, 1 total
Snapshots:   0 total
Time:        0.894s, estimated 1s
Ran all test suites.

이제 fetchUser() 함수를 호출할 때 인자를 2 대신 다시 1을 넘기도록 코드를 수정하면 콜백 함수도 호출되면서 Jest는 모든 코드를 빠짐없이 실행하면서 정확하게 테스트를 통과시켜 줍니다.

Promise 테스트

다음으로는 Promise를 사용해서 구현된 비동기 코드에 대한 테스트를 어떻게 작성하는지 알아보겠습니다.

위에서 작성한 콜백 함수를 사용하는 비동기 코드는 다음과 같이 Promise를 이용해서 재작성 해보았습니다.

function fetchUser(id) {
  return new Promise((resolve) => {
    setTimeout(() => {
      console.log("wait 0.1 sec.");
      const user = {
        id: id,
        name: "User" + id,
        email: id + "@test.com",
      };
      resolve(user);
    }, 100);
  });
}

차이점은 인자로 콜백 함수를 받는 대신 0.1초를 기다렸다가 사용자 객체를 제공(resolve)하는 Promise 객체를 리턴한다는 것입니다.

위 코드가 잘 이해되지 않으시는 분들은 자바스크립트의 Promise에 대한 포스팅를 먼저 읽어보시면 도움이 되실 겁니다.

먼저 실패하는 테스트를 작성하고 정말 실패하는지 살펴보겠습니다.

test("fetch a user", () => {
  fetchUser(2).then((user) => {
    expect(user).toEqual({
      id: 1,
      name: "User1",
      email: "1@test.com",
    });
  });
});

테스트를 실행해보면 예상했던 것 처럼 실패하지 않습니다. 실행 시간도 1ms이고, 콘솔에 wait 0.1 sec.이 출력되지 않은 걸로 말미암아 fetchUser() 함수에서 리턴된 Promise 객체의 then() 메서드가 실행될 기회도 얻지 못했을 것 같습니다.

$ npm test

> my-jest@1.0.0 test /my-jest
> jest

 PASS  ./promise.test.js
  ✓ fetch a user (1ms)

Test Suites: 1 passed, 1 total
Tests:       1 passed, 1 total
Snapshots:   0 total
Time:        0.766s, estimated 1s
Ran all test suites.

해결 방법은 이외로 간단합니다. 다음과 같이 return 문만 추가해주면 원했던 바와 같이 테스트는 실패하게 됩니다. 테스트 함수가 Promise를 리턴하면 Jest Runner는 리턴된 Promise가 resolve될 때까지 기다려줍니다.

test("fetch a user", () => {
  return fetchUser(2).then((user) => {
    expect(user).toEqual({
      id: 1,
      name: "User1",
      email: "1@test.com",
    });
  });
});
$ npm test

> my-jest@1.0.0 test /my-jest
> jest

 FAIL  ./promise.test.js
  ✕ fetch a user (117ms)

  ● fetch a user

    expect(received).toEqual(expected)

    Difference:

    - Expected
    + Received

      Object {
    -   "email": "1@test.com",
    -   "id": 1,
    -   "name": "User1",
    +   "email": "2@test.com",
    +   "id": 2,
    +   "name": "User2",
      }

      15 | test('fetch a user', () => {
      16 |   return fetchUser(2).then(user => {
    > 17 |     expect(user).toEqual({
         |                  ^
      18 |       id: 1,
      19 |       name: 'User1',
      20 |       email: '1@test.com'

      at toEqual (promise.test.js:17:18)

  console.log promise.test.js:4
    wait 0.1 sec.

Test Suites: 1 failed, 1 total
Tests:       1 failed, 1 total
Snapshots:   0 total
Time:        1.021s
Ran all test suites.

Async/Await 테스트

마지막으로 ES6의 async/await 키워드를 사용하면 더 읽기 쉬운 테스트를 작성할 수 있습니다.

자바스크립트의 async/await에 대한 자세한 설명은 관련 포스팅를 참고 바랍니다.

다음과 같이 테스트 함수 맨 앞에 async 를 추가하고, Promise를 리턴하는 함수 앞에 await를 붙여주면 마치 동기 코드처럼 보이는 테스트를 작성할 수 있습니다.

test("fetch a user", async () => {
  const user = await fetchUser(2);
  expect(user).toEqual({
    id: 1,
    name: "User1",
    email: "1@test.com",
  });
});

위 테스트는 예상했던 바와 id 2를 넘기면서 id1인 사용자 객체를 기대하고 있기 때문에 실패합니다. 하지만 실행 시간이 114ms로 0.1초 이상이고 콘솔에 wait 0.1 sec.가 출력되는 걸로 봐서는 테스트 함수가 제대로 실행된 것을 알 수 있습니다.

$ npm test

> my-jest@1.0.0 test /my-jest
> jest

 FAIL  ./promise.test.js
  ✕ fetch a user (114ms)

  ● fetch a user

    expect(received).toEqual(expected)

    Difference:

    - Expected
    + Received

      Object {
    -   "email": "1@test.com",
    -   "id": 1,
    -   "name": "User1",
    +   "email": "2@test.com",
    +   "id": 2,
    +   "name": "User2",
      }

      15 | test('fetch a user', async () => {
      16 |   const user = await fetchUser(2);
    > 17 |   expect(user).toEqual({
         |                ^
      18 |     id: 1,
      19 |     name: 'User1',
      20 |     email: '1@test.com'

      at Object.toEqual (promise.test.js:17:16)

  console.log promise.test.js:4
    wait 0.1 sec.

Test Suites: 1 failed, 1 total
Tests:       1 failed, 1 total
Snapshots:   0 total
Time:        0.997s, estimated 1s
Ran all test suites.

다음과 같이 인자에 맞게 기대 값을 바꿔주면 테스트는 통과하게 됩니다.

test("fetch a user", async () => {
  const user = await fetchUser(2);
  expect(user).toEqual({
    id: 2,
    name: "User2",
    email: "2@test.com",
  });
});
$ npm test

> my-jest@1.0.0 test /my-jest
> jest

 PASS  ./promise.test.js
  ✓ fetch a user (119ms)

  console.log promise.test.js:4
    wait 0.1 sec.

Test Suites: 1 passed, 1 total
Tests:       1 passed, 1 total
Snapshots:   0 total
Time:        1.476s
Ran all test suites.

.resolves 수정자

Jest는 Promise 테스팅에 특화된 .resolves.rejects 수정자(modifier) 제공하는데요. 이 수정자를 함수를 사용하면 좀 더 깔끔하게 then() 함수나 async/await 없이 비동기 코드에 대한 테스트를 작성할 수 있습니다.

예를 들어, .resolves를 사용하여 동일한 테스트를 재작성해볼까요? expect() 함수 안에서 바로 비동기 함수를 호출할 수 있으며 .resolves 다음에 toEqual()과 같은 원하는 matcher 함수를 자연스럽게 연결해줄 수 있는데요. 이 결과를 위에서 Promise 테스트 때 그랬던 것처럼 반드시 return 키워드로 반환해줘야 합니다.

test("fetch a user", async () => {
  return expect(fetchUser(2)).resolves.toEqual({
    id: 2,
    name: "User2",
    email: "2@test.com",
  });
});
$ npm test

> my-jest@1.0.0 test /my-jest
> jest

 PASS  ./promise.test.js
  ✓ fetch a user (102 ms)

Test Suites: 1 passed, 1 total
Tests:       1 passed, 1 total
Snapshots:   0 total
Time:        0.489 s, estimated 1 s
Ran all test suites.

이번에는 인자를 바꿔서 테스트가 실패하는지도 확인해 볼까요?

test("fetch a user", async () => {
  return expect(fetchUser(1)).resolves.toEqual({
    id: 2,
    name: "User2",
    email: "2@test.com",
  });
});
$ npm test

> my-jest@1.0.0 test /my-jest
> jest

 FAIL  ./promise.test.js
  ✕ fetch a user (106 ms)

  ● fetch a user

    expect(received).resolves.toEqual(expected) // deep equality

    - Expected  - 3
    + Received  + 3

      Object {
    -   "email": "2@test.com",
    -   "id": 2,
    -   "name": "User2",
    +   "email": "1@test.com",
    +   "id": 1,
    +   "name": "User1",
      }

      14 |
      15 | test("fetch a user", () => {
    > 16 |   return expect(fetchUser(1)).resolves.toEqual({
         |                                        ^
      17 |     id: 2,
      18 |     name: "User2",
      19 |     email: "2@test.com",

      at Object.toEqual (node_modules/expect/build/index.js:198:20)
      at Object.<anonymous> (promise.test.js:16:40)

.rejects 수정자

이번에는 .rejects 수정자(modifier)를 사용하기 위해서 fetchUser() 함수를 조금 변경해볼까요? 인자로 숫자가 아닌 id가 넘어오면 거부(reject)하는 Promise를 반환하도록 해보겠습니다.

function fetchUser(id) {
  return new Promise((resolve, reject) => {
    setTimeout(() => {
      console.log("wait 0.1 sec.");

      if (typeof id !== "number") reject(new Error("id must be a number!"));
      const user = {
        id: id,
        name: "User" + id,
        email: id + "@test.com",
      };
      resolve(user);
    }, 100);
  });
}

이제 .rejectstoThrow() matcher 함수를 연결하여 id로 문자열 "2"를 넘겼을 때 거부되는지 간편하게 테스트를 해볼 수 있습니다. 마찬가지로 return 키워드를 사용해야하는 부분을 까먹지 마시기 바랍니다.

test("rejects give non-number id", () => {
  return expect(fetchUser("2")).rejects.toThrow("id must be a number");
});
$ npm test

> my-jest@1.0.0 test /my-jest
> jest

 PASS  ./promise.test.js
  ✓ fetch a user (114 ms)

  console.log
    wait 0.1 sec.

      at promise.test.js:4

Test Suites: 1 passed, 1 total
Tests:       1 passed, 1 total
Snapshots:   0 total
Time:        0.567 s, estimated 1 s
Ran all test suites.

마치면서

이상으로 Jest를 이용해서 비동기 코드에 대한 테스트를 작성하는 다양한 방법에 대해서 알아보았습니다. 이 중에서 한 가지 방법에 다른 방법보다 더 우수하다고는 말하기 어려우며 상황에 따라 적절한 방법을 선택하시면 될 것 같습니다.

다음 포스팅에서는 Jest로 테스트 전/후 처리 방법에 대해서 다뤄보도록 하겠습니다.

Jest에 연관된 포스팅은 Jest 태그를 통해서 쉽게 만나보세요!