Featured image of post FakeTimers로 타이머 API 테스트 코드를 작성해보자

FakeTimers로 타이머 API 테스트 코드를 작성해보자

최근 발행-구독 패턴을 활용하기 위한 메시지 브로커를 구현하면서 주어진 시간 간격으로 사용하지 않는 채널을 정리하는 기능을 추가하였는데요

이를 위해 setInterval을 활용하여 특정 기능을 설정한 주기로 실행시키는 Scheduler를 구현하였습니다.

구현한 Scheduler의 테스트코드를 작성하며 JestFakeTimer를 활용하였는데 이 경험을 공유해보려해요

먼저 Scheduler를 살펴볼까요?

Scheduler

Scheduler는 아래와 같은 기능을 가지고 있습니다.

  1. 작업 등록/해제
    • registerTask: 새로운 주기적 작업 등록
    • unregisterTask: 등록된 작업 제거
  2. 작업 실행 제어
    • start/stop: 개별 작업의 시작과 중지
    • startAll/stopAll: 모든 작업의 일괄 시작과 중지
  3. 동시성 제어
    • runningTasks를 통한 동일 작업의 중복 실행 방지

실제 Scheduler 구현에서 setInterval의 동작을 확인해야하는 코드들만 추려봤습니다.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
export interface ScheduledTask {
  readonly name: string;
  readonly interval: number;

  execute(): Promise<void>;
}

export class Scheduler {
  private readonly timers: Map<string, NodeJS.Timeout> = new Map();
  private readonly runningTasks: Set<string> = new Set();

  constructor(private tasks: ScheduledTask[] = []) {
  }

  public registerTask(task: ScheduledTask) {
    if (this.tasks.find((t) => t.name === task.name)) {
      throw new Error(`Task with name ${task.name} already exists`);
    }
    this.tasks.push(task);
  }

  public start(taskName: string) {
    const task = this.tasks.find((task) => task.name === taskName);

    if (!task) {
      throw new Error(`Task ${taskName} not found`);
    }

    if (this.timers.has(taskName)) {
      return;
    }

    const timer = setInterval(() => this.executeTask(task), task.interval);

    this.timers.set(taskName, timer);
  }

  private async executeTask(task: ScheduledTask) {
    if (this.runningTasks.has(task.name)) {
      return;
    }

    this.runningTasks.add(task.name);

    try {
      await task.execute();
    } finally {
      this.runningTasks.delete(task.name);
    }
  }
}

ScheduledTask의 구현체들을 받아, 설정한 주기대로 setInterval을 적용해주는 간단한 처리입니다.

타이머 API를 이용한 테스트

먼저 FakeTimers를 사용하지 않고 start를 수행하였을 때의 테스트 코드를 살펴볼까요?

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
describe('Scheduler (without FakeTimers)', () => {
  let tasks: ScheduledTask[];
  let scheduler: Scheduler;

  beforeEach(() => {
    tasks = [];
    scheduler = new Scheduler(tasks);
  });

  describe('start', () => {
    it('지정된 간격으로 작업이 실행되어야 한다', (done) => {
      const mockTask = createMockTask('test-task', 1000);
      const tasks = [mockTask];
      const scheduler = new Scheduler(tasks);

      scheduler.start(mockTask.name);

      setTimeout(() => {
        expect(mockTask.execute).toHaveBeenCalledTimes(3);
        scheduler.stop(mockTask.name);
        done();
      }, 3000);
    });
  });

  it('동일한 작업이 동시에 실행되지 않아야 한다', (done) => {
    let executionCount = 0;
    const mockTask = createMockTask('test-task', 1000);
    mockTask.execute.mockImplementation(async () => {
      executionCount++;
      await new Promise((resolve) => setTimeout(resolve, 2000));
    });

    const tasks = [mockTask];
    const scheduler = new Scheduler(tasks);

    scheduler.start(mockTask.name);

    setTimeout(() => {
      expect(executionCount).toEqual(1);
      setTimeout(() => {
        expect(executionCount).toEqual(2);
        scheduler.stop(mockTask.name);
        done();
      }, 1500);
    }, 1500);
  });
});

