Logo

Jest의 가짜 타이머로 테스트의 시간을 멈추기

테스트를 작성하다보면 날짜와 시간이 골칫거리가 되는 경우가 많습니다. 날짜와 시간은 다른 데이터와 다르게 항상 변하기 때문인데요.

이번 포스팅에서는 Jest를 이용하여 날짜와 시간을 효과적으로 모킹(mocking)하는 방법에 대해서 알아보겠습니다.

테스팅 프레임워크인 Jest에 생소하신 분들은 먼저 관련 포스팅를 읽어 보시고 돌아오시기를 추천드립니다.

예제 코드

자바스크립트의 Intl API를 사용하여 현재 날짜를 주어진 언어에 따라 문자열로 변환해주는 간단한 함수를 작성해보겠습니다.

datetime.js
export function formatCurrentDate(locale = "en") {
  const koDtf = new Intl.DateTimeFormat(locale, {
    dateStyle: "long",
    timeStyle: "short",
  });
  return koDtf.format(new Date());
}

이 함수는 Date() 생성자를 통해서 현재 날짜를 얻고 있는데요. 함수 내부에서 생성되는 날짜 객체는 함수가 호출될 때 마다 바뀔 거에요. 너무 당연한 얘기겠지만 우리의 시간은 언제나 흐르고 있으니까요.

이 시간의 섭리가 테스트를 작성할 때는 의외의 복병으로 작용할 수 있는데요. 지금부터 실제 테스트를 작성하면서 어떤 문제에 부딪히게 되는지 보여드릴께요.

테스트 작성

방금 작성한 함수가 영어와 한국어가 주어졌을 때 예상대로 작동하는지 검증하기 위한 테스트를 작성해보겠습니다.

datetime.test.js
import { formatCurrentDate } from "./datetime";

test("formats current date for English", () => {
  expect(formatCurrentDate()).toEqual("October 21, 2023 at 4:57 PM");
});

test("formats current date for Korean", () => {
  expect(formatCurrentDate("ko")).toEqual("2023년 10월 21일 오후 4:57");
});

그리고 테스트를 실행해보면 잘 통과하는데요.

$ jest datetime.test.js
 PASS  ./datetime.test.js
  ✓ formatted string starts with date (10 ms)
  ✓ formatted string ends with time

Test Suites: 1 passed, 1 total
Tests:       2 passed, 2 total
Snapshots:   0 total
Time:        0.189 s, estimated 1 s
Ran all test suites matching /.datetime.test.js/i.

5분 정도 있다가 동일한 테스트를 다시 실행해보니 이번에는 실패합니다.

