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

PostgreSQLの現在時刻をフェイクする方法とRailsサンプルコード(翻訳)

概要

原著者の許諾を得て翻訳・公開いたします。

お急ぎの方は「データベースの時刻をフェイクする方法」から読むとよいでしょう。

週刊Railsウォッチの以下の項も読んでおくことをおすすめします。

参考: 週刊Railsウォッチ20220511 RailsのテストでPostgreSQLの時刻をフェイクする

PostgreSQLの現在時刻をフェイクする方法とRailsサンプルコード(翻訳)

最初にお断りしておきますが、私は時刻のフェイクに反対しているにもかかわらず、この記事を書いています。一般に、単体テストや統合テストでAPIを呼び出す場合は、グローバルなクロックを改変するのではなく、時刻を値として渡す方が望ましいとされています。フルスタックテストを書くときは、テストが日時に影響されずにパスするようにデータやスクリプトを工夫しておくことで、アプリが1年を通して毎日同じように振る舞うことを検証できるようになります。

しかし、しかしです。

時間という要素は多くのアプリケーションで非常に重要なので、機能のビジネスロジックから日時の依存性を切り離せない場合もあります。そうしたアプリケーションの多くは、何らかの形で現在時刻を制御しなければ、重要なエンドツーエンドテストの実装が非常に困難になります。

本記事を書いたのは、私が以前The Selfish Programmerという記事で紹介したことのあるKameSameというアプリのために耐久性の高いテストデータを作ろうとして週末をつぶしたあげく失敗した後のことです。このアプリでは日本語学習のためにspaced repetition間隔反復)というシステムを実装しており、学習した単語ごとに学習効果を高める形で一連のタイマーを編成します(ユーザー1人あたり数千語になることもよくあります)。

さらに、このアプリのUI構成でメインとなるのは、現在時刻を起点とするタイマーの個別ステータスと累積ステータスの表示です。そのため、ユーザーがログインするタイミングによってアプリの印象ががらりと変わる可能性もあります。

最後に、このアプリで最も複雑な機能の大半が、この日時との関係を変更することに関連します。たとえば、休暇中はレビューを先延ばしにしたり、予定されているレビューをうまく散らして負担が大きくならないようにしたりできます。

私はこれまで、時刻を部分的にフェイクことで制約をかけるエンドツーエンドテストの試行を山ほど見てきましたが、実際にはフェイクする時刻の精度を上げると失敗するようになります。時間管理のコードが不十分なテストに、構成を誤ったアサーションが加わると、こういうことになりがちです。

timecopなどの言語レベルのライブラリは、標準ライブラリ(RubyのTime.nowDate.todayなど)が返す値を加工して時間を止めたりタイムトラベルしたりするユーティリティを提供します。

これらのライブラリは概ね洗練されていて信頼できますが、アプリケーションが外部世界(HTTPサーバーやデータベースなど)と通信する必要が生じてくると、そうもいかなくなってしまいます。今どきのアプリケーションではこうした要件を求められるのが普通です。

アプリケーションが「今は2042年」だと思っているのに、データベースが「今はまだ2022年」と思っていたら、データベース側に実装されたロジックが多ければ多いほど悲惨な結果になるでしょう。

これに対して、「だからデータベースが持つ現在時刻は決して当てにしてはならない」「日時は毎回必ずクエリパラメータとして律儀に渡すべきだ」と主張する人もいるかもしれません(これは、テストしやすくするためにnow()などのSQL関数を使い続ける方針と対照的です)。こういう方針にメリットがないとまでは言いませんが、私がもしこの通りにするとなったら、せっかく数年がかりでデータ集約型ロジックをSQLのVIEWや関数に移行する形で達成したパフォーマンス最適化を、また元に戻さなくてはならなくなります。テストしやすいデザインパターンにはもちろん価値がありますが、実行時パフォーマンスの低下と引き換えだとすると(当然ながら)厳しいトレードオフになるでしょう。

🔗 データベースの時刻をフェイクする方法

それでは始めましょう。本記事で扱っている内容はすべて以下のサンプルアプリで実装されていますので、ご自由に参照いただけます。

testdouble/time_traveler_demo - GitHub

このガイドはPostgreSQLで書いていますが、PostgreSQL固有の機能は必須ではなく、ほぼすべてのリレーショナルデータベースで使えるはずです。