위 테스트 코드는 mockTaskexecute에 담겨있는 jest.fn().mockResolvedValue(undefined)를 1초마다 한번씩 실행시켜서 그 결과를 확인하는 테스트와 스케줄러를 통해 실행되는 작업이 아직 완료되지 않았을 경우 해당 작업이 실행되지 않는 것을 확인하는 테스트입니다.

테스트 코드를 이해하기위해 먼저 타이머 API의 동작을 간단히 설명이 필요할 것 같은데요

싱글스레드로 동작하는 자바스크립트는 하나의 콜스택만 사용할 수 있습니다. 따라서 setTimeout 또는 setInterval과 같은 타이머 API를 통해 넘겨진 동작(콜백 함수)는 별도의 공간에서 카운트다운을 시작하게됩니다.

카운트다운이 완료되면 해당 콜백 함수가 태스크 큐(매크로태스크 큐)에 들어가게되고, 이벤트 루프가 콜스택이 비었을 때 태스크 큐에 담겨있는 콜백을 실행하게 됩니다.

그렇기 때문에 Scheduler.start 메서드가 실행되면 setInterval의 콜백으로 ScheduledTask.excute를 넘겨주게되고, 카운트다운이 완료되면 태스크 큐로 들어간 후 자신의 차례가 되었을 때 실행되게 됩니다.

그렇기 때문에 setTimeout의 콜백으로 expect를 넘겨줘야 Scheduler.start가 실행되고 검증 처리를 수행할 수 있게 됩니다.

문제점

예시처럼 타이머 API를 직접 활용하는 테스트 코드에는 몇 가지 문제점이 있습니다.


실제 시간에 의존

첫 번째로 테스트가 실제 시간에 의존한다는 것 인데요

실제 시간을 활용하는 타이머 API를 사용하기 때문에, 테스트의 interval로 주어진 시간만큼 필요하게 됩니다.

따라서 interval 값을 크게 줄 경우 그 시간만큼 테스트가 길어지게 되는 것이죠

이 때문에 좋은 테스트 코드의 조건인 빠른 시간에 적합하지 않을 수 있고, 이를 해결하기 위해 interval을 작은 값으로 지정하면 또 다른 문제가 발생할 수 있습니다.


신뢰성

두 번째로 신뢰성 인데요. 테스트의 신뢰성은 동일한 조건에서는 항상 동일한 결과가 나와야 한다는 의미인데, 타이머 API의 특성으로 인해 동일한 결과가 나오지 않을 수 있습니다.

앞서 설명드렸던 것 처럼 타이머 API를 통해 넘겨진 콜백은 카운트다운이 완료된 후 태스크 큐에 적재되고, 먼저 적재된 작업들을 메인 스레드가 다 처리해야 해당 작업을 메인 스레드가 처리하게 되는데요

이로 인해 타이머 API가 설정한 시간에 도달해서 태스크 큐에 작업을 적재하더라도 즉시 실행되는 것은 보장할 수 없습니다.

  • 우선 순위가 더 높은 작업들이 큐에 많이 적재되어 있는 경우
  • 선행 작업이 메인 스레드를 오래 점유하는 경우

그렇기 때문에 시스템 부하에 따라 호출 횟수가 달라져 테스트를 통과하지 못할 여지가 있습니다.

물론 현재 테스트코드는 가볍고, 양이 적기 때문에 당장 문제가 없을 지 모르지만 아예 배제할수는 없는 것이죠

FakeTimers란?

Jest의 FakeTimers는 테스트 환경에서 시간 관련 함수들을 모킹하여 시간의 흐름을 제어할 수 있게 해주는 기능입니다. 내부적으로는 @sinonjs/fake-timers를 사용하여 구현되어 있습니다.

