Tech Racho エンジニアの「?」を「!」に。
  • Ruby / Rails関連

Rails: 時間を操作するテストを安定させる方法(翻訳)

概要

元サイトの許諾を得て翻訳・公開いたします。

日本語タイトルは内容に即したものにしました。

Rails: 時間を操作するテストを安定させる方法(翻訳)

ソフトウェアのテストでは、時間をどのようにフェイクするかがよく話題になります。現在時刻や現在の日付は多くのプログラムの振る舞いに影響しますし、実際のシステムクロックをそのまま読み取って使うと、後々テストの信頼性を落とすようなエッジケースとなってしまう可能性もあります(例: 大晦日の真夜中の直前にビルドを開始すると、現在が西暦何年であるかに関するアサーションが失敗する可能性があります)。

私は長年、この問題にさまざまな方法で取り組んできました。また、それ用の推奨ツールはどの技術スタックにもたくさん存在しています。Rubyistなら、timecop gemやActive SupporのTimeHelpersモジュールを利用することが多いでしょう。

しかしそれでも、ツールに頼る方法は、どんな場合にも対応できるほど堅牢にはできません。「OS」「言語ランタイム」「データベース」そして「あらゆるサードパーティのサービス」における現在時刻の扱われ方について合意が取れていなければ、アプリが思わぬ形で振る舞う可能性が残されるのです。

言い換えれば、プログラマーが時刻をチェックする方法があまりにも多いので、最も優秀な時間フェイクツールを手に入れて使うだけでは不十分なのです。あらゆる場面で時間を完璧にフェイクできるツールなど存在しません。嘘だと思うなら経験者に聞いてみてください。

走らせたテストの実行時間が無限大になったりマイナスになったりを繰り返すようになったとき、このテストの出来具合について私がどんな気持ちになったか、お察しください。

あるいは、データベースが謎の内部データ整合性エラーを吐き出すようになり、私がシステムクロックをいじったのが原因だったにもかかわらず、どこを調べてもその兆候を発見できなかったときの私の心境でも構いません。

あるいは、テスト実行中にOSが証明書キャッシュを更新しようとして、信頼できるSSL証明が1つ残らず期限切れのように見えてしまったために、自分のコンピュータ全体がオフラインになったときの心境でも構いません。

こういうことが起きるのはテストだけではありません!
私は自分の開発環境で現在時刻をフェイクしたくなったことが何度もあります。その理由は、私が直近で作成した最も複雑な4つのユーザーインターフェイス(タイムシート請求アプリ、タイマーベースのメモ化ツール、人員配置・予測ダッシュボード、筋力トレーニングトラッカーなど)が「現在時刻は何時何分か?」という問いかけへの答えに完全に依存していたためです。どのテストケースについても、アプリが未来または過去の何日間(または何週間、何か月間)どのように振る舞うかを手軽にシミュレートできるようになれば、動作のバリデーションや探索的なテストをずっと行いやすくできるでしょう。

ソフトウェアの世界ではよくあることなのですが、長年通用するソリューションで必要なのは、「十分に練り上げられたツール」と「厳格な規律」の組み合わせ、結局これに尽きるのです。

最初に、コード内にチョークポイントを1箇所指定します。その場所では、「現在時刻のオーバーライド」と「現在時刻の読み取り(現実またはフェイク)」の両方を行えるようにします。これは表面的には難しいことではありません。難しいのは、時刻の読み出し方法を、コード内のあらゆる箇所で厳格に統一することです

いくら何でも極端ではという気持ちになるかもしれません。特に、貢献者たちに「Time.currentなどの使い慣れたAPIの利用を避けて、アプリケーション時刻を(それ自身の時刻も読み取り可能な)外部のシステムに必ず渡せ(例: now()などを呼び出すのではなく、時刻をSQLクエリでパラメータ化する)」と強制するのはなおさらです。

最初は過激な話に聞こえるかもしれませんが、時間をこのように扱うのは、「コードから遠ざけておきたいサードパーティ依存関係を扱う」ときと何ら変わりません。アプリとライブラリAPIの間に差し替え可能なアダプタ層を導入するのと同じようなものです。あるいは、ログ出力やセキュリティのconcernsを一貫して処理するために、フレームワークの機能を自分が所有するオブジェクトにラップするのと同じようなものです。

私は自分ひとりで作業できる立場にある利己的なプログラマーなので、規律の徹底については巨大組織の大規模チームよりも楽にやれます。そういうわけで、私が今手掛けているアプリ向けにこの小さなソリューションを以下にまとめておきました。必要なステップはわずかで済みます。

  1. Nowという名前のモジュールを新規作成する。
    Nowは現在時刻や現在日付を提供するとともに、「現在の」値を任意の時刻に置き換えるメソッドも提供する。
  2. コードを徹底的に調べて、コード内のTime.currentTime.zone.nowDate.today、SQLのnow()、およびdatetimeカラムのデフォルト値がNow.timeNow.dateに依存するようにすべて変更する。
  3. テスト実行後は、クロックを実際の時刻に戻しておく
  4. 開発用サーバーでテストを実行するときにクロックを上書きする方法を用意しておく

