ユニットテストに時限爆弾を作らないためのベストプラクティス

要約

時限爆弾的なテストとは、テスト内で扱う日時(レコードのregistered_atなどの属性値やスタブの値)にハードコードされた日時文字列( '2024-07-15' など)を使うことで、時間経過により失敗するようになるテストのこと。

基本原則:

  • '2024-07-15' のようなハードコードされた日時文字列を書かない(エッジケース除く)
  • ✅ 通常のテスト: Time.current1.week.ago など テスト実行時を基準とした相対日時 を使う
  • ✅ 日時依存ロジック: travel_toテスト実行時の「今日」を特定の日付に固定 し、テストデータの日時もTime.zone.parse具体的な日時を指定 する
  • ✅ エッジケース(閏年・年跨ぎ・月末): travel_toを使い、境界条件を示す具体的な日時 を指定する

よくある例:

# ❌ Bad - 時間が経つと壊れる
let(:user) { create(:user, registered_at: '2024-07-15') }

# ✅ Good - 相対日時
let(:user) { create(:user, registered_at: 6.months.ago) }

# ✅ Good - travel_toで時刻固定(contextで仕様を明示)
context '登録から1年以内の場合' do
  it '2025年7月15日時点で、6ヶ月前(2025年1月15日)に登録したユーザーはキャンペーン対象' do
    travel_to Time.zone.parse('2025-07-15 10:00:00') do
      user = create(:user, registered_at: Time.zone.parse('2025-01-15 10:00:00'))
      expect(user.show_campaign?).to be true
    end
  end
end

重要な注意点:

  • 1.year365.days は閏年で結果が異なる → ビジネス要件で使い分ける
  • Time.zone.parseTime.current を使う(Time.parseTime.now はアプリケーションのタイムゾーン設定を無視してシステムのタイムゾーンで解釈されるため避ける)
  • contextで仕様を明示、テスト名に経過期間を含めると分かりやすい

はじめに

みなさん、こんにちは! IAM チームでエンジニアをしている 新岡 です。
先日、ユニットテストにおける「時限爆弾」を見かけたので、知見を共有するためにこの記事を書いています。
誰でも書きうる、よくある落とし穴なので「何を今更」と思わず、この記事でおさらいしましょう。


開発していて、こんな経験ありませんか?

「自分のコード何も変更してないのに、なぜかテストが落ちてる...?」
「mainブランチでも同じテストが落ちてる...なんで?」

これは「時限爆弾」と呼ばれる、時間経過によって失敗するようになるテストの典型的な症状です。
この記事では次の参考例を元に、時限爆弾が生まれる原理と時限爆弾とサヨナラするテストの書き方を見ていきましょう。

何が起きたか

あるサービスのテストで、以下のようなテストが突然失敗しました。

  • 「ユーザー登録から1年以内の場合、特定の機能が有効になる」
  • 「サブスクリプション解約から1年以内の場合、再登録キャンペーンが表示される」

なぜ失敗したか

テストを書いた時点(2025年1月)では、次のコードで問題なく動いていました:

describe 'キャンペーン表示' do
  let(:user_data) do
    {
      name: 'Test User',
      email: 'test@example.com',
      registered_at: '2024-04-15T10:00:00Z',  # 2025年1月時点で約9ヶ月前
    }
  end

  let(:user) { create(:user, user_data) }

  it 'ユーザー登録から1年以内の場合、特定の機能が有効になる' do
    # 2025年1月時点: 2024年4月15日は約9ヶ月前 → 1年以内なのでテスト成功 ✅
    expect(user.special_feature_enabled?).to be true
  end
end

2025年1月時点(テスト作成時):

  • 今日: 2025年1月
  • 登録日: 2024年4月15日(約9ヶ月前)
  • 判定: 1年以内 → テスト成功 ✅

でも、約4ヶ月後の2025年5月になると...

2025年5月時点:

  • 今日: 2025年5月
  • 登録日: 2024年4月15日(1年1ヶ月前)
  • 判定: 1年を超えている → テスト失敗 ❌

この日付は「1年以上前」になってしまい、「1年以内」という条件が成立しなくなりました。コードは何も変更していないのに、時間経過だけでテストが失敗してしまいます。これで時限爆弾の完成です 💣