FakeTimers를 사용하면 setTimeout, setInterval, clearTimeout, clearInterval 등의 타이머 API를 가짜 구현체로 대체하여 실제 시간이 흐르는 것을 기다리지 않고도 타이머 기반 코드를 테스트할 수 있습니다.

주요 기능 분석

FakeTimers는 크게 세 가지 중요한 기능을 제공합니다.

타이머 제어

가짜 타이머를 활성화하고 관리하는 기능들입니다.

1
2
3
4
5
6
7
8
// 가짜 타이머 활성화
jest.useFakeTimers();

// 실제 타이머로 복원
jest.useRealTimers();

// 모든 타이머 초기화
jest.clearAllTimers();
  • useFakeTimers():

    • 모든 타이머 관련 함수(setTimeout, setInterval 등)를 가짜 구현체로 교체
    • 테스트 환경에서 시간을 완벽하게 제어할 수 있게 됨
    • 설정 옵션을 통해 특정 타이머만 가짜로 교체하는 것도 가능
  • useRealTimers():

    • 모든 타이머 함수를 원래의 구현체로 복원
    • 테스트 종료 후 정리 작업에 필수
  • clearAllTimers():

    • 현재 대기 중인 모든 타이머를 제거
    • 타이머 관련 상태를 완전히 초기화할 때 사용

시간 진행 제어

시간의 흐름을 제어하여 타이머의 실행을 관리하는 기능들입니다.

1
2
3
4
5
6
7
8
// 대기 중인 모든 타이머 즉시 실행
jest.runAllTimers();

// 현재 대기 중인 타이머만 실행
jest.runOnlyPendingTimers();

// 특정 시간만큼 진행
jest.advanceTimersByTime(1000); // 1초 진행
  • runAllTimers():

    • 현재 예약된 모든 타이머를 즉시 실행
    • 재귀적인 타이머(타이머 내에서 새로운 타이머를 생성하는 경우)도 모두 실행
    • 무한 루프 방지를 위해 기본적으로 100,000회로 제한됨
  • runOnlyPendingTimers():

    • 현재 대기 중인 타이머만 실행하고 새로 생성되는 타이머는 실행하지 않음
    • 재귀적인 타이머를 한 단계씩 테스트할 때 유용
     1
     2
     3
     4
     5
     6
     7
     8
     9
    10
    
    // 예: 재귀적 타이머 테스트
    function recursiveTimer() {
      setTimeout(() => {
        console.log('tick');
        recursiveTimer();
      }, 1000);
    }
    
    recursiveTimer();
    jest.runOnlyPendingTimers(); // 첫 번째 타이머만 실행
    
  • advanceTimersByTime():

    • 가상의 시간을 특정 밀리초만큼 진행
    • 진행되는 시간 동안 실행되어야 할 모든 타이머를 실행
    • 시간의 흐름을 가장 정확하게 시뮬레이션할 수 있는 방법

비동기 작업 지원

Promise 기반의 비동기 작업을 지원하는 기능들입니다.

1
2
3
// Promise와 함께 사용할 수 있는 비동기 버전
await jest.runAllTimersAsync();
await jest.advanceTimersByTimeAsync(1000);
  • runAllTimersAsync():

    • runAllTimers의 비동기 버전
    • Promise 콜백이 처리될 때까지 대기
    • 마이크로태스크 큐의 작업도 함께 처리
  • advanceTimersByTimeAsync():

    • advanceTimersByTime의 비동기 버전
    • 시간 진행 중에 발생하는 Promise도 함께 처리
      1
      2
      3
      4
      5
      6
      7
      8
      9
      
      // 예: 비동기 타이머 처리
      async function asyncTimer() {
      await new Promise(resolve => setTimeout(resolve, 1000));
      return 'done';
      }
      
      const promise = asyncTimer();
      await jest.advanceTimersByTimeAsync(1000);
      const result = await promise; // 'done'
      

FakeTimers를 이용한 테스트

