🕰️

年/月/日が変わると落ちるテストに出会った時に考えること

2025/01/16に公開

この記事の例では JavaScript と Jest を使いましたが、他の言語やテストライブラリでも類似の機能は提供されていると思います。考え方だけ参考にしてもらえたら嬉しいです。


年/月/日が変わると特定のテストが落ちてCIがこけてしまうことがあります。特に年が変わった時に発生することが多いでしょうか。

年/月/日が変わると落ちるテストの例

ホテルの予約サービスを例にしてみます。 BookService は予約を管理するサービスで、 .create は新しい予約を作成するメソッドです。

test("日付を指定すると予約できる", () => {
  const bookService = new BookService();

  // 何日か先の日付を指定する
  const result = bookService.create(new Date("2025-03-08"));
  expect(result).toStrictEqual({ type: 'success' });
});

また、たとえば、「1年以上先の日付は予約できない」という仕様があるときは次のようなテストを書いているかもしれません。

describe("予約が1年以上先の日付のとき", () => {
  test("予約は失敗する", () => {
    const bookService = new BookService();

    // 1年以上先の日付を指定する
    const result = bookService.create(new Date("2127-04-12"));
    expect(result).toStrictEqual({ type: "error", reason: "1年以上先の日付は指定できません。" });
  });
});

実装してすぐはテストが通ります。しかし、日付に依存するテストはある日急に落ちるようになることがあります。
この例では、もし「過去の日付には予約できない」という仕様があるなら、一つ目のテストが2025年3月9日に急に落ちるようになるはずです。

- { "type": "success" }
+ { "type": "error", "reason": "過去の日付は指定できません。" }

また、二つ目のテストは2126年4月13日になると予約ができるようになってしまうので、落ちるようになります。

- { "type": "error", "reason": "1年以上先の日付は指定できません。" }
+ { "type": "success" }

テストが落ちる理由

このようなテストが落ちる理由は、テストを実行するその時刻に依存しているからです。テストを作ったあるタイミングでは通っていても、実行する時刻が変われば通らなくなってしまいます。
実行する時刻が変わってもテストが落ちないようにする方法を、いくつか挙げてみます。

対処法1. システムの時刻をモックする

ひとつは、システムの時刻をモックして指定した時刻に実行しているとみなす方法です。
JavaScript で Jest を利用する場合は useFakeTimers を利用して new Date() をモックし、特定の日付を返すように設定します。テスト内での現在時刻は常に一定なので、いつ実行しても同じ結果になります。

+ beforeAll(() => {
+   jest.useFakeTimers().setSystemTime(new Date('2025-01-16')); // new Date() をモックして 2025/01/16 09:00 (JTC) で固定する
+ });
+
+ afterAll(() => {
+   jest.useRealTimers(); // テストが終わった後は new Date() のモックを解除する
+ });

test("日付を指定すると予約できる", () => {
  const bookService = new BookService();

  const result = bookService.create(new Date("2025-03-08"));
  expect(result).toStrictEqual({ type: 'success' });
});

対処法2. テスト対象に実行する時刻を渡せるようにする

テスト対象自体に実行する時刻を指定する機能をつけるという手もあります。
たとえば、インスタンスを生成するときに日付を指定して、その時刻で実行したとみなすようにしてみると、次のようにテストできます。テスト内で実行する日付を 2025/01/16 で固定しているので、こちらも常に同じ結果になるはずです。

test("日付を指定すると予約できる", () => {
+ const bookService = new BookService(new Date("2025-01-16")); // 実行する日付を 2025/01/16 で固定する

  const result = bookService.create(new Date("2025/03/08"));
  expect(result).toStrictEqual({ type: 'success' });
});

このモジュールをアプリケーションの中で使う場合には、次のように現在時刻を明示的に渡すか、何も渡さない時に現在時刻とみなすように実装しておく必要があります。

function someModule() {
  const bookService = new BookService(new Date()); // 現在時刻を渡す
}

// もしくは

function someModule() {
  const bookService = new BookService(); // なにも渡していないことをもって現在時刻で実行するように実装しておく
}

対処法3. 指定する日付を時刻を動的に変更する