影響の特徴

  • コードの変更なしにテストが失敗する
  • developやmainブランチでも同様に失敗する
  • 時間が経過するたびに、より多くのテストが失敗し始める

こういう問題に遭遇したら - 調査のステップ

「テストが落ちてるけど、自分の変更によるものではなさそう...」と思ったら、まずは以下の手順で確認してみてください。
時限爆弾の原因を見つけたら、積極的に直していきましょう。

1. mainブランチでも落ちるか確認

git checkout main
bundle exec rspec spec/path/to/spec.rb:LINE_NUMBER

mainでも落ちていたら、今回の変更とは無関係な既存の問題です。

2. 問題箇所を探す

# テストの履歴を確認
git log --oneline spec/path/to/spec.rb | head -10

# 該当行がいつ書かれたか確認
git blame -L START_LINE,END_LINE spec/path/to/spec.rb

3. ハードコードされた日時を探す

# '2024-' など、年がハードコードされている箇所を探す
grep -n "202[0-9]-" spec/path/to/spec.rb

よくあるパターンは以下の通りです。

  • '2024-07-15T10:00:00Z' のようなハードコードされた日時文字列
  • Time.parse('2024-07-15') のような特定日時への変換
  • テスト作成時の Time.now の値をそのまま使っているケース

時限爆弾を作らないためのコツ

ここからは時限爆弾を作らない/直すためのテストの書き方を見ていきましょう。

💡 1. 相対的な日時を使おう(travel_toを使わない場合)

ハードコードされた日時文字列ではなく、テスト実行時を基準とした相対的な日時を使います。

# ✅ Good - いつ実行しても動く
let(:user_data) do
  {
    name: 'Test User',
    email: 'test@example.com',
    registered_at: Time.current.iso8601,
    # または
    registered_at: 1.week.ago.iso8601,
    registered_at: 6.months.ago.iso8601,
  }
end

# ❌ Bad - 時間が経つと壊れる
let(:user_data) do
  {
    name: 'Test User',
    email: 'test@example.com',
    registered_at: '2024-07-15T10:00:00Z',
  }
end

💡 2. travel_to で「今日」を固定し、具体的な日時でテストしよう

時刻に依存するロジックをテストする場合は、travel_to を使って テスト実行時の「今日」を特定の日付に固定 します。そして、テストデータの日時も Time.zone.parse具体的な日時を指定 します。
これにより、「◯月◯日時点で、△月△日のデータは...」という仕様を明確に表現できます。

# ✅ Good - travel_toで「今日」を固定し、テストデータも具体的な日時で指定
describe 'キャンペーン表示ロジック' do
  context '登録から1年以内の場合' do
    it '2025年7月15日時点で、6ヶ月前(2025年1月15日)に登録したユーザーはキャンペーン対象' do
      # 今日を2025年7月15日に固定
      travel_to Time.zone.parse('2025-07-15 10:00:00') do
        # 2025年1月15日に登録されたユーザー(6ヶ月前 = 1年以内)
        user = create(:user, registered_at: Time.zone.parse('2025-01-15 10:00:00'))

        expect(user.show_campaign?).to be true
      end
    end
  end

  context '登録から1年を超えた場合' do
    it '2025年7月15日時点で、13ヶ月前(2024年6月15日)に登録したユーザーはキャンペーン対象外' do
      # 今日を2025年7月15日に固定
      travel_to Time.zone.parse('2025-07-15 10:00:00') do
        # 2024年6月15日に登録されたユーザー(13ヶ月前 = 1年超過)
        user = create(:user, registered_at: Time.zone.parse('2024-06-15 10:00:00'))

        expect(user.show_campaign?).to be false
      end
    end
  end
end

travel_toの利点:

  • テスト実行日時に関わらず、常に同じ結果が得られる
  • 「今日が◯月◯日の時、△月△日のデータは...」という仕様を明確に表現できる
  • 日付計算のロジックを具体的な日付で検証できる
  • 未来や過去の特定の日時での挙動を検証できる
  • テストケース名で仕様を明確に文書化できる