이제 앞서 보았던 테스트 코드를 FakeTimers 적용해보겠습니다.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
describe('Scheduler', () => {
  let tasks: ScheduledTask[];
  let scheduler: Scheduler;

  beforeEach(() => {
    tasks = [];
    scheduler = new Scheduler(tasks);
    jest.useFakeTimers();
  });

  afterEach(() => {
    jest.useRealTimers();
    jest.clearAllMocks();
  });

  describe('start', () => {
    it('지정된 간격으로 작업이 실행되어야 한다', async () => {
      const mockTask = createMockTask('test-task', 1000);
      const tasks = [mockTask];
      const scheduler = new Scheduler(tasks);

      scheduler.start(mockTask.name);

      await jest.advanceTimersByTimeAsync(3000);

      expect(mockTask.execute).toHaveBeenCalledTimes(3);
    });
  });

  describe('동시 실행 방지', () => {
    it('동일한 작업이 동시에 실행되지 않아야 한다', async () => {
      const mockTask = createMockTask('test-task', 1000);
      const tasks = [mockTask];
      const scheduler = new Scheduler(tasks);

      // 2초가 걸리는 작업
      mockTask.execute.mockImplementation(() =>
        jest.advanceTimersByTimeAsync(2000),
      );

      scheduler.start(mockTask.name);

      await jest.advanceTimersByTimeAsync(1500);
      expect(mockTask.execute).toHaveBeenCalledTimes(1);

      await jest.advanceTimersByTimeAsync(1500);
      expect(mockTask.execute).toHaveBeenCalledTimes(2);
    });
  });
});

뭔가 더 깔끔해지지 않았나요?! FakeTimers를 적용하여 개선된 부분을 살펴보면 아래와 같습니다.


실행 속도

FakeTimers 사용 전 FakeTimers 사용 후

위 테스트 결과는 작성된 모든 테스트 코드를 통해 실행 속도를 측정한 결과인데요, 보시는 것 같이 FakeTimers을 적용하지 않았을 때 21초 이상, FakeTimers를 적용한 후에 25ms 소요되었습니다.

앞서 언급했던 것 처럼 FakeTimers를 활용하기 때문에 실제 시간을 기다리지 않고 즉시 실행되어 실제 타이머의 동작을 기다리는 이전 방식과는 많은 차이가 발생할 수 밖에 없습니다.


정확성

FakeTimers를 사용했을 때 정확성 측면에서 아래와 같은 장점들을 취할 수 있습니다.

  • 시스템 부하와 관계없이 일관된 결과를 보장
  • 실제 타이머는 시스템 상태에 따라 지연될 수 있지만, FakeTimers는 정확한 시점에 실행

FakeTimers 사용 전

FakeTimers 사용 전에는 마지막 테스트인 동일한 작업이 동시에 실행되지 않아야 한다가 실패하는 것을 확인하실 수 있는데요

실패한 테스트를 독립적으로 실행한 결과

하지만 이 테스트는 독립적으로 실행되었을 때는 통과되는 문제(?)가 있었습니다. (실패하는 경우도 있었어요😂)

타이머를 실행했을 때 마다 결과가 달라지는 원인으로 가장 흔한 경우는 실행이 지연되는 경우이죠

이처럼 FakeTimers를 활용하면 일관된 결과를 보장할 수 있습니다.


제어 용이성

또 다른 장점으로 시간의 흐름을 정확하게 제어할 수 있습니다.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
it('동일한 작업이 동시에 실행되지 않아야 한다(ㅈwithout FakeTimers', (done) => {
  let executionCount = 0;
  const mockTask = createMockTask('test-task', 1000);
  mockTask.execute.mockImplementation(async () => {
    executionCount++;
    await new Promise((resolve) => setTimeout(resolve, 2000));
  });

  const tasks = [mockTask];
  const scheduler = new Scheduler(tasks);

  scheduler.start(mockTask.name);

  setTimeout(() => {
    expect(executionCount).toEqual(1);
    setTimeout(() => {
      expect(executionCount).toEqual(2);
      scheduler.stop(mockTask.name);
      done();
    }, 1500);
  }, 1500);
});