実行する日付を指定したりモックする代わりに、比較する対象の日付を動的に変更する方法もあります。次の例では、常に実行する時刻の1年後の日付に対してテストを実行するようにします。dayAfterOneYear の日付は、 2025年1月10日にテストを実行すると 2026-01-10 になり、 2027年12月30日にテストを実行すると 2028-12-30 になります。

describe("予約が1年以上先の日付のとき", () => {
  test("予約は失敗する", () => {
    const bookService = new BookService();

    // 今日から1年先の日付を作る
+   const dayAfterOneYear = new Date(); // 今日の日付
+   dayAfterOneYear.setYear(dayAfterOneYear.getFullYear() + 1); // その1年後の日付に設定

    const result = bookService.create(dayAfterOneYear);
    expect(result).toStrictEqual({ type: "error", reason: "1年以上先の日付は指定できません。" });
  });
});

実行する時刻自体をモックしたり差し替えたりする他の例とは違う角度からのアプローチですが、わりとよく見る対応方法かなと思います。

おすすめの対処法は「対処法1. システムの時刻をモックする」

「対処法2. テスト対象に実行する時刻を渡せるようにする」は、テストのための過剰な機能を実装している

実行する時刻が指定できるのは一見便利そうですが、テスト以外の環境、とりわけ本番環境で時刻の指定をしないのであれば機能の過剰な実装といえるでしょう。例に出したホテルの予約サービスで常に「現在時刻で予約」するのであれば、過剰な実装です。
本番環境でインスタンスを生成するたびに日付を指定させると、指定する時刻を間違って想定外の挙動をする可能性があります。誤った使い方を招く可能性があるので、一般的にテストのために過剰な機能を作ることは避けた方がいいかなと思います。

また、このように外から時刻を指定できるようにしたモジュールを利用する親モジュールも、親の親も、テストのために時刻を指定できるようにしないといけなくなります。連鎖的にテストしづらくなってしまうので、避けるべきでしょう。

function someModule() {
  const bookService = new BookService(new Date()); // インスタンスが実行時の時刻で生成されているので、テストできない
  ...
}

function someModuleWithSpecificDate(executeAt: Date) { // 引数で渡された日付でインスタンスを生成するのでテスト可能
  const bookService = new BookService(executeAt);
  ...
}

「対処法3. 指定する日付を時刻を動的に変更する」は、具体的な結果がわからない

対処法3は、「どういう状態で実行すると、どんな結果になるか」がよくわからないまま終わってしまいます。
対処法3の例で出てくる dayAfterOneYear の値は、ロジックを辿ることでしかわからないです。一年後だから簡単だろうと思うかもしれないですが、例えば2024年2月29日に実行したとき dayAfterOneYear はどうなるでしょうか。(JavaScript の Date の場合は2025年3月1日を指します)

テストはテスト対象が想定通りの挙動をしていることを確かにする役割があります。内部で利用している値の実態が不明瞭なまま実行すると、テスト自体が想定通りに動いているか確証を持てずにテスト自体の信頼性を損ねてしまいます。テストを作った本人は作った時点で確信を持っていたとしても、時間が経って忘れてしまうことがあります。別の変更で想定していない挙動に変化してしまう可能性もあり、そのときテストが落ちれば気づけますが、想定しないままテストがパスしてしまい気づけないまま想定してない挙動になることも少なくないです。

先ほどの閏年の例で、2月29日の1年後の日付を次の年の2月28日として実装していた場合、2月29日にだけテストが落ちる可能性があります。仕様の良し悪しは別として、テストと実態が乖離してしまっているのでテストを直す手間が増えます。しかし、今日の日付に依存しているので、かなり歪な直し方をしないといけないかもしれません。

describe("予約が1年以上先の日付のとき", () => {
  test("予約は失敗する", () => {
    const bookService = new BookService();

    // 今日から1年先の日付を作る
    const dayAfterOneYear = new Date(); // 今日の日付
+   if (dayAfterOneYear.getMonth() === 1 && dayAfterOneYear.getDate() === 29) { // もし対象の日付が閏年の2月29日なら
+     dayAfterOneYear.setYear(dayAfterOneYear.getFullYear() + 1); // その一年後の3月1日になる
+     dayAfterOneYear.setDate(0); // 前の月(2月)の最終日にする
    } else {
      dayAfterOneYear.setYear(dayAfterOneYear.getFullYear() + 1);
    }

    const result = bookService.create(dayAfterOneYear);
    expect(result).toStrictEqual({ type: "error", reason: "1年以上先の日付は指定できません。" });
  });
});