注意点:

  • travel_toブロック内では、Time.currentDate.todayDateTime.nowなどが指定した日時を返す
  • ブロックを抜けると、時刻は元に戻る
  • travel_to内でテストデータの日時を設定する際は、Time.zone.parse('2025-01-15')のように具体的な日時を指定し、1.week.agoなどの相対日時は避ける(「今日」を固定しているので、相対日時を使う意味がない)

💡 3. 境界値は明示的に

「1年以内」のような条件をテストする場合、境界値を明確にします。

travel_toで具体的な日時を使う:

travel_toの利点(再現性、仕様の文書化など)は「💡 2. travel_to で「今日」を固定し、具体的な日時でテストしよう」で説明した通りです。加えて、具体的な日付を指定することで、閏日や月末などの特殊な境界を正確にテストできます。

# ✅ Good - travel_toで境界値をテスト
describe 'サブスクリプション再登録キャンペーン' do
  context '解約から1年以内の場合' do
    it '2025年6月2日時点で、1年前(2024年6月2日)に解約したサブスクリプションはキャンペーン対象' do
      travel_to Time.zone.parse('2025-06-02 12:00:00') do
        # 2024年6月2日に解約(ちょうど1年前 = 1年以内)
        subscription = create(:subscription,
          canceled_at: Time.zone.parse('2024-06-02 12:00:00')
        )

        expect(subscription.show_resubscribe_campaign?).to be true
      end
    end
  end

  context '解約から1年以上経過した場合' do
    it '2025年6月2日時点で、1年1日前(2024年6月1日)に解約したサブスクリプションはキャンペーン対象外' do
      travel_to Time.zone.parse('2025-06-02 12:00:00') do
        # 2024年6月1日に解約(1年1日前 = 1年超過)
        subscription = create(:subscription,
          canceled_at: Time.zone.parse('2024-06-01 12:00:00')
        )

        expect(subscription.show_resubscribe_campaign?).to be false
      end
    end
  end
end

相対日時を使う:

相対日時を使う利点:

  • 境界条件が直感的 - 1.year.ago + 1.dayで「ギリギリ1年以内」が一目で分かる
  • コードがシンプル - 日付の計算が不要

ただし、travel_toを使わないとテスト実行日によって結果が変わる可能性があります。

# ✅ Good - 相対日時で境界を表現
describe 'サブスクリプション再登録キャンペーン' do
  context '解約から1年以内の場合' do
    let(:subscription) { create(:subscription, canceled_at: (1.year.ago + 1.day)) }

    it 'キャンペーンバナーが表示される' do
      expect(subscription.show_resubscribe_campaign?).to be true
    end
  end

  context '解約から1年以上前の場合' do
    let(:subscription) { create(:subscription, canceled_at: (1.year.ago - 1.day)) }

    it 'キャンペーンバナーが表示されない' do
      expect(subscription.show_resubscribe_campaign?).to be false
    end
  end
end

💡 4. FactoryBot を活用しよう

FactoryBotのtraitを使って、異なる日時状態を管理します。

# ✅ Good - traitで状態を管理
FactoryBot.define do
  factory :subscription do
    user
    status { :active }
    started_at { Time.current }
    canceled_at { nil }

    trait :recently_canceled do
      status { :canceled }
      canceled_at { 6.months.ago }
    end

    trait :canceled_long_ago do
      status { :canceled }
      canceled_at { 13.months.ago }
    end
  end
end

# テストで使う
describe 'キャンペーン表示' do
  it '最近解約したユーザーにはキャンペーンが表示される' do
    subscription = create(:subscription, :recently_canceled)
    expect(subscription.show_resubscribe_campaign?).to be true
  end

  it '長期間前に解約したユーザーにはキャンペーンが表示されない' do
    subscription = create(:subscription, :canceled_long_ago)
    expect(subscription.show_resubscribe_campaign?).to be false
  end
end

💡 5. 応用: キャンペーン期間の境界値テスト

キャンペーン開始日・終了日を基準とした境界値テストでは、期間の計算を相対的にすることで可読性が高まる場合があります。このパターンは、キャンペーン期間のような「特定の絶対日時を基準」とした境界値テストで有効です。
基準となる日時(campaign_startcampaign_end)は絶対日時で定義し、その前後の境界を相対的に表現します。