それでは個別のステップを見てみましょう。

🔗 Nowモジュールを作る

以下が私が作ったNowモジュールです。

# app/lib/now.rb
class Now
  def self.instance
    @instance ||= Now.new
  end

  def self.override!(fake_start_time)
    raise "Overriding time is not allowed in production!" if Rails.env.production?
    @instance = Now.new(fake_start_time)
  end

  def self.reset!
    @instance = Now.new
  end

  def self.time
    instance.time
  end

  def self.date
    instance.date
  end

  def initialize(fake_start_time = nil)
    @fake_start_time = fake_start_time
    @actual_start_time = Time.current
  end

  def time
    if @fake_start_time.present?
      elapsed_time = Time.current - @actual_start_time
      @fake_start_time + elapsed_time
    else
      Time.current
    end
  end

  def date
    time.to_date
  end
end

見ればおわかりかと思いますが、Now.override!に特定の時刻を渡すことで時刻をフェイクしています。重要なのは、そうやって時刻をフェイクした後もNow.timeで取れる時刻が引き続き進行する、つまり時間がちゃんと進み続けるという点です。TimeHelpers#travelを使うと、フェイクに使った時刻が不自然に固定されてしまいますが、この方法ならその心配はありません。また、production環境で誤って時刻がフェイクされないようにガード句も置いてあります(production環境で時刻をフェイクするのはよくありません)。
この実装は完成品ではなく、あくまで出発点に過ぎない点にご注意ください。たとえば、この実装はスレッドセーフではまったくないので、激しく使われるとコケてしまいます。

以下は、このNowモジュールに合わせてさくっと書いたテストコードです。

# test/lib/now_test.rb
require "test_helper"

class NowTest < ActiveSupport::TestCase
  def test_normal_now
    assert_in_delta Time.current, Now.time, 1.second
    assert_equal Date.current, Now.date
  end

  def test_overridden_time
    fake_start_time = Time.current - 1.month
    Now.override!(fake_start_time)

    assert_in_delta fake_start_time, Now.time, 1.second
    assert_equal fake_start_time.to_date, Now.date
    refute_equal fake_start_time, Now.time

    Now.reset!
    assert_in_delta Time.current, Now.time, 1.second
    assert_equal Date.current, Now.date
  end
end

🔗 すべての時間参照を置き換える

すべてのコードを徹底的に調べて、Now.timeNow.dateだけに依存するよう書き換えます。

このステップは読者への課題とします。

🔗 テスト実行後に時刻をリセットする

これで、テストで現在時刻を考慮する場合は、いつでも以下のようにNow.override!で現在時刻をフェイクできるようになりました。

require "test_helper"

class MonthTest < ActiveSupport::TestCase
  # ...
  def test_first_day_of_the_month
    Now.override!(Date.new(2024, 8, 30))

    assert_equal Date.new(2024, 8, 1), Month.beginning_of_this_month
  end
end

続いてテストヘルパーに立ち戻り、ActiveSupport::TestCaseteardownフックでNow.reset!を呼び出します。これで、すべての子孫が連鎖的にダウンします。

# test/test_helper.rb
# ...
module ActiveSupport
  class TestCase
    # ...
    teardown do
      Now.reset!
    end
  end
end

🔗 development用サーバーで時刻をフェイク可能にする

フェイクされた現在時刻をデータベース内でトラッキングするといった高度な作業も既に可能になっています(なお、私のアプリはたいていデータが1行しかないテーブルを持つSystemConfigurationモデルだけを使っています)が、私の利用目的では、時間と空間を完全に制御可能であることが実装されるたびにdevelopment用サーバーを再起動しても一向に構いません。

これをFAKE_NOW_TIME環境変数で行う方法を以下に示します。

# config/initializers/fake_now_time.rb
Rails.application.config.after_initialize do
  if ENV["FAKE_NOW_TIME"] && !Rails.env.production?
    warn "⏰ Overriding time with #{ENV["FAKE_NOW_TIME"]} ⏲️"
    Now.override!(Time.zone.parse(ENV["FAKE_NOW_TIME"]))
  end
end

🔗 追加のクレジット

この手法の難しい点は、アプリがその使命を終えるまでずっと、時間参照にNowモジュールだけを使うという鉄の規律をずっと守り続けなければならないことです。この問題の解決方法のひとつとして、アプリ開発者を1人だけに絞るという手が考えられます(細部へのこうした点にこだわる開発者なら理想です)。別の方法としては、このルールを強制するRuboCopカスタムルールをいくつか書き下ろす方法も考えられます。

関連記事

Rails: Timecopを使わなくても時間を止められた話


CONTACT

TechRachoでは、パートナーシップをご検討いただける方からの
ご連絡をお待ちしております。ぜひお気軽にご意見・ご相談ください。