そもそもテストを実行する日によって実行される値が変わるので、閏年に限らずいつ急に落ちてもおかしくないテストで、避けるべきです。

対処法1の時刻をモックをする方法なら、2月29日のテストを簡単に用意できます。こちらは2月29日のテストをしていることが明確で、なおかつ実行する日が2月29日でなくてもよいです。
また、特別に注目しておいた方が良さそうな日付についてもテストを作っておくことでよりテスト対象の挙動がわかりやすくなるでしょう。それぞれのブロックの中で実装したテストの現在時刻は beforeAll で指定した時刻になります。

describe("閏年の2月29日の場合", () => {
  beforeAll(() => jest.useFakeTimers().setSystemTime(new Date('2024-02-29')));

  afterAll(() => jest.useRealTimers());

  ...
});

describe("12月31日の場合", () => {
  beforeAll(() => jest.useFakeTimers().setSystemTime(new Date('2025-12-31')));

  afterAll(() => jest.useRealTimers());

  ...
})

閏年のような例でなくても、具体的な日付を指定した方がわかりやすくなる例はたくさんあります。たとえば「クーポンは発行してから50日有効」という仕様に対して、対処法3では次のようなテストを書くことになるでしょう。

test("クーポンの日付が50日以降なら有効、それ以前は無効", () => {
  const dateBefore50 = new Date();
  dateBefore50.setDate(dateBefore50.getDate() - 50); // 50日前にする
  const coupon50DaysAgo = new Coupon(dateBefore50);

  const dateBefore51 = new Date();
  dateBefore51.setDate(dateBefore51.getDate() - 51); // 51日前にする
  const coupon51DaysAgo = new Coupon(dateBefore51);

  const couponChecker = new CouponChecker();
  expect(couponChecker.isValid(coupon50DaysAgo)).toBe(true);
  expect(couponChecker.isValid(coupon51DaysAgo)).toBe(false);
});

これでも50日前、51日前のクーポンの挙動はわかると思います。ただ、おそらく日付の比較のロジックでも、new Date().setDate() を使ったロジックを使っているのだと想像できます。テスト対象の実装にもそのテストにも同じ仕組みを使っているなら、このテストが通っているのも机上論でしかないことを否定しきれません。具体的な数値を使ったテストなら、そういった暗黙的に信じている前提に頼らずに書けるのでより堅牢です。

describe("クーポンの日付が50日以降なら有効、それ以前は無効", () => {
  describe("2025年2月2日の場合", () => {
    beforeAll(() => jest.useFakeTimers().setSystemTime(new Date('2024-02-02')));

    afterAll(() => jest.useRealTimers());

    test("2024年12月15日以降なら有効、2024年12月14日以前なら無効", () => {
      const couponChecker = new CouponChecker();

      const coupon50DaysAgo = new Coupon(new Date("2024-12-15"));
      const coupon51DaysAgo = new Coupon(new Date("2024-12-14"));

      expect(couponChecker.isValid(coupon50DaysAgo)).toBe(true);
      expect(couponChecker.isValid(coupon51DaysAgo)).toBe(false);
    });
  });
});

「2025年2月2日の50日前がいつなのかぱっとわからない」と思うかもしれないですが、そういう計算をするツール(たとえば https://keisan.casio.jp のようなサイト)もありますし、最悪数えることで誰でもわかります。逆に具体的な値を使わないと、「クーポンは発行してから50"年"有効」という実装にしてしまっていても、テストの実装も間違ったせいで気づけなかった、なんてことも発生する可能性が大いにあります。

テストは、テスト対象の動作を誰が見ても把握できるように設計されているべきです。そのためには、なるべくロジックを排除して具体的な値を指定できるとよいですね。


別な事情がない限りは、テスト対象が実行する時刻に依存する場合は、時刻をモックするのがいいかなと思っています。

Discussion