describe '期間限定キャンペーン' do
  # 基準となる日時は絶対日時で定義
  let(:campaign_start) { Time.zone.parse('2025-07-01 00:00:00') }
  let(:campaign_end) { Time.zone.parse('2025-07-31 23:59:59') }

  context 'キャンペーン期間中' do
    it 'キャンペーン開始日にバナーが表示される' do
      travel_to campaign_start do
        expect(show_campaign_banner?).to be true
      end
    end

    it 'キャンペーン期間の中間日にバナーが表示される' do
      travel_to campaign_start + 15.days do
        expect(show_campaign_banner?).to be true
      end
    end
  end

  context 'キャンペーン期間外' do
    it 'キャンペーン開始前はバナーが表示されない' do
      travel_to campaign_start - 1.day do
        expect(show_campaign_banner?).to be false
      end
    end

    it 'キャンペーン終了後はバナーが表示されない' do
      travel_to campaign_end + 1.day do
        expect(show_campaign_banner?).to be false
      end
    end
  end
end

注意すべきエッジケース

時間計算のテストでは、以下のようなエッジケースに特に注意が必要です。
エッジケースのテストでは、travel_to と組み合わせて、閏年や月末などの特定の境界条件を示す具体的な日時を使用するのが以下の観点からおすすめです。

  • 境界条件を明示 - 「2024年2月29日(閏日)から1年後」という具体例で、特殊なケースの仕様を示す
  • テストの意図が明確 - 何をテストしているか一目で分かる
  • エッジケースの特性を文書化 - 「この特定の条件でこうなる」という知識を残す
  • コードがシンプル - 「次の閏年を探す」などの複雑なロジックが不要

🗓️ 1. 閏年の扱い

閏年(2月29日)を含む期間の計算では、予期しない結果になることがあります。

# 閏年のケース
describe '閏年を含む期間の計算' do
  context '2024年2月29日(閏日)から1年後' do
    it '2025年2月28日時点で、2024年2月29日登録のユーザーは1年経過している' do
      travel_to Time.zone.parse('2025-02-28 10:00:00') do
        user = create(:user, registered_at: Time.zone.parse('2024-02-29 10:00:00'))

        # 2024年2月29日 + 1.year = 2025年2月28日(閏日がないため前日になる)
        expect(user.registered_at + 1.year).to eq(Time.zone.parse('2025-02-28 10:00:00'))
      end
    end
  end

  context '閏年の前後での1年計算' do
    it '2024年3月1日から1年前は2023年3月1日' do
      travel_to Time.zone.parse('2024-03-01 10:00:00') do
        # 2024年は閏年だが、3月1日から1年前は通常通り3月1日
        one_year_ago = 1.year.ago
        expect(one_year_ago.to_date).to eq(Date.parse('2023-03-01'))
      end
    end
  end
end

ポイント:

  • 1.year は暦に基づいて計算されるため、閏年を考慮してくれる
  • 2024年2月29日 + 1年 = 2025年2月28日(2025年は平年なので2月29日が存在しない)
  • 365日で計算すると閏年の場合にズレが生じるため、1.year を使うのが安全

📅 2. 年を跨ぐケース

年を跨ぐ期間の計算では、年の境界で意図しない動作になることがあります。

describe '年を跨ぐ期間の計算' do
  context '12月から翌年1月への期間' do
    it '2025年1月15日時点で、2024年12月1日登録のユーザーは1ヶ月以上経過' do
      travel_to Time.zone.parse('2025-01-15 10:00:00') do
        # 2024年12月1日登録(45日前)
        user = create(:user, registered_at: Time.zone.parse('2024-12-01 10:00:00'))

        days_passed = (Time.current - user.registered_at) / 1.day
        expect(days_passed).to be >= 30
      end
    end
  end

  context '1年以内の判定で年を跨ぐ場合' do
    it '2025年2月1日時点で、2024年3月1日登録のユーザーは1年以内(11ヶ月)' do
      travel_to Time.zone.parse('2025-02-01 10:00:00') do
        user = create(:user, registered_at: Time.zone.parse('2024-03-01 10:00:00'))

        # 11ヶ月経過 = 1年以内
        months_passed = ((Time.current.year - user.registered_at.year) * 12) +
                       (Time.current.month - user.registered_at.month)
        expect(months_passed).to eq(11)
        expect(months_passed).to be < 12
      end
    end
  end