it('동일한 작업이 동시에 실행되지 않아야 한다', async () => {
  const mockTask = createMockTask('test-task', 1000);
  const tasks = [mockTask];
  const scheduler = new Scheduler(tasks);

  // 2초가 걸리는 작업
  mockTask.execute.mockImplementation(() =>
    jest.advanceTimersByTimeAsync(2000),
  );

  scheduler.start(mockTask.name);

  await jest.advanceTimersByTimeAsync(1500);
  expect(mockTask.execute).toHaveBeenCalledTimes(1);

  await jest.advanceTimersByTimeAsync(1500);
  expect(mockTask.execute).toHaveBeenCalledTimes(2);
});

advanceTimersByTime 혹은 advanceTimersByTimeAsync를 통해 원하는 시점으로 정확하게 이동할 수 있습니다.

위처럼 비교적 복잡한 시나리오(예: 타이머 중첩, 긴 실행 시간)도 쉽게 테스트할 수 있습니다. 가독성도 높아집니다!

주의사항

제가 FakeTimers를 사용하면서 겪었던 문제들을 통해 주의해야하는 내용들도 공유드려볼까합니다.


비동기 처리

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
it('비동기 작업 테스트 시 주의사항', async () => {
  const mockTask = createMockTask('test-task', 1000);
  const tasks = [mockTask];
  const scheduler = new Scheduler(tasks);

  mockTask.execute.mockImplementation(async () => await someAsyncOperation());

  scheduler.start(asyncTask.name);

  // 잘못된 방법
  jest.advanceTimersByTime(1000);
  await Promise.resolve(); // 마이크로태스크 큐 처리
  expect(asyncTask.execute).toHaveBeenCalled(); // 실패할 수 있음

  // 올바른 방법
  await jest.advanceTimersByTimeAsync(1000);
  expect(asyncTask.execute).toHaveBeenCalled();
});

advanceTimersByTime는 원하는 시점으로 시간을 정확히 이동시키기는 하지만, async 처럼 Promise를 반환하는 함수를 사용할 때 주의가 필요합니다.

이 부분은 자바스크립트의 이벤트 루프와 Promise의 동작 방식과 관련이 있는데요, 이벤트 루프는 매크로태스크 큐, 마이크로태스크 큐 순으로 작업들을 처리하기 때문입니다.

jest.advanceTimersByTime()은 매크로태스크 큐에 있는 타이머 콜백들을 실행시키지만, Promise로 만들어진 마이크로태스크들은 실행시키지 않습니다.

위 예시에서 someAsyncOperation()는 Promise를 반환하는 비동기 함수로, 이 Promise의 처리(then/catch 등)는 마이크로태스크 큐에 들어가게 됩니다.

실행 순서를 자세히 보면:

  1. jest.advanceTimersByTime(1000)
    • setInterval의 콜백을 실행 (매크로태스크)
    • executeTask 함수가 호출됨
    • someAsyncOperation()가 Promise를 반환
  2. 이 시점에서는:
    • someAsyncOperation()의 결과로 생성된 Promise의 처리가 마이크로태스크 큐에 있음
  3. await Promise.resolve()
    • 마이크로태스크 큐를 비움
    • Promise의 처리가 실행됨
    • finally 블록이 실행됨

따라서 await Promise.resolve()가 없다면 Promise 처리가 완료되지 않은 상태에서 다음 테스트 코드가 실행되는 등 결과적으로 테스트가 실패하거나 예상치 못한 동작을 할 수 있습니다.

때문에 비동기 로직의 경우 advanceTimersByTimeAsync를 사용하는 것을 추천드리겠습니다.

