기다리는 테스트는 좋은 테스트가 아니다
테스트를 처음 작성할 때 가장 자연스럽게 떠올리는 방법은 "실제로 기다리는 것"입니다. 예를 들어 1초 뒤에 실행되는 코드가 있다면 테스트에서도 1초를 기다리면 될 것 같습니다.
function notifyLater(callback) {
setTimeout(() => {
callback('완료!');
}, 1000);
}이 함수를 테스트하려고 다음처럼 작성할 수도 있습니다.
test('1초 뒤에 callback을 호출한다', (done) => {
notifyLater((message) => {
expect(message).toBe('완료!');
done();
});
});겉으로 보기에는 문제가 없어 보입니다. 하지만 이런 테스트가 프로젝트에 100개, 500개 쌓이면 이야기가 달라집니다. 1초짜리 테스트 100개는 아무리 빨라도 100초가 필요합니다. 더 큰 문제는 "정확히 1초 뒤"라는 것이 테스트 환경에서는 생각보다 믿을 만하지 않다는 점입니다.
setTimeout(callback, 1000)은 "정확히 1000밀리초 뒤에 실행한다"는 약속이 아닙니다. 자바스크립트 런타임은 타이머를 예약해 두었다가 이벤트 루프가 여유 있을 때 콜백을 실행합니다. 로컬 컴퓨터에서는 잘 통과하던 테스트가 CI 서버에서는 갑자기 실패할 수 있습니다. CI 서버는 여러 작업을 동시에 처리하고, 운영체제 스케줄링에 따라 타이머 실행 시점이 조금씩 밀릴 수 있습니다. 이런 식으로 가끔 실패하는 테스트를 보통 flaky test, 즉 "흔들리는 테스트"라고 부릅니다.
시간을 다루는 테스트의 핵심은 실제 시간을 기다리지 않는 것입니다. 우리는 진짜 시계를 바라보며 기다릴 필요가 없습니다. 테스트 안에서는 시간을 직접 움직이면 됩니다.
Jest는 이를 위해 fake timers, 즉 가짜 타이머 기능을 제공합니다. Jest 공식 문서에서도 실제 타이머 함수는 시간이 실제로 흘러야 하므로 테스트 환경에 적합하지 않고, Jest가 타이머를 제어 가능한 함수로 바꿀 수 있다고 설명합니다.1