end

ポイント:

  • 年を跨ぐと単純な月の引き算だけでは正確な期間が計算できない
  • テストでは年を跨ぐ具体的な日付で検証することが重要

📆 3. 月末の扱い

月末日からの期間計算は、月によって日数が異なるため注意が必要です。

describe '月末日からの期間計算' do
  context '1月31日の1ヶ月後' do
    it '1月31日 + 1.month = 2月28日(平年)' do
      travel_to Time.zone.parse('2025-01-31 10:00:00') do
        one_month_later = 1.month.from_now
        # 2月は28日までしかないため、2月28日になる
        expect(one_month_later.to_date).to eq(Date.parse('2025-02-28'))
      end
    end

    it '1月31日 + 1.month = 2月29日(閏年)' do
      travel_to Time.zone.parse('2024-01-31 10:00:00') do
        one_month_later = 1.month.from_now
        # 閏年の場合は2月29日になる
        expect(one_month_later.to_date).to eq(Date.parse('2024-02-29'))
      end
    end
  end

  context '月末登録ユーザーの1ヶ月後キャンペーン' do
    it '2025年2月28日時点で、1月31日登録のユーザーはちょうど1ヶ月後' do
      travel_to Time.zone.parse('2025-02-28 10:00:00') do
        # 1月31日登録
        user = create(:user, registered_at: Time.zone.parse('2025-01-31 10:00:00'))

        # 1月31日 + 1.month = 2月28日
        expect(user.registered_at + 1.month).to eq(Time.zone.parse('2025-02-28 10:00:00'))
      end
    end
  end
end

ポイント:

  • 月末日 + 1ヶ月は、翌月にその日が存在しない場合、翌月の末日になる
  • 1月31日 + 1ヶ月 = 2月28日(平年)または2月29日(閏年)
  • 月の日数の違いを考慮したテストケースを用意する

🔢 4. 「1年」の定義に注意

「1年」の定義が365日なのか、暦上の1年なのかで結果が変わります。

describe '1年の定義' do
  context '365日 vs 1.year の違い' do
    it '閏年を含む場合、365日と1.yearで結果が異なる' do
      travel_to Time.zone.parse('2024-03-01 10:00:00') do
        base_date = Time.zone.parse('2023-03-01 10:00:00')

        # 365日後
        after_365_days = base_date + 365.days
        expect(after_365_days.to_date).to eq(Date.parse('2024-02-29'))  # 閏日

        # 1年後
        after_1_year = base_date + 1.year
        expect(after_1_year.to_date).to eq(Date.parse('2024-03-01'))    # 同じ日付
      end
    end
  end

  context '「登録から1年以内」の判定' do
    it '仕様が「365日以内」なのか「暦上の1年以内」なのかを明確にする' do
      travel_to Time.zone.parse('2024-03-01 10:00:00') do
        # 2023年3月1日登録(365日前)
        user_365_days = create(:user, registered_at: 365.days.ago)

        # 2023年3月1日登録(1年前)
        user_1_year = create(:user, registered_at: 1.year.ago)

        # 閏年を含む期間では、これらは異なる日付になる
        expect(user_365_days.registered_at.to_date).to eq(Date.parse('2024-02-29'))
        expect(user_1_year.registered_at.to_date).to eq(Date.parse('2023-03-01'))
      end
    end
  end
end

ポイント:

  • 365.days は常に365日(8760時間)
  • 1.year は暦上の1年(閏年なら366日、平年なら365日)
  • ビジネス要件で「1年」の定義を明確にし、適切な方を使う

🌍 5. タイムゾーンに注意

タイムゾーンの違いで、日付の境界が変わることがあります。