클린업

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
describe('Timer 테스트', () => {
  // 잘못된 방법
  it('첫 번째 테스트', () => {
    jest.useFakeTimers();
    // ... 테스트 코드
  });

  it('두 번째 테스트', () => {
    // 이전 테스트의 타이머 상태가 영향을 미칠 수 있음
    // ... 테스트 코드
  });

  // 올바른 방법
  beforeEach(() => {
    jest.useFakeTimers();
  });

  afterEach(() => {
    jest.useRealTimers();
    jest.clearAllMocks();
  });
});

각 테스트가 끝난 후 jest.useRealTimers()를 호출하여 타이머를 원래 상태로 복원해야 합니다.


타이머 상태 누수 문제

타이머 상태가 테스트 간에 누수되면 예상치 못한 동작이 발생할 수 있습니다.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
describe('타이머 누수 예시', () => {
  // 잘못된 방식
  it('첫 번째 테스트', () => {
    jest.useFakeTimers();
    const scheduler = new Scheduler();
    const task = createMockTask('task', 1000);

    scheduler.start(task.name);
    jest.advanceTimersByTime(1000);

    // 타이머가 정리되지 않은 상태로 테스트 종료
  });

  it('두 번째 테스트', () => {
    jest.useFakeTimers();
    // 이전 테스트의 타이머가 여전히 활성화 상태일 수 있음
    // 예상치 못한 동작 발생 가능
  });
});

// 올바른 방식
describe('타이머 정리 예시', () => {
  beforeEach(() => {
    jest.useFakeTimers();
  });

  afterEach(() => {
    jest.useRealTimers();
    jest.clearAllTimers();
    jest.clearAllMocks();
  });

  it('안전한 테스트', () => {
    const scheduler = new Scheduler();
    const task = createMockTask('task', 1000);

    scheduler.start(task.name);
    jest.advanceTimersByTime(1000);
  });
});

비동기 작업과 타이머 정리

비동기 작업이 완료되기 전에 타이머가 정리되면 테스트가 실패할 수 있습니다.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
describe('비동기 작업과 타이머', () => {
  // 문제가 될 수 있는 상황
  it('비동기 작업 중 타이머 정리', async () => {
    jest.useFakeTimers();
    const scheduler = new Scheduler();
    const task = {
      name: 'async-task',
      interval: 1000,
      execute: jest.fn().mockImplementation(async () => {
        await someAsyncOperation();
        // 이 시점에서 타이머가 이미 정리되었다면?
        await somethingElse();
      })
    };

    scheduler.start(task.name);
    jest.advanceTimersByTime(1000);
    jest.useRealTimers(); // 너무 일찍 타이머 정리
  });

  // 올바른 방식
  it('안전한 비동기 작업 테스트', async () => {
    jest.useFakeTimers();
    const scheduler = new Scheduler();
    const task = {
      name: 'async-task',
      interval: 1000,
      execute: jest.fn().mockImplementation(async () => {
        await someAsyncOperation();
        await somethingElse();
      })
    };

    scheduler.start(task.name);
    await jest.advanceTimersByTimeAsync(1000);
    jest.useRealTimers();
  });
});

실제 시간과의 차이

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
it('실제 시간 동작과 차이가 있을 수 있음', () => {
  const longRunningOperation = () => {
    // 실제 환경: CPU 부하에 따라 지연될 수 있음
    // FakeTimers: 정확히 1초 후 실행
    setTimeout(() => heavyComputation(), 1000);
  };

  longRunningOperation();
  jest.advanceTimersByTime(1000);
  // 실제 환경과 다른 결과가 나올 수 있음
});

FakeTimers는 가상의 시간을 사용하므로, 실제 환경과 약간의 차이가 있을 수 있습니다.

CPU 부하, 시스템 상태 등에 따른 지연을 시뮬레이션할 수 없기 때문에 중요한 시간 관련 로직은 실제 환경에서도 검증이 필요할 수 있습니다.


CPU 부하에 따른 타이밍 차이

