💭

Jestの時間依存テストが突然失敗する問題の原因と安定させるための方法

に公開

私の所属しているプロジェクトでは、フロントエンドのコードでNext.jsを用いています。
そして、コミットするたび、jestで書かれたテストコードが実行されます。

ある日、storybookを追加しただけのはずのコミットで、なぜかテストが失敗し、戸惑いました。

起きた事象

どんなテストが失敗したのかを確認したところ、以下のような感じで、
引数に日時を受け取り、それが現在の日時からどれくらい前なのかを計算して返すようなメソッドのテストでした。

※テスト対象のメソッドはこんな感じです。

utils/timeAgo.ts
export const timeAgo = (datetime: Date): string => {
  const now = new Date();
  const diffMs = now.getTime() - datetime.getTime();
  const diffSeconds = Math.floor(diffMs / 1000);
  const diffMinutes = Math.floor(diffSeconds / 60);
  const diffHours = Math.floor(diffMinutes / 60);
  const diffDays = Math.floor(diffHours / 24);
  const diffWeeks = Math.floor(diffDays / 7);
  const diffMonths = Math.floor(diffDays / 30);
  const diffYears = Math.floor(diffDays / 365);

  if (diffSeconds < 60) return `${diffSeconds}秒前`;
  if (diffMinutes < 60) return `${diffMinutes}分前`;
  if (diffHours < 24) return `${diffHours}時間前`;
  if (diffDays < 7) return `${diffDays}日前`;
  if (diffWeeks < 4) return `${diffWeeks}週間前`;
  if (diffMonths < 12) return `${diffMonths}ヶ月前`;
  return `${diffYears}年前`;
};

そして、テストコードは以下のような感じです。
59秒前を返すはずのテストで1分前と返ってきました。

__tests__/timeAgo.unsafe.test.ts
import { timeAgo } from '../utils/timeAgo';

describe('timeAgo (不安定なテスト)', () => {
  it('59秒前を返す(失敗しやすい)', () => {
    const date = new Date(Date.now() - 59 * 1000);
    expect(timeAgo(date)).toBe('59秒前');
  });

  it('60秒前を返す(1分前と判定されるべき)', () => {
    const date = new Date(Date.now() - 60 * 1000);
    expect(timeAgo(date)).toBe('1分前');
  });

  it('60分前を返す(1時間前)', () => {
    const date = new Date(Date.now() - 60 * 60 * 1000);
    expect(timeAgo(date)).toBe('1時間前');
  });

  it('24時間前を返す(1日前)', () => {
    const date = new Date(Date.now() - 24 * 60 * 60 * 1000);
    expect(timeAgo(date)).toBe('1日前');
  });
});

なぜ失敗したのか

テストの実行前の時刻と、テスト対象のメソッドを呼び出した時の時刻に1秒のズレがあったからです。

Date.now() で59秒前の時刻を作っていても、expect(...) 実行時に60秒を過ぎていれば 1分前と判定され、意図とズレるためです。

頻繁に発生する事象ではないですが、身に覚えのないテストの失敗は開発体験としてストレスになると思います。

対処法

現在の時刻を固定することです。
jestには時間の経過をコントロールできるMockが存在します。

こちらに記載があるように、jest.useFakeTimers()を呼び出してその機能を有効にできます。

その上で、システム時刻を固定するjest.setSystemTime()を呼び出します。

テスト終了後は他のテストへの影響を防ぐため、jest.useRealTimers()を呼び出します。
コードにすると以下のようになります。

__tests__/timeAgo.stable.test.ts
import { timeAgo } from '../utils/timeAgo';

describe('timeAgo (安定したテスト)', () => {
  const fixedNow = new Date('2024-01-01T12:00:00Z');

  beforeAll(() => {
    jest.useFakeTimers(); // Jestのタイマーをモック化することで、setTimeoutやDateなどの時間に関する処理を制御可能にする
    jest.setSystemTime(fixedNow); // 現在時刻を固定し、テスト実行時の時間経過による期待結果とのズレを防ぐ
  });

  afterAll(() => {
    jest.useRealTimers(); // テスト後にタイマーのモックを元に戻し、通常の動作に戻す(他のテストへの影響を防ぐ)
  });

  it('59秒前を返す', () => {
    const date = new Date('2024-01-01T11:59:01Z');
    expect(timeAgo(date)).toBe('59秒前');
  });

  it('60秒前 → 1分前を返す', () => {
    const date = new Date('2024-01-01T11:59:00Z');
    expect(timeAgo(date)).toBe('1分前');
  });

  it('60分前 → 1時間前を返す', () => {
    const date = new Date('2024-01-01T11:00:00Z');
    expect(timeAgo(date)).toBe('1時間前');
  });

  it('24時間前 → 1日前を返す', () => {
    const date = new Date('2023-12-31T12:00:00Z');
    expect(timeAgo(date)).toBe('1日前');
  });
});

まとめ

レアケースではありますが、関係ないテストが失敗するのは心臓に悪いので、
すぐに対処すべきと考えます。
※私は即座にPullRequestを作成し、対処しました。

テストを作成するときの行動としては、以下のようにすべきと考えます。

  • 日時・時間を扱うテストでは、常にjest.useFakeTimers()+setSystemTime() の使用を検討する
  • CIでランダムに落ちる系のバグは時間依存である可能性を疑う

Discussion