時刻をフェイクするのに必要なすべてのレベルは以下のとおりです。

  1. システムクロックに対する相対時刻オフセットを保存する場所を用意する
  2. 現実の時刻に保存済みのオフセットを足すことでフェイク時刻を返すカスタムDB関数を作る
  3. DBの現在時刻を使っているすべての箇所をこの新しいDB関数に置き換える
  4. アプリケーションの現在時刻とデータベースの現在時刻を同時にフェイクする

🔗 1. 時刻オフセットを保存する

私のアプリのほとんどは、単一行とそれに対応するシングルトンActive Recordモデルを持つsystem_configurationsテーブルのようなものになります。このテーブルには、デプロイ全体のコンフィグとステートを保存します(たとえばこのテーブルにlast_updated_japanese_dictionary_atタイムスタンプを保存するかもしれません)。

なお、コンフィグのプロパティを保存するテーブルを追加したくない場合は、おそらくSQLのSETSHOWで同じことができるかもしれません。

このコンフィグテーブルのマイグレーションは以下のような感じになります。

class CreateSystemConfiguration < ActiveRecord::Migration[7.0]
  def change
    create_table :system_configurations do |t|
      t.bigint :global_time_offset_seconds, null: false, default: 0

      t.timestamps
    end
  end
end

後は、シングルトンのSystemConfigurationモデルがあれば十分です。

class SystemConfiguration < ApplicationRecord
  def self.instance
    if (system = first)
      system
    else
      SystemConfiguration.create!
    end
  end

  def reset_global_time_offset_seconds!
    update!(global_time_offset_seconds: 0)
  end
end

常にSystemConfiguration.instanceでアクセスする限り、デプロイされた各環境は一度に最大1個の永続化されたシステムコンフィグを持つことになります(このinsertトリガーで強制することも可能です)。これは事実上、単一のglobal_time_offset_seconds値を持ち、アプリケーション全体がこれに依存するようにできることを意味します。

オフセットを秒単位のbigintにしたことにお気づきでしょうか。その理由は、PostgreSQLのinterval型でいろいろ試した結果、あまりに面倒だったからです。それで、out-of-rangeを発生しないISO8601文字列を作成するために、2つの時刻の差をdurationに変換するという効率の低い方法になりました(これはActive RecordのPGアダプタでよく使われています)。

🔗 2. now()のラッパー関数を作成する

オフセットが保存可能になったので、今度はnow()から返される現実の時刻に追加するSQL関数が必要です。

以下は、その名もnowish()というSQL関数を定義するマイグレーションです。

class CreateNowishFunction < ActiveRecord::Migration[7.0]
  def up
    # `pg_catalog.now()'の形を模倣するために書いた
    #
    # CREATE OR REPLACE FUNCTION pg_catalog.now()
    #  RETURNS timestamp with time zone
    #  LANGUAGE internal
    #  STABLE PARALLEL SAFE STRICT
    # AS $function$now$function$
    #
    execute <<~SQL
      CREATE OR REPLACE FUNCTION public.nowish()
      RETURNS timestamp with time zone
      AS
      $$
      BEGIN
      RETURN pg_catalog.now() + (select global_time_offset_seconds
        from public.system_configurations
        limit 1
      ) * interval '1 second';
      END;
      $$
      LANGUAGE plpgsql STABLE PARALLEL SAFE STRICT;
    SQL
  end

  def down
    execute "drop function public.nowish()"
  end
end

nowish()呼び出しはnow()呼び出しよりわずかにコストが高いのですが、これはシングルトンテーブルの1行1列をSELECTするだけのSTABLE関数なので、コストは多少抑えられます。コストの軽減方法はいくつか考えられます。

🔗 3. すべての参照をnow()からnowish()に置き換える

次は、スキーマ内における現在時刻へのすべての参照を新しいnowish()関数に変更する作業です。

この作業の難易度は、now()呼び出しの個数や、無数の同義の関数(大文字小文字を区別しない)や関連する関数(clock_timestampage(timestamp)など)の個数に完全に依存します。grepをご用意ください。

私のKameSameアプリの場合、数回のシンプルなマイグレーションで40箇所の参照を検索置換する必要がありました。率直に言うと、思ったほど大変ではありませんでした。マイグレーションの多くは、以下のようなカラムデフォルトの変更です。