fake timers의 핵심 아이디어
jest.useFakeTimers()를 호출하면 Jest는 테스트 안의 타이머 함수를 가짜 구현으로 바꿉니다. 여기서 말하는 타이머 함수는 주로 다음과 같습니다.
setTimeoutsetIntervalclearTimeoutclearInterval
가짜 타이머를 사용하면 시간이 자동으로 흐르지 않습니다. 대신 테스트 코드가 "이제 1초 지난 걸로 하자", "예약된 타이머를 전부 실행하자"라고 직접 명령합니다.
예를 들어 다음 코드를 봅시다.
// reminder.js
function remindAfterOneSecond(callback) {
setTimeout(() => {
callback('물 마실 시간입니다');
}, 1000);
}
module.exports = { remindAfterOneSecond };이 코드는 1초 뒤에 콜백을 호출합니다. 실제로 1초를 기다리지 않고 테스트하려면 이렇게 작성할 수 있습니다.
// reminder.test.js
const { remindAfterOneSecond } = require('./reminder');
test('1초 뒤에 알림 메시지를 전달한다', () => {
jest.useFakeTimers();
const callback = jest.fn();
remindAfterOneSecond(callback);
expect(callback).not.toHaveBeenCalled();
jest.advanceTimersByTime(1000);
expect(callback).toHaveBeenCalledTimes(1);
expect(callback).toHaveBeenCalledWith('물 마실 시간입니다');
jest.useRealTimers();
});여기서 실제 시간은 1초도 흐르지 않았습니다. 테스트는 거의 즉시 끝납니다. 하지만 테스트 안의 가짜 시계는 1000밀리초 앞으로 움직였고, Jest는 그 시간 안에 실행되어야 할 setTimeout 콜백을 실행했습니다.
이것이 fake timers의 가장 중요한 감각입니다. 현실의 시간은 그대로 두고, 테스트 속 시간만 움직인다.
jest.useFakeTimers()는 타이머를 가로챈다
jest.useFakeTimers()는 반드시 타이머가 등록되기 전에 호출해야 합니다. 이미 setTimeout이 실행되어 실제 타이머로 등록된 뒤에는, Jest가 그 타이머를 뒤늦게 가짜 타이머로 바꿀 수 없습니다.
잘못된 예를 먼저 보겠습니다.
test('잘못된 순서의 예', () => {
const callback = jest.fn();
setTimeout(callback, 1000); // 이미 실제 타이머로 등록됨
jest.useFakeTimers();
jest.advanceTimersByTime(1000);
expect(callback).toHaveBeenCalled(); // 실패할 수 있음
});올바른 순서는 다음과 같습니다.
test('타이머를 등록하기 전에 fake timers를 켠다', () => {
jest.useFakeTimers();
const callback = jest.fn();
setTimeout(callback, 1000);
jest.advanceTimersByTime(1000);
expect(callback).toHaveBeenCalledTimes(1);
jest.useRealTimers();
});이 순서는 모듈을 불러올 때도 중요합니다. 어떤 파일은 import 또는 require되는 순간 타이머를 등록하기도 합니다.
// auto-refresh.js
setInterval(() => {
console.log('자동 새로고침');
}, 1000);이런 모듈을 테스트한다면 require보다 jest.useFakeTimers()가 먼저 와야 합니다.
test('모듈 로딩 시 등록되는 타이머도 제어한다', () => {
jest.useFakeTimers();
require('./auto-refresh');
jest.advanceTimersByTime(1000);
jest.useRealTimers();
});초보자에게는 이 규칙 하나만 기억해도 많은 문제가 줄어듭니다. 가짜 시간은 타이머가 생기기 전에 켠다.
모든 예약 타이머를 한 번에 실행하는 runAllTimers
가장 단순한 지연 로직은 "조금 뒤에 딱 한 번 실행"되는 코드입니다. 이런 경우 jest.runAllTimers()를 사용할 수 있습니다. 이 함수는 현재 예약된 타이머들을 모두 실행합니다.
// delivery.js
function sendLater(send) {
setTimeout(() => {
send('택배가 도착했습니다');
}, 3000);
}
module.exports = { sendLater };// delivery.test.js
const { sendLater } = require('./delivery');
test('예약된 메시지를 보낸다', () => {
jest.useFakeTimers();
const send = jest.fn();
sendLater(send);
expect(send).not.toHaveBeenCalled();
jest.runAllTimers();
expect(send).toHaveBeenCalledTimes(1);
expect(send).toHaveBeenCalledWith('택배가 도착했습니다');
jest.useRealTimers();
});runAllTimers는 "몇 밀리초가 지났는지"보다 "예약된 것을 전부 실행했는지"가 중요한 테스트에 잘 맞습니다. 예를 들어 3초든 5초든 최종적으로 콜백이 실행되는지만 확인하고 싶다면 편리합니다.
하지만 항상 runAllTimers를 쓰면 안 됩니다. 테스트 대상 코드 안에 여러 타이머가 있거나, 반복 타이머가 있거나, 타이머 콜백 안에서 다시 타이머를 등록하는 코드가 있다면 모든 타이머를 실행하는 방식이 너무 과격할 수 있습니다.
원하는 만큼만 시간을 움직이는 advanceTimersByTime
실무에서는 "아직 실행되면 안 되는 시점"과 "이제 실행되어야 하는 시점"을 나누어 검증할 때가 많습니다. 이때는 jest.advanceTimersByTime(ms)가 더 적합합니다. 이 함수는 가짜 시간을 지정한 밀리초만큼 앞으로 이동시키고, 그 시간 안에 실행되어야 하는 타이머만 실행합니다.2
예를 들어 인증 코드 재전송 버튼을 생각해 봅시다. 사용자가 버튼을 누른 뒤 5초가 지나야 다시 누를 수 있다고 해 보겠습니다.
// resend.js
function enableResendAfterDelay(enableButton) {
setTimeout(() => {
enableButton();
}, 5000);
}
module.exports = { enableResendAfterDelay };테스트는 다음처럼 시간의 경계를 확인할 수 있습니다.
// resend.test.js
const { enableResendAfterDelay } = require('./resend');
test('5초가 지나야 재전송 버튼을 활성화한다', () => {
jest.useFakeTimers();
const enableButton = jest.fn();
enableResendAfterDelay(enableButton);
jest.advanceTimersByTime(4999);
expect(enableButton).not.toHaveBeenCalled();
jest.advanceTimersByTime(1);
expect(enableButton).toHaveBeenCalledTimes(1);
jest.useRealTimers();
});이 테스트는 읽는 사람에게 의도를 분명히 보여 줍니다. 4999밀리초까지는 실행되지 않고, 정확히 5000밀리초가 되었을 때 실행됩니다. 실제로 5초를 기다리지도 않습니다.
이런 테스트는 디바운스나 쓰로틀 같은 지연 로직을 검증할 때도 자주 사용합니다. 디바운스는 짧은 시간 안에 같은 동작이 여러 번 발생했을 때 마지막 호출만 처리하는 기법입니다. 검색창에 글자를 입력할 때 매 글자마다 API를 호출하지 않고, 입력이 잠시 멈춘 뒤 한 번만 요청하는 상황을 떠올리면 됩니다.
// debounce.js
function debounce(fn, delay) {
let timeoutId;
return (...args) => {
clearTimeout(timeoutId);
timeoutId = setTimeout(() => {
fn(...args);
}, delay);
};
}
module.exports = { debounce };// debounce.test.js
const { debounce } = require('./debounce');
test('연속 호출되어도 마지막 호출만 지연 후 실행한다', () => {
jest.useFakeTimers();
const search = jest.fn();
const debouncedSearch = debounce(search, 300);
debouncedSearch('j');
debouncedSearch('je');
debouncedSearch('jes');
debouncedSearch('jest');
jest.advanceTimersByTime(299);
expect(search).not.toHaveBeenCalled();
jest.advanceTimersByTime(1);
expect(search).toHaveBeenCalledTimes(1);
expect(search).toHaveBeenCalledWith('jest');
jest.useRealTimers();
});이 예제에서 clearTimeout도 함께 중요합니다. 이전에 예약된 타이머를 지우고 새 타이머를 등록하기 때문에 마지막 호출만 살아남습니다. fake timers는 이런 흐름을 실제 시간 없이 빠르게 검증하게 해 줍니다.
setInterval 테스트하기
setInterval은 일정 시간마다 반복 실행됩니다. 예를 들어 1초마다 카운트를 증가시키는 코드를 보겠습니다.
// counter.js
function createCounter() {
let count = 0;
let intervalId;
return {
start() {
intervalId = setInterval(() => {
count += 1;
}, 1000);
},
stop() {
clearInterval(intervalId);
},
getCount() {
return count;
},
};
}
module.exports = { createCounter };이 코드를 실제 시간으로 테스트하면 3초 뒤 count가 3인지 확인해야 합니다. fake timers를 사용하면 즉시 확인할 수 있습니다.
// counter.test.js
const { createCounter } = require('./counter');
test('1초마다 count를 증가시킨다', () => {
jest.useFakeTimers();
const counter = createCounter();
counter.start();
expect(counter.getCount()).toBe(0);
jest.advanceTimersByTime(1000);
expect(counter.getCount()).toBe(1);
jest.advanceTimersByTime(2000);
expect(counter.getCount()).toBe(3);
counter.stop();
jest.useRealTimers();
});여기서 advanceTimersByTime(2000)은 "처음부터 2000밀리초"가 아니라 "현재 시점에서 2000밀리초 더"를 의미합니다. 이미 1000밀리초를 움직였으므로 총 3000밀리초가 지난 셈이고, count는 3이 됩니다.
setInterval 테스트에서 조심할 점은 jest.runAllTimers()입니다. interval은 기본적으로 계속 반복됩니다. "모든 타이머를 실행하라"고 명령하면 끝이 없는 반복에 빠질 수 있습니다. Jest는 무한 루프를 막기 위해 일정 개수 이상의 타이머가 실행되면 중단하지만, 그 전에 테스트 의도가 흐려집니다. 반복 타이머에는 보통 advanceTimersByTime으로 필요한 시간만큼만 움직이는 방식이 더 안전합니다.
재귀 타이머와 runOnlyPendingTimers
반복 작업을 만드는 방법은 setInterval만 있는 것이 아닙니다. 어떤 코드는 setTimeout 콜백 안에서 다시 setTimeout을 등록합니다. 이를 재귀 타이머라고 부를 수 있습니다.
// polling.js
function startPolling(fetchData) {
function poll() {
fetchData();
setTimeout(() => {
poll();
}, 1000);
}
poll();
}
module.exports = { startPolling };이 코드는 처음에 fetchData를 한 번 호출하고, 이후 1초마다 다시 호출합니다. 이런 코드에 runAllTimers를 사용하면 계속 새 타이머가 생기므로 위험합니다. Jest 공식 문서에서도 타이머 콜백이 다시 타이머를 등록하는 경우 runAllTimers가 끝나지 않는 루프가 될 수 있으며, 이런 상황에서는 runOnlyPendingTimers()를 사용할 수 있다고 설명합니다.3
runOnlyPendingTimers는 이름 그대로 "현재 대기 중인 타이머만" 실행합니다. 실행 도중 새로 등록된 타이머까지 끝없이 따라가지 않습니다.
// polling.test.js
const { startPolling } = require('./polling');
test('현재 예약된 다음 polling만 실행한다', () => {
jest.useFakeTimers();
const fetchData = jest.fn();
startPolling(fetchData);
expect(fetchData).toHaveBeenCalledTimes(1);
jest.runOnlyPendingTimers();
expect(fetchData).toHaveBeenCalledTimes(2);
jest.useRealTimers();
});재귀 타이머나 polling 로직을 테스트할 때는 다음 질문을 먼저 해 보는 것이 좋습니다.
한 번의 예약만 확인하면 되는가?
특정 시간 동안 몇 번 실행되는지 확인하면 되는가?
무한히 반복될 수 있는 구조인가?
한 번의 예약만 확인하면 runOnlyPendingTimers, 특정 시간 동안의 반복 횟수를 확인하면 advanceTimersByTime, 종료가 명확한 단순 타이머라면 runAllTimers가 잘 맞습니다.
Promise와 타이머가 함께 있을 때
시간 테스트에서 초보자가 가장 자주 막히는 지점은 Promise와 타이머가 함께 등장할 때입니다. 이유는 자바스크립트의 작업 큐가 하나가 아니기 때문입니다.
간단히 말하면 setTimeout 콜백은 보통 macrotask로 다뤄지고, Promise의 .then이나 async/await 이후 작업은 microtask로 다뤄집니다. 용어가 어렵다면 이렇게 이해해도 충분합니다. 타이머 큐와 Promise 큐는 결이 다르며, fake timers가 시간을 움직인다고 해서 Promise 후속 작업까지 항상 즉시 끝나는 것은 아닙니다.
먼저 타이머 콜백 안에서 Promise를 사용하는 코드를 보겠습니다.
// async-reminder.js
function remindAfterDelay(loadMessage, callback) {
setTimeout(async () => {
const message = await loadMessage();
callback(message);
}, 1000);
}
module.exports = { remindAfterDelay };loadMessage는 Promise를 반환한다고 가정합니다.
// async-reminder.test.js
const { remindAfterDelay } = require('./async-reminder');
test('1초 뒤 비동기 메시지를 불러와 callback에 전달한다', async () => {
jest.useFakeTimers();
const loadMessage = jest.fn().mockResolvedValue('스트레칭할 시간입니다');
const callback = jest.fn();
remindAfterDelay(loadMessage, callback);
jest.advanceTimersByTime(1000);
expect(loadMessage).toHaveBeenCalledTimes(1);
expect(callback).not.toHaveBeenCalled();
await Promise.resolve();
expect(callback).toHaveBeenCalledTimes(1);
expect(callback).toHaveBeenCalledWith('스트레칭할 시간입니다');
jest.useRealTimers();
});여기서 중요한 장면은 await Promise.resolve()입니다. advanceTimersByTime(1000)을 호출하면 타이머 콜백 자체는 실행됩니다. 그래서 loadMessage는 호출됩니다. 하지만 await loadMessage() 뒤의 코드, 즉 callback(message)는 Promise가 이어서 처리되는 단계에 있습니다. 테스트가 그 microtask 처리를 기다리지 않으면 callback이 아직 호출되지 않은 상태일 수 있습니다.
이번에는 반대로 Promise 안에서 타이머를 등록하는 예를 보겠습니다.
// schedule-after-ready.js
function scheduleAfterReady(callback) {
Promise.resolve().then(() => {
setTimeout(() => {
callback('준비 완료 후 실행');
}, 1000);
});
}
module.exports = { scheduleAfterReady };이 코드를 다음처럼 테스트하면 실패할 수 있습니다.
test('잘못된 예: 타이머가 등록되기 전에 시간을 움직인다', () => {
jest.useFakeTimers();
const callback = jest.fn();
scheduleAfterReady(callback);
jest.advanceTimersByTime(1000);
expect(callback).toHaveBeenCalled(); // 실패할 수 있음
jest.useRealTimers();
});왜 실패할까요? scheduleAfterReady를 호출한 직후에는 아직 Promise.resolve().then(...) 안의 코드가 실행되지 않았을 수 있습니다. 즉 setTimeout 자체가 아직 등록되지 않았는데 시간을 1000밀리초 움직인 것입니다. 아무 타이머도 없으니 실행될 것도 없습니다.
올바른 테스트는 Promise가 타이머를 등록할 기회를 먼저 줘야 합니다.
const { scheduleAfterReady } = require('./schedule-after-ready');
test('Promise 이후 등록된 타이머를 실행한다', async () => {
jest.useFakeTimers();
const callback = jest.fn();
scheduleAfterReady(callback);
await Promise.resolve(); // then 안에서 setTimeout이 등록될 시간을 준다
jest.advanceTimersByTime(1000);
expect(callback).toHaveBeenCalledTimes(1);
expect(callback).toHaveBeenCalledWith('준비 완료 후 실행');
jest.useRealTimers();
});최근 Jest에는 runAllTimersAsync, advanceTimersByTimeAsync 같은 비동기 타이머 API도 있습니다. Promise와 타이머가 여러 단계로 얽힌 코드에서는 이런 API가 더 편할 수 있습니다. 하지만 초보 단계에서는 먼저 원리를 이해하는 것이 중요합니다. fake timers는 타이머를 제어하고, Promise의 다음 단계는 별도로 기다려야 할 수 있다. 이 문장만 기억해도 대부분의 혼란을 줄일 수 있습니다.
async 함수 안의 지연 테스트
다음처럼 async 함수 안에서 setTimeout을 Promise로 감싼 코드는 매우 흔합니다.
// sleep.js
function sleep(ms) {
return new Promise((resolve) => {
setTimeout(resolve, ms);
});
}
async function workAfterDelay() {
await sleep(2000);
return '작업 완료';
}
module.exports = { workAfterDelay };이때 초보자가 자주 작성하는 잘못된 테스트가 있습니다.
test('잘못된 예: await부터 해 버리면 테스트가 멈춘다', async () => {
jest.useFakeTimers();
const result = await workAfterDelay();
jest.advanceTimersByTime(2000);
expect(result).toBe('작업 완료');
jest.useRealTimers();
});이 테스트는 멈출 수 있습니다. await workAfterDelay()에서 함수가 2초짜리 가짜 타이머를 기다리는데, 그 타이머를 진행시키는 코드는 그 아래에 있습니다. 하지만 아래 코드는 await가 끝나야 실행됩니다. 서로가 서로를 기다리는 모양입니다.
올바른 흐름은 비동기 작업을 먼저 시작하고, 기다리기 전에 시간을 움직이는 것입니다.
const { workAfterDelay } = require('./sleep');
test('2초 뒤 작업 결과를 반환한다', async () => {
jest.useFakeTimers();
const promise = workAfterDelay();
jest.advanceTimersByTime(2000);
const result = await promise;
expect(result).toBe('작업 완료');
jest.useRealTimers();
});이 패턴은 아주 중요합니다.
const promise = 비동기함수시작();
시간을움직인다();
const result = await promise;시간을 움직이기 전에 await부터 해 버리면, 테스트가 가짜 시간 앞에서 멈춰 설 수 있습니다.
실제 시간 측정 대신 가짜 시간을 검증하기
가끔 다음처럼 실제 시간을 재서 테스트하려는 유혹이 생깁니다.
test('최소 100ms는 기다린다', async () => {
const startedAt = Date.now();
await someDelayedWork();
const endedAt = Date.now();
expect(endedAt - startedAt).toBeGreaterThanOrEqual(100);
});이 테스트는 읽기에는 자연스럽지만 불안정할 수 있습니다. Date.now()는 밀리초 단위로 값을 반환하고, 타이머 실행 시점은 런타임과 운영체제의 영향을 받습니다. 실제 사례에서도 100ms 이상을 기대했는데 특정 Node.js 버전이나 CI 환경에서 99ms처럼 측정되어 실패하는 문제가 보고됩니다.4
테스트가 확인하고 싶은 것은 "현실에서 정말 100ms가 지났는가?"가 아닙니다. 보통은 "코드가 100ms 지연 로직을 올바르게 사용했는가?"입니다. 그렇다면 실제 시계를 보는 대신 fake timers로 행동을 검증하는 편이 낫습니다.
처음에는 다음처럼 콜백 호출 여부로 경계를 확인하는 방식이 가장 이해하기 쉽습니다.
function callAfterDelay(callback, delay) {
setTimeout(() => {
callback('done');
}, delay);
}
test('100ms 뒤에 callback을 호출한다', () => {
jest.useFakeTimers();
const callback = jest.fn();
callAfterDelay(callback, 100);
jest.advanceTimersByTime(99);
expect(callback).not.toHaveBeenCalled();
jest.advanceTimersByTime(1);
expect(callback).toHaveBeenCalledTimes(1);
expect(callback).toHaveBeenCalledWith('done');
jest.useRealTimers();
});Promise를 반환하는 지연 함수라면 비동기 작업을 먼저 시작하고, 시간을 움직인 뒤, 마지막에 Promise를 기다립니다.
function runWithMinimumDelay(task, delay) {
return new Promise((resolve) => {
task();
setTimeout(() => {
resolve('done');
}, delay);
});
}
test('지정한 지연 시간이 지나야 완료된다', async () => {
jest.useFakeTimers();
const task = jest.fn();
const promise = runWithMinimumDelay(task, 100);
expect(task).toHaveBeenCalledTimes(1);
jest.advanceTimersByTime(99);
let completed = false;
promise.then(() => {
completed = true;
});
await Promise.resolve();
expect(completed).toBe(false);
jest.advanceTimersByTime(1);
const result = await promise;
expect(result).toBe('done');
jest.useRealTimers();
});이 테스트는 실제 100ms를 기다리지 않습니다. 대신 테스트 안의 시계를 99ms, 1ms로 나누어 움직이며 경계를 확인합니다. 다만 Promise 완료 여부를 중간에 확인하는 코드는 조금 복잡할 수 있으므로, 처음에는 콜백 예제처럼 "호출되었는가 / 아직 호출되지 않았는가"를 검증하는 방식으로 시작해도 충분합니다.
테스트는 시간이 아니라 행동을 검증해야 합니다.
각 테스트 뒤에는 반드시 원래 시간으로 되돌리기
fake timers는 강력하지만, 켜 둔 채로 방치하면 다른 테스트에 영향을 줍니다. 어떤 테스트는 실제 타이머를 기대하는데 이전 테스트가 가짜 타이머를 켜 둔 상태라면, 원인을 찾기 어려운 실패가 생길 수 있습니다.
그래서 보통은 beforeEach와 afterEach를 함께 사용합니다.
beforeEach(() => {
jest.useFakeTimers();
});
afterEach(() => {
jest.runOnlyPendingTimers();
jest.useRealTimers();
});Testing Library 문서에서도 fake timers는 네이티브 타이머 함수를 mock하는 것이므로 테스트가 끝난 뒤 실제 타이머로 복구해야 하며, 실제 타이머로 전환하기 전에 남은 pending timer를 실행하는 패턴을 안내합니다.5
다만 모든 상황에서 남은 타이머를 실행하고 싶지는 않을 수 있습니다. 예를 들어 어떤 테스트에서는 "이 타이머가 실행되기 전에 취소되는가"를 확인하고 있고, 남은 타이머를 실행하면 오히려 의도하지 않은 부수 효과가 생길 수 있습니다. 그럴 때는 jest.clearAllTimers()로 남은 타이머를 지우는 방식도 사용할 수 있습니다.
afterEach(() => {
jest.clearAllTimers();
jest.useRealTimers();
});중요한 것은 둘 중 무엇을 쓰든 테스트가 끝날 때 정리해야 한다는 점입니다. fake timers를 켰다면 반드시 끄십시오.
개별 테스트 안에서만 fake timers를 쓰고 싶다면 try/finally도 좋은 습관입니다.
test('타이머를 사용하는 테스트', () => {
jest.useFakeTimers();
try {
const callback = jest.fn();
setTimeout(callback, 1000);
jest.advanceTimersByTime(1000);
expect(callback).toHaveBeenCalledTimes(1);
} finally {
jest.useRealTimers();
}
});finally 안의 코드는 테스트 중간에 에러가 나도 실행됩니다. 덕분에 실패한 테스트가 뒤따르는 다른 테스트까지 망가뜨리는 일을 줄일 수 있습니다.
어떤 타이머 API를 선택해야 할까
시간 테스트를 작성하다 보면 runAllTimers, advanceTimersByTime, runOnlyPendingTimers 중 무엇을 써야 할지 헷갈립니다. 기준은 생각보다 단순합니다.
runAllTimers는 끝이 명확한 단순 타이머에 어울립니다. 예를 들어 setTimeout 하나가 있고, 그 콜백이 실행되는지만 확인하면 되는 상황입니다.
advanceTimersByTime은 시간의 흐름을 단계별로 검증할 때 좋습니다. "999ms까지는 안 된다", "1000ms가 되면 된다", "3초 동안 interval이 3번 실행된다" 같은 테스트에 적합합니다.
runOnlyPendingTimers는 타이머 콜백 안에서 다시 타이머가 등록되는 코드에 유용합니다. polling, 재귀 setTimeout, 무한 반복 가능성이 있는 코드에서는 전체를 다 실행하지 말고 현재 예약분만 실행하는 편이 안전합니다.
간단히 기억하면 이렇습니다.
한 번에 끝나는 타이머라면
runAllTimers시간 경계를 확인하려면
advanceTimersByTime반복 또는 재귀 타이머라면
advanceTimersByTime이나runOnlyPendingTimers
초보 단계에서는 advanceTimersByTime을 기본값처럼 사용해도 좋습니다. 시간을 얼마나 움직였는지 코드에 드러나기 때문에 테스트 의도가 더 선명해지는 경우가 많습니다.
작은 실전 예제: 자동 로그아웃 타이머
마지막으로 조금 더 현실적인 예제를 보겠습니다. 사용자가 일정 시간 동안 활동하지 않으면 자동 로그아웃시키는 코드입니다.
// auto-logout.js
function createAutoLogout(logout, timeoutMs) {
let timerId;
function reset() {
clearTimeout(timerId);
timerId = setTimeout(() => {
logout();
}, timeoutMs);
}
function stop() {
clearTimeout(timerId);
}
return { reset, stop };
}
module.exports = { createAutoLogout };이 코드는 reset이 호출될 때마다 기존 타이머를 지우고 새 타이머를 등록합니다. 사용자가 계속 활동하면 로그아웃이 미뤄지고, 마지막 활동 이후 timeout 시간이 지나면 로그아웃됩니다.
테스트해 봅시다.
// auto-logout.test.js
const { createAutoLogout } = require('./auto-logout');
describe('createAutoLogout', () => {
beforeEach(() => {
jest.useFakeTimers();
});
afterEach(() => {
jest.clearAllTimers();
jest.useRealTimers();
});
test('지정한 시간 동안 활동이 없으면 로그아웃한다', () => {
const logout = jest.fn();
const autoLogout = createAutoLogout(logout, 5000);
autoLogout.reset();
jest.advanceTimersByTime(4999);
expect(logout).not.toHaveBeenCalled();
jest.advanceTimersByTime(1);
expect(logout).toHaveBeenCalledTimes(1);
});
test('중간에 reset하면 로그아웃 시간이 다시 미뤄진다', () => {
const logout = jest.fn();
const autoLogout = createAutoLogout(logout, 5000);
autoLogout.reset();
jest.advanceTimersByTime(3000);
autoLogout.reset();
jest.advanceTimersByTime(4999);
expect(logout).not.toHaveBeenCalled();
jest.advanceTimersByTime(1);
expect(logout).toHaveBeenCalledTimes(1);
});
test('stop을 호출하면 예약된 로그아웃을 취소한다', () => {
const logout = jest.fn();
const autoLogout = createAutoLogout(logout, 5000);
autoLogout.reset();
autoLogout.stop();
jest.advanceTimersByTime(5000);
expect(logout).not.toHaveBeenCalled();
});
});이 예제에는 시간 테스트의 핵심이 모두 들어 있습니다. 실제 시간을 기다리지 않고, fake timers를 먼저 켜고, 경계 시간을 나누어 검증하고, 테스트가 끝나면 타이머를 정리합니다.
이번 장에서 기억할 것
시간을 다루는 테스트는 실제로 기다리기 시작하면 금방 느리고 불안정해집니다. setTimeout, setInterval, 디바운스, polling, 자동 로그아웃처럼 시간에 의존하는 코드는 Jest의 fake timers로 테스트 안의 시간을 직접 움직이는 편이 좋습니다.
jest.useFakeTimers()는 타이머가 등록되기 전에 호출해야 합니다. 단순히 예약된 타이머를 모두 실행하려면 runAllTimers, 특정 시간만큼만 진행하며 경계를 확인하려면 advanceTimersByTime, 재귀 타이머처럼 새 타이머가 계속 생기는 구조에서는 runOnlyPendingTimers를 고려합니다.
Promise와 타이머가 함께 있을 때는 순서가 특히 중요합니다. 타이머를 진행한 뒤 Promise 후속 처리를 별도로 기다려야 할 수 있고, async 함수 안의 지연을 테스트할 때는 비동기 작업을 먼저 시작한 뒤 시간을 움직이고 마지막에 await해야 합니다.
마지막으로 fake timers를 켰다면 반드시 실제 타이머로 되돌리세요. 좋은 시간 테스트는 빠르고, 안정적이며, "정말 몇 초가 지났는가"보다 "시간이 지났을 때 코드가 어떤 행동을 하는가"를 분명하게 검증합니다.
참고
이 노트는 보도·비평·교육·연구 목적으로 작성되었습니다. 저작권 문의가 있으시면 에서 알려주세요.