describe 'タイムゾーンの扱い' do
  context 'UTCとJSTの違い' do
    it 'Time.zone.parse を使ってアプリケーションのタイムゾーンで統一する' do
      # Railsのタイムゾーン設定がJST(Asia/Tokyo)の場合
      travel_to Time.zone.parse('2025-01-01 00:00:00') do
        # Time.zone.parse は設定されたタイムゾーンで解釈される
        jst_time = Time.zone.parse('2025-01-01 00:00:00')

        # Time.parse はシステムのタイムゾーンで解釈される可能性がある
        # 常に Time.zone.parse を使うこと!
        expect(jst_time.zone).to eq('JST')
      end
    end
  end

  context '日付境界のテスト' do
    it '2025年1月1日 0時0分(JST)は確実に新年' do
      travel_to Time.zone.parse('2025-01-01 00:00:00') do
        expect(Time.current.year).to eq(2025)
        expect(Time.current.month).to eq(1)
        expect(Time.current.day).to eq(1)
      end
    end
  end
end

ポイント:

  • 常に Time.zone.parse を使い、Time.parse は避ける
  • Time.current を使い、Time.now は避ける
  • テストでは明示的にタイムゾーンを意識した日時を使う

レビュー時のチェックポイント

コードレビューする時は以下の内容を満たしているか確認し、時限爆弾の種に気づいたらコメントで優しく指摘してあげましょう 🙂

基本的なチェック

  • [ ] '2024-07-15' みたいなハードコードされた日時文字列が使われてないか?
  • [ ] 通常のテスト(travel_toなし)では、Time.current1.week.ago みたいな相対日時が使われているか?
  • [ ] 日時依存のロジックで travel_to を使って「今日」を固定しているか?
  • [ ] travel_to ブロック内では、Time.zone.parse('2025-01-15')のように具体的な日時が指定されているか?
  • [ ] 「◯年以内」「◯ヶ月後」みたいな条件のテストで境界値が明確か?
  • [ ] contextで仕様を明示し、テストケース名で「いつの時点で、◯ヶ月前(いつ)のデータが、どうなる」という形式になっているか?

エッジケースのチェック

  • [ ] 閏年(2月29日)を含む期間の計算が必要なロジックで、そのテストケースがあるか?
  • [ ] 年を跨ぐケース(12月→1月)のテストがあるか?
  • [ ] 月末日からの計算(1月31日 + 1ヶ月など)のテストがあるか?
  • [ ] 365.days1.year の使い分けが正しいか?(ビジネス要件に応じて)
  • [ ] Time.zone.parseTime.current を使い、Time.parseTime.now を避けているか?
  • [ ] 境界値(ちょうど1年後、閏日の前後、月末日の前後など)のテストがあるか?

まとめ

時限爆弾的なテストは誰でもうっかり書く可能性があるものです。もしも発火した場合、その影響は思っているよりも大きく、修正が反映されるまで他の人の手を止めてしまうことになります。
しかし、これはちょっとした注意で防げるものです。そうならないように以下の点に気を配りながら快適なテスト環境を保っていきましょう! 🚀

時限爆弾を防ぐ、時間にまつわるテスト観点の振り返り

基本原則
  • ハードコードされた日時文字列を避ける - '2024-07-15' のような日付を直接書かない
  • 通常のテストは相対日時 - Time.current1.week.ago を使おう(travel_toなし)
  • 日時依存のロジックはtravel_toで「今日」を固定 - テスト実行時の「今日」を特定の日付に固定しよう
  • travel_to内では具体的な日時を指定 - Time.zone.parse('2025-01-15') でテストデータの日時を明確に
  • 境界値を明確に - 「◯年以内」の境界ギリギリのケースをテストしよう
  • テスト名で仕様を表現 - contextで仕様を明示し、「いつの時点で、◯ヶ月前(いつ)のデータが、どうなる」を明確に
  • FactoryBotのtraitを活用 - 異なる日時状態を管理しやすくしよう
エッジケースの扱い
  • 閏年、年跨ぎ、月末などはtravel_toを使って境界条件を明示 - 2月29日などの特殊なケースを具体的に示す
  • 「1年」の定義を明確に - 365.days1.year か、ビジネス要件に応じて選ぶ
  • タイムゾーンに注意 - Time.zone.parseTime.current を使う
レビューで気づく
  • ハードコードされた日時文字列を見つけたら優しく指摘しよう
  • エッジケースのテストが漏れていないかチェックしよう