class ChangePetsBornAtDefault < ActiveRecord::Migration[7.0]
  def change
    change_column_default :pets, :born_at,
      from: -> { "now()" }, to: -> { "nowish()" }
  end
end

とは言うものの、それなりの作業ではあります。これが正しく機能するには、現在時刻を参照するたびに、フェイクされた時刻を期待通りに返す関数を実行する必要があります。大規模なチームの場合、lintやコミットフックでコンプライアンスを強制できなければ、このような細かなレベルの調整は難しいかもしれません。

🔗 4. タイムトラベル関数用APIを作成する

以上でようやく、プログラミング言語とデータベースのどちらでも、望みの時刻にタイムトラベルするクラスや関数をアプリで作成できるようになります。

以上をすべてまとめたRubyコードを以下に示します。

私は、Rails標準のActiveSupport::Testing::TimeHelpersではなくtimecop gemを使うことにしました。前者のTimeHelpersは単に指定の時刻でクロックをフリーズさせるだけなので(travelを呼び出す場合でも同様です)、たちまちデータベースとアプリケーションの同期が取れなくなってしまうからです。一方、後者のTimecop.travelは時間の流れを止めずに現在時刻を変更します。

class TravelsInTime
  def call(destination_time)
    Timecop.return
    set_pg_time!(destination_time)
    set_ruby_time!(destination_time)
  end

  private

  def set_pg_time!(destination_time)
    SystemConfiguration.instance.update!(
      global_time_offset_seconds: destination_time - Time.zone.now
    )
  end

  def set_ruby_time!(destination_time)
    Timecop.travel(destination_time)
  end
end

これで、RubyとPostgreSQLの両方の時刻を以下のようにシンプルに操作できるようになります。

TravelsInTime.new.call(1.year.from_now)

おや、ついでに自分の次の誕生日がいつだったかを思い出しましたね?

もちろん、以下のサンプルRailsアプリケーションでこのアプローチに慣れておいてから自分のアプリケーションで実装するのは大歓迎です。

testdouble/time_traveler_demo - GitHub

🔗 現実を改変するときに重要なのは「マインドセット」

ほとんどの開発者は、モックについて「本物はフェイクよりも優れているのだから、フェイクはできる限り最小限にすべき」という合理的かつシンプルな直感に従うものです。この考え方自体は間違っていませんが、現実がいかに複雑で不合理に満ちているかが過小評価されがちです。いかなるテストも、現実世界の利用方法を完璧にはシミュレートできません。テストを現実に近づければ近づけるほど、テストはより変動にさらされるようになり、テストが失敗する意味について確信を持てなくなってきます。

こうした懸念を現実に対する譲歩として上乗せするよりも、テストを一種の科学実験と見なす方が生産的だと私は思います。外部要因を固定できれば、それらはすべてテストにおける実験の制御下に置かれます。制御が効けば効くほど、テストの意図が読む人に深く伝わるようになり、テストがパスしたときに仮説が実証されたという確信も深まります。

「科学実験における制御」という目線で考えることによって、より多くの情報を手がかりにして何をいつフェイクすべきかを決定できるようになります。

たとえば、ある時刻を受け取って別の時刻を返す純粋な関数をテストする場合、システムクロックの値がテストの実験デザインに影響するとは普通考えないでしょう。しかし、木曜日と金曜日(タイムシートの締切日前後など)で動作ががらりと変わる時間管理アプリのエンドツーエンドテストを書く場合はどうでしょう。

何が何でもフェイクを最小限にしたいという衝動に駆られてテストを書いてしまうと、一見傷が浅そうに見えても、実は実装の変更にもろいテストになってしまったり、欲しい振る舞いをチェックするテストスコープのロジックが散らばってテストの意図がわかりにくくなってしまったりします。

システムからどの程度アクセスされるかを心配せずにクロックを制御できるようにすることで、同じテストでも大規模なリファクタリングに耐えられるようになり、指定の曜日に期待される振る舞いが観測可能になってオーサリングがやりやすくなることが期待できます。

ソフトウェアのほとんどのタスクは、答えが1つではなく複数あるものですが、どの答えにもトレードオフがあります。トレードオフによってやむを得ずデータベースの時刻をフェイクしたことがある方にとって、本記事のアプローチがお役に立てば何よりです。

関連記事

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


CONTACT

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