Jest 타이머 모킹
Jest 타이머 모킹 (Timer Mocks)
setTimeout(), setInterval() 같은 네이티브 타이머 함수는 실제 시간이 흘러야 하니 테스트 환경에서 다루기 까다로워요. Jest는 타이머를 '시간의 흐름을 직접 조절할 수 있는 함수'로 바꿔치기해서, 밀리초 단위로 시간을 앞당기며 테스트할 수 있게 해 줘요.
가짜 타이머 켜고 끄기
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 문서를 참고해요.
더 알아보기
- The Jest Object - Fake Timers - 가짜 타이머 API 전체
- The Jest Object - jest.useFakeTimers - 타이머 구성 옵션