Jest 타이머 모킹

Jest 타이머 모킹 (Timer Mocks)

setTimeout(), setInterval() 같은 네이티브 타이머 함수는 실제 시간이 흘러야 하니 테스트 환경에서 다루기 까다로워요. Jest는 타이머를 '시간의 흐름을 직접 조절할 수 있는 함수'로 바꿔치기해서, 밀리초 단위로 시간을 앞당기며 테스트할 수 있게 해 줘요.

출처: Jest 공식 문서 - Timer Mocks

가짜 타이머 켜고 끄기

jest.useFakeTimers()를 호출하면 setTimeout() 등 타이머 함수의 원래 구현이 가짜 구현으로 바뀌고, jest.useRealTimers()로 원래 동작을 되돌릴 수 있어요.

1초 후 콜백을 실행하는 타이머 게임을 예로 들어 볼게요.

function timerGame(callback) {
  console.log('Ready....go!');
  setTimeout(() => {
    console.log("Time's up -- stop!");
    callback && callback();
  }, 1000);
}

module.exports = timerGame;
jest.useFakeTimers();
jest.spyOn(global, 'setTimeout');

test('waits 1 second before ending the game', () => {
  const timerGame = require('../timerGame');
  timerGame();

  expect(setTimeout).toHaveBeenCalledTimes(1);
  expect(setTimeout).toHaveBeenLastCalledWith(expect.any(Function), 1000);
});

콜백이 1초 뒤에 호출되는지를 테스트할 때는 타이머 제어 API로 중간에 시간을 앞당겨요.

jest.useFakeTimers();

test('calls the callback after 1 second', () => {
  const timerGame = require('../timerGame');
  const callback = jest.fn();

  timerGame(callback);

  // 이 시점엔 아직 콜백이 호출되지 않았어야 해요.
  expect(callback).not.toHaveBeenCalled();

  // 모든 타이머가 실행될 때까지 시간을 앞당겨요.
  jest.runAllTimers();

  // 이제 콜백이 호출됐어요!
  expect(callback).toHaveBeenCalled();
  expect(callback).toHaveBeenCalledTimes(1);
});

재귀 타이머와 runOnlyPendingTimers

자기 콜백 안에서 새 타이머를 만드는 재귀 타이머가 있으면 runAllTimers()로 전부 돌리다 보면 무한 루프에 빠져요. 그때 이런 오류가 나요: "Aborting after running 100000 timers, assuming an infinite loop!"

이럴 때는 jest.runOnlyPendingTimers()를 쓰면 현재 대기 중인 타이머만 실행하고, 그 과정에서 새로 생긴 타이머는 실행하지 않아요.

jest.useFakeTimers();
jest.spyOn(global, 'setTimeout');

describe('infiniteTimerGame', () => {
  test('schedules a 10-second timer after 1 second', () => {
    const infiniteTimerGame = require('../infiniteTimerGame');
    const callback = jest.fn();

    infiniteTimerGame(callback);

    expect(setTimeout).toHaveBeenCalledTimes(1);
    expect(setTimeout).toHaveBeenLastCalledWith(expect.any(Function), 1000);

    // 현재 대기 중인 타이머만 소진해요 (그 사이 생기는 새 타이머 제외).
    jest.runOnlyPendingTimers();

    expect(callback).toHaveBeenCalled();

    // 10초 뒤 새 타이머가 하나 더 생겼어요.
    expect(setTimeout).toHaveBeenCalledTimes(2);
    expect(setTimeout).toHaveBeenLastCalledWith(expect.any(Function), 10000);
  });
});

디버깅 등을 위해 오류를 던지기 전에 실행할 타이머 상한을 바꿀 수 있어요.

jest.useFakeTimers({ timerLimit: 100 });

advanceTimersByTime

jest.advanceTimersByTime(msToRun)을 호출하면 모든 타이머가 msToRun 밀리초만큼 앞당겨져요. setTimeout()/setInterval()로 큐에 들어간 매크로 태스크가 그 시간 동안 실행되고, 그 안에서 새로 예약돼 같은 시간대에 실행될 태스크도 남은 타이머가 없을 때까지 실행돼요.

jest.useFakeTimers();

it('calls the callback after 1 second via advanceTimersByTime', () => {
  const timerGame = require('../timerGame');
  const callback = jest.fn();

  timerGame(callback);

  expect(callback).not.toHaveBeenCalled();

  jest.advanceTimersByTime(1000);

  expect(callback).toHaveBeenCalled();
});

가끔 대기 중인 타이머를 전부 비워야 할 때는 jest.clearAllTimers()가 있어요.

애니메이션 프레임 다루기

애니메이션 프레임 안에서 작업을 예약할 때는 jest.advanceTimersToNextFrame()이 편리해요. 가짜 타이머 기준 애니메이션 프레임은 시계 시작 후 매 16ms(대략 초당 60프레임)마다 실행돼요. requestAnimationFrame(callback)으로 콜백을 예약하면 시계가 16ms 진행됐을 때 콜백이 호출되고, advanceTimersToNextFrame()은 다음 16ms 배수 지점까지 시계를 앞당겨요.

jest.useFakeTimers();

it('calls the animation frame callback after advanceTimersToNextFrame()', () => {
  const callback = jest.fn();

  requestAnimationFrame(callback);

  expect(callback).not.toHaveBeenCalled();

  jest.advanceTimersToNextFrame();

  expect(callback).toHaveBeenCalled();
  expect(callback).toHaveBeenCalledTimes(1);
});

doNotFake 옵션

특정 API의 원래 구현을 덮어쓰지 않고 싶다면 doNotFake 옵션을 줘요. 예를 들어 jsdom 환경에서 performance.mark()에 커스텀 mock 함수를 제공하고 싶다면 이렇게 해요.

/**
 * @jest-environment jsdom
 */
const mockPerformanceMark = jest.fn();
window.performance.mark = mockPerformanceMark;

test('allows mocking `performance.mark()`', () => {
  jest.useFakeTimers({ doNotFake: ['performance'] });
  expect(window.performance.mark).toBe(mockPerformanceMark);
});

가짜 타이머 API 전체 목록은 Fake Timers API 문서를 참고해요.

더 알아보기