실제 환경에서는 CPU 부하에 따라 타이머 실행이 지연될 수 있지만, FakeTimers는 이를 시뮬레이션하지 않습니다.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
describe('CPU 부하와 타이밍', () => {
  it('FakeTimers vs 실제 환경의 차이', async () => {
    const task = createMockTask('task', 1000);
    const tasks = [task];
    const scheduler = new Scheduler(tasks);

    mockTask.execute.mockImplementation(async () => {
      for (let i = 0; i < 1000000000; i++) {
        Math.random();
      }
    });

    jest.useFakeTimers();

    scheduler.start(heavyTask.name);
    await jest.advanceTimersByTimeAsync(1000);

    setTimeout(() => {}, 1000);
  });
});

FakeTimers는 정확히 1초 후 실행되겠지만 실제 환경에서는 CPU 부하로 인해 1초보다 더 늦게 실행될 수 있습니다.


이벤트 루프 블로킹

실제 환경에서는 이벤트 루프 블로킹이 타이머 실행에 영향을 미칠 수 있습니다.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
describe('이벤트 루프 블로킹', () => {
  it('블로킹 상황의 차이', async () => {
    const task = createMockTask('task', 1000);
    const tasks = [task];
    const scheduler = new Scheduler(tasks);

    // FakeTimers 환경
    jest.useFakeTimers();
    scheduler.start(task.name);

    // 이벤트 루프를 블로킹하는 동기 작업
    const blockingOperation = () => {
      const start = Date.now();
      while (Date.now() - start < 500) {
      } // 500ms 동안 블로킹
    };

    blockingOperation();
    await jest.advanceTimersByTimeAsync(100);
    expect(task.execute).toHaveBeenCalledTimes(1); // 성공
  });
});

위와 같은 코드는 테스트 환경에서 성공하지만, 실제 환경에서는 실패하는 테스트 입니다.

  • 블로킹 동안 타이머가 지연됨
  • 블로킹이 끝난 후 밀린 타이머가 실행됨
  • 정확한 간격으로 실행되지 않을 수 있음

해결 방안

중요한 시간 관련 로직은 실제 환경에서도 테스트

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
describe('실제 환경 테스트', () => {
  it('중요한 타이밍 테스트', (done) => {
    const task = createMockTask('task', 1000);
    const tasks = [task];
    const scheduler = new Scheduler(tasks);

    // 실제 타이머로 테스트
    scheduler.start(task.name);

    setTimeout(() => {
      try {
        expect(task.execute).toHaveBeenCalled();
        scheduler.stop(task.name);
        done();
      } catch (error) {
        done(error);
      }
    }, 1500); // 여유 있는 타임아웃 설정
  });
});

테스트에 적절한 여유 시간 추가

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
describe('여유 시간이 있는 테스트', () => {
  it('안정적인 타이밍 테스트', (done) => {
    const task = createMockTask('task', 1000);
    const tasks = [task];
    const scheduler = new Scheduler(tasks);

    scheduler.start(task.name);

    // 1초 간격으로 3번 실행 체크 시
    setTimeout(() => {
      try {
        expect(task.execute).toHaveBeenCalledTimes(3);
        scheduler.stop(task.name);
        done();
      } catch (error) {
        done(error);
      }
    }, 3500); // 3초가 아닌 3.5초로 여유 시간 추가
  });
});

마무리

Jest의 FakeTimers를 활용하면 시간 관련 코드를 안정적이고 빠르게 테스트할 수 있습니다. 특히 스케줄러와 같이 시간에 의존적인 로직을 테스트할 때 매우 유용한 도구입니다.

실제 시간에 의존하지 않고 시간을 제어할 수 있다는 점은 테스트의 신뢰성과 효율성을 크게 향상시킵니다. 다만, 비동기 처리와 클린업에 주의를 기울여야 하며, 필요한 경우 실제 환경에서의 검증도 병행하는 것이 좋겠습니다.

끝까지 읽어주셔서 감사합니다 😊