$ jest datetime.test.js
 FAIL  ./datetime.test.js
  ✕ formats current date for English (29 ms)
  ✕ formats current date for Korean (1 ms)

  ● formats current date for English

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

    Expected: "October 21, 2023 at 4:57 PM"
    Received: "October 21, 2023 at 5:01 PM"

      2 |
      3 | test("formats current date for English", () => {
    > 4 |   expect(formatCurrentDate()).toEqual("October 21, 2023 at 4:57 PM");
        |                               ^
      5 | });
      6 |
      7 | test("formats current date for Korean", () => {

      at Object.toEqual (./datetime.test.js:4:31)

  ● formats current date for Korean

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

    Expected: "2023년 10월 21일 오후 4:57"
    Received: "2023년 10월 21일 오후 5:01"

       6 |
       7 | test("formats current date for Korean", () => {
    >  8 |   expect(formatCurrentDate("ko")).toEqual("2023년 10월 21일 오후 4:57");
         |                                   ^
       9 | });
      10 |

      at Object.toEqual (./datetime.test.js:8:35)

왜 이런 일이 발생하는 걸까요?

네, 맞습니다! 테스트를 실행할 때 마다 현재 날짜와 시간이 달라지기 때문입니다. 다시 말해서 formatCurrentDate() 함수 안에서 생성되는 날짜 객체는 매번 다를 수 밖에 없는 것이지요.

뿐만 아니라 이 문제는 테스트가 Deterministic, 즉 동일한 입력에 대해 항상 동일한 결과를 반환해야 한다는 테스틍의 기본 원칙도 위배하고 있습니다. Deterministic하지 않은 테스트는 예측이 어렵고 일관성을 보장할 수 없어서 개발 생산성에 저해하는 요소가 됩니다.

Jest의 날짜 모킹

이 문제를 해결하려면 어떻게 해야할까요?

우선, 함수를 호출할 때 인자로 new Date(2023, 9, 21, 16, 57)와 같은 날짜 객체를 넘기는 것을 생각해볼 수 있지만 함수의 구현을 변경하지 않고는 불가능합니다.

결국은 formatCurrentDate() 함수 내부에서 생성되는 날짜 객체가 항상 동일하도록 만들어야하는데요. 그러면 흘러가는 시간을 어떻게든 잡아야하겠죠? 🙃

우리의 컴퓨터가 아무리 빠르더라도 현실적으로 new Date()의 호출 결과가 동일하기는 매우 어렵습니다. 밀리초(ms) 단위에서는 차이가 발생하기 때문에 다음과 같이 new Date()를 연달아 두 번 연속으로 호출하더라도 결과는 다르거든요.

test("date changes when using real timers", () => {
  const realDate = new Date(2023, 9, 21, 16, 57);
  expect(new Date()).not.toEqual(realDate); // 다름
});

그런데, Jest를 사용하면 테스트의 시간을 멈출 수 있다는 사실! 😲 Jest는 효과적인 날짜 모킹(mocking)을 위해서 소위 Fake Timer, 즉 가짜 타이머를 제공합니다.

가짜 타이머는 jest.useFakeTimers()를 호출해서 얻을 수 있으며, 이 가짜 타이머의 setSystemTime() 함수를 통해서 특정 시간으로 고정해볼께요. 이렇게 해주면 그 이후로는 new Date()를 호출했을 때 항상 동일한 결과가 나오게 됩니다.

import { jest } from "@jest/globals";

test("date does not change when using fake timers", () => {
  const fakeDate = new Date(2023, 9, 21, 16, 57);
  jest.useFakeTimers().setSystemTime(fakeDate);  expect(new Date()).toEqual(fakeDate); // 같음
});

테스트 수정

그럼 Jest의 가짜 타이머를 이용해서 formatCurrentDate() 함수에 대한 테스트가 언제 실행하든지와 상관없이 항상 통과하도록 수정해볼까요?

각 테스트에서 formatCurrentDate() 함수를 호출하기 전에 가짜 타이머로 날짜와 시간을 고정시켜주기면 하면 됩니다.

datetime.test.js
import { jest } from "@jest/globals";
import { formatCurrentDate } from "./datetime";

test("formats current date for English", () => {
  jest.useFakeTimers().setSystemTime(new Date(2023, 9, 21, 16, 57));  expect(formatCurrentDate()).toEqual("October 21, 2023 at 4:57 PM");
});

test("formats current date for Korean", () => {
  jest.useFakeTimers().setSystemTime(new Date(2023, 9, 21, 16, 57));  expect(formatCurrentDate("ko")).toEqual("2023년 10월 21일 오후 4:57");
});

자, 이제 이 테스트는 다음 주에 실행하든 다음 달에 실행하든 내년에 실행하든 formatCurrentDate() 함수의 구현이 바뀌지 않는 이상 언제나 통과할 것입니다.

$ jest datetime.test.js
 PASS  ./datetime.test.js
  ✓ formats current date for English (26 ms)
  ✓ formats current date for Korean (1 ms)

Test Suites: 1 passed, 1 total
Tests:       2 passed, 2 total
Snapshots:   0 total
Time:        0.211 s, estimated 1 s
Ran all test suites matching /.datetime.test.js/i.

주의사항

Jest의 가짜 타이머를 사용할 때 흔하게 볼 수 있는 실수가 있는데요.

우리가 작성한 테스트 파일에 나중에 다른 개발자가 테스트를 추가했다고 가정을 해볼께요. 만약에 그 개발자가 Jest의 가짜 타이머에 대해서 잘 모르거나, 기존 테스트 코드를 꼼꼼이 읽어보지 않고, 아래와 같은 테스트를 추가한다면 어떻게 될까요?

datetime.test.js
import { jest } from "@jest/globals";
import { formatCurrentDate } from "./datetime";

test("formats current date for English", () => {
  jest.useFakeTimers().setSystemTime(new Date(2023, 9, 21, 16, 57));
  expect(formatCurrentDate()).toEqual("October 21, 2023 at 4:57 PM");
});

test("formats current date for Korean", () => {
  jest.useFakeTimers().setSystemTime(new Date(2023, 9, 21, 16, 57));
  expect(formatCurrentDate("ko")).toEqual("2023년 10월 21일 오후 4:57");
});

// 다른 개발자가 나중에 추가한 테스트test("date always changes", () => {  const realDate = new Date(2023, 9, 21, 16, 57);  expect(new Date()).not.toEqual(realDate); // 다름});

추가된 테스트가 예상과 다르게 실패하고 그 개발자는 영문을 몰라 머리를 긁적일 것입니다.

$ jest datetime.test.js
 FAIL  ./datetime.test.js
  ✓ formats current date for English (9 ms)
  ✓ formats current date for Korean (1 ms)date always changes

  ● date always changes

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

    Expected: not 2023-10-21T20:57:00.000Z

      17 | test("date always changes", () => {
      18 |   const realDate = new Date(2023, 9, 21, 16, 57);
    > 19 |   expect(new Date()).not.toEqual(realDate);
         |                          ^
      20 | });
      21 |

      at Object.toEqual (./datetime.test.js:19:26)

Test Suites: 1 failed, 1 total
Tests:       1 failed, 2 passed, 3 total
Snapshots:   0 total
Time:        0.111 s, estimated 1 s
Ran all test suites matching /.datetime.test.js/i.

팀 프로젝트에서 위와 같은 혼선을 예방하기 위해서, 가짜 타이머를 사용하고 난 후에는 반드시 원래 타이머로 복원해주는 것이 중요한데요. 테스트 파일에 beforeEach() 함수를 추가하고, 그 안에서 jest.useRealTimers() 함수를 호출해 주면 가짜 타이머를 사용하는 테스트가 성공하든 실패하든, 그 테스트가 종료하면 진짜 타이머가 사용됩니다. 따라서 그 외에 테스트는 원래 타이머를 사용하도록 보장할 수 있습니다.

datetime.test.js
import { jest } from "@jest/globals";
import { formatCurrentDate } from "./datetime";

beforeEach(() => jest.useRealTimers());
test("formats current date for English", () => {
  jest.useFakeTimers().setSystemTime(new Date(2023, 9, 21, 16, 57));
  expect(formatCurrentDate()).toEqual("October 21, 2023 at 4:57 PM");
});

test("formats current date for Korean", () => {
  jest.useFakeTimers().setSystemTime(new Date(2023, 9, 21, 16, 57));
  expect(formatCurrentDate("ko")).toEqual("2023년 10월 21일 오후 4:57");
});

test("date always changes", () => {
  const realDate = new Date(2023, 9, 21, 16, 57);
  expect(new Date()).not.toEqual(realDate); // 다름
});

이제 3개의 테스트 간의 서로 간섭이 없이 잘 통과하는 것을 볼 수 있습니다.

$ jest datetime.test.js
 PASS  ./datetime.test.js
  ✓ formats current date for English (31 ms)
  ✓ formats current date for Korean (1 ms)date always changes (1 ms)

Test Suites: 1 passed, 1 total
Tests:       3 passed, 3 total
Snapshots:   0 total
Time:        0.217 s, estimated 1 s
Ran all test suites matching /.datetime.test.js/i.

Jest로 테스트 전/후 처리 방법에 대해서 별도 포스팅을 참고 바랍니다.

직접 날짜 모킹

Jest에 가짜 타이머 기능이 추가되기 전에 출시된 오래된 버전의 Jest를 사용하고 있는 프로젝트에서는 어떻게 해야할까요? 이런 경우에는 대안으로 spyOn() 함수를 사용해서 Date() 생성자가 항상 고정된 날짜 객체를 만들어 내도록 직접 모킹을 해주실 수 있습니다.

test("formats current date for English", () => {
  jest
    .spyOn(global, "Date")
    .mockImplementationOnce(() => new Date(2023, 9, 21, 16, 57));
  jest.useFakeTimers().setSystemTime(new Date(2023, 9, 21, 16, 57));
});

Jest에서 fn()이나 spyOn()을 사용하여 함수를 모킹하는 방법에 대해서 별도 포스팅에서 자세히 다루고 있으니 참고 바랍니다.

전체 코드

본 포스팅에서 작성한 코드는 아래에서 확인하시고 바로 실행해보실 수 있습니다.

마치면서

지금까지 언제나 변하는 날짜와 시간 때문에 테스트를 작성하다가 겪을 수 있는 문제에 대해서 살펴보았습니다. 그리고 이러한 문제를 Jest에서 제공하는 가짜 타이머를 활용해서 해결해보았습니다.