スタディサプリ Product Team Blog

株式会社リクルートが開発するスタディサプリのプロダクトチームのブログです

現在時刻に依存する機能の動作確認コストを下げる基盤 -Time Injection-

こんにちは。スタディサプリ小学・中学講座の Engineering Manager をしている @ywada526 です。

今回は、現在時刻に依存する機能の動作確認コストを下げる基盤について紹介します。この基盤は、社内ではコード上の名前をとって「Time Injection」という愛称 1 で呼ばれています。この記事内でも Time Injection という名前を使って説明します。

みなさんは、現在時刻に依存する機能の動作確認や品質保証はどのように行なっていますか。「時刻を固定した環境を用意する」「データを弄る」「ユニットテストで担保するから動作確認はしない」「コードをじっと見つめて問題ないと言い切る 2」など色々あると思います。これまで、スタディサプリ小学・中学講座でも同様の手段で動作確認を行なってきましたが、動作確認コストが課題であったため、Time Injection を導入することにしました。

Time Injection は一言でいうと「アカウントごとに任意の時刻を現在時刻として設定できる機能」で、非本番環境に導入しています。時刻を設定するための GUI を提供しており非開発者にも利用されています。

本記事では、現在時刻依存機能のテストのむずかしさについて整理し、Time Injection の実装と導入における注意点について説明します。「前置きはいいからとにかく本題が読みたい」という方は、前提は飛ばして、実装のパートから読み進めてください。

現在時刻に依存する機能のテストのむずかしさ

まずは、現在時刻に依存する機能のテストのむずかしさについて整理します。このむずかしさは、「現在時刻のふるまいが非決定的なため、特定の条件の再現がむずかしい」ということに換言できます。

簡単な実装で考えてみましょう。

現在時刻とユニットテスト

平時は 'Hello!' 文字列を返すが、クリスマスには 'Merry Christmas!' 文字列を返す greet メソッドを実装します。コードは Ruby です。

def greet
  now = Time.now
  if now.month == 12 && now.day == 25
    'Merry Christmas!'
  else
    'Hello!'
  end
end

さて、このメソッドがクリスマスに 'Merry Christmas!' 文字列を返すことをテストするにはどうすればよいでしょうか。単純に関数を実行して返り値を検証するのでは、本当にクリスマスに実行するほかありません。これが「特定の状況の再現がむずかしい」ということです。

では、このコードのテストを書くにはどうすればよいでしょうか。大別すると以下の 2 つのパターンがあります。

  1. 現在時刻への依存を隔離する
  2. 現在時刻を扱うライブラリへ介入する

1 は、現在時刻を greet メソッドの引数として渡す実装になります。こうしておけばテストコードで指定の時刻を渡して greet メソッドを実行できます。

2 は、テストのヘルパーライブラリなどを利用して、Time クラスの now メソッドのスタブを作成する実装になります。テストコードでスタブが再現したい時刻を返すように実装します。

私は、基本的には 1 を選択するべきと考えています。非決定的なふるまいをする現在時刻に直接依存するロジックはそれ自体が非決定的なふるまいをすることになり、そのロジックに依存するロジックもまた非決定的なふるまいを... と次々に呼び出し元に波及していきます。テスタビリティやメンテナビリティを確保するために、こうした依存はできることなら隔離するべきです。3

例外的に「シグネチャを変更したくない」「そもそも依存を外から渡すことができない」などの理由で 2 を選択することもあるとは思います。

現在時刻に関するユニットテストについて深く知りたい方は、こちらの記事「現在時刻が関わるユニットテストから、テスト容易性設計を学ぶ - t-wadaのブログ」の分析と説明が大変わかりやすいので参考にしてみてください。

現在時刻と E2E テストや動作確認

ユニットテストであれば、テストすること自体は簡単にできることがわかりました。仮に現在時刻への依存を隔離できなかったとしても、現在時刻を扱うライブラリに介入することでテストすることができます。では、もっと統合されたテスト、たとえば E2E テストや動作確認ではどうでしょうか。

スタディサプリ小学・中学講座で実際にあった例を 2 つあげます。

  1. 夏休み講座など期間限定コンテンツの動作確認を行いたい
  2. 今週の学習進捗に応じて翌週のシステムの挙動が変わる機能の動作確認を行いたい

まず 1 に関しては、先述のクリスマスの例と同じで、ある時点での挙動が再現できればよいわけです。ゆえに、ユニットテストのときと同じ戦略をとることができます。

ただ、E2E ではユニットレベルで現在時刻をコントロールすればよいわけではありません。基本的にはシステム全体で現在時刻をコントロールすることになります。この例では、システム全体で現在時刻を夏休みに固定する必要があります。

ふつう、検証用の環境は一人で占有できるものではなく、チームや組織でシェアをしていると思います。シェアしている環境の現在時刻を改変するわけにはいきません。現在時刻を固定した専用の環境を立ち上げればよいですが、準備するコストがかかるのが難点です。

次に 2 のケースを見てみましょう。

このケースでは「今週」と「翌週」という 2 つの時点が登場しています。つまり「ある時点のユーザーの行動が未来のシステムの挙動に影響する」パターンです。現実問題として、このような仕様を持つプロダクトは多いと思います。

これをテストするには「今週」に条件を作るための行動をして「翌週」に確認をする必要があります。動作確認方法の例は以下の通りです。

  • 「今週」に現在時刻を固定した環境を用意した上で条件を再現する行動をとり、「翌週」に現在時刻を移動し動作を確認する
  • 条件を再現するためにデータベースを編集して動作を確認する
  • 条件を再現する行動をとった上で「翌週」までじっと待って 4 動作を確認する

いずれの方法でも、先ほどのケースよりも動作確認の難易度が上がっているのがわかると思います。また、データベースを編集して確認をする方法では、編集する作業にミスが混入する可能性があるためテスト結果の信頼性が下がります。

E2E での検証の場合、ユニットレベルでの検証に比べ、準備のコストが高く難易度がグッと上がることがわかったと思います。スタディサプリ小学・中学講座ではこの手の動作確認のコストが高いことに度々悩まされていました。

現在時刻に依存する機能の動作確認コストを下げる基盤の実装

さて、ここからが本題です。現在時刻依存機能の動作確認コストを下げるために導入した Time Injection について説明します。

やりたいことの整理

あらためてやりたいことを整理します。

  • いちいち現在時刻を固定した環境を用意せずとも既存の検証環境で動作確認をしたい
  • 一つのシナリオの中で、現在時刻が移動するパターン (たとえば先述の夏休み講座) にも対応したい
  • プロダクトマネージャー、学習コンテンツ制作担当者、QA 担当者など、非開発者も動作確認をしたい

これを簡単に要件に落とします。

  • 既存の検証環境にて、ユーザーごとに、任意の時刻を現在時刻として設定 / 更新できること
  • 非開発者も利用するため、GUI で現在時刻を設定できること

既存の検証環境で、環境自体の現在時刻を改変することなく動作確認を可能にするために、ユーザー (検証アカウント) ごとに現在時刻を設定できるようにするという方針をとることとしました。

実装

さて、要件を満たすために実装することは大きく以下の 3 つです。

  • 設定時刻をユーザーごとに永続化するデータストアを用意する
  • 時刻の参照 / 設定をできるようにする
  • アプリケーションで設定された時刻を利用する

まずは、データストアの用意です。データストアについては、ユーザー ID と設定時刻 (たとえば ISO 8601 形式の String) のペアが登録できれば何を利用してもよいと思います。シンプルな KVS で十分なので、私たちは使い慣れている Redis を選択しました。

次に、時刻の参照 / 設定です。参照も設定もほとんど同じなので、設定のケースを説明します。以下がシーケンスです。

時刻設定のシーケンス図

先述の通り、非開発者も利用するため時刻設定用の GUI を作成します。スタディサプリ小学・中学講座では、既に、非本番環境の /debug パスにデバッグ画面を用意していたため、ここに時刻設定機能を追加しました。

(1) で、設定したい時刻を Body に詰めて /injected_time に POST します。コードベース上では設定時刻を injected_time と表現しています。時刻の形式はなんでもよいですが、ISO 8601 形式が標準化されておりライブラリ等で扱いやすいのでおすすめです。(2) で、認証ユーザーの id と設定時刻のペアを Data Store に保存します。

最後に、アプリケーションで設定された時刻を利用する実装です。以下がシーケンスです。

時刻利用のシーケンス図

(2) で、コントローラーの共通処理などで認証ユーザーの設定時刻をデータストアから取得します。(4) で、取得した設定時刻をビジネスロジックに渡します。

ここで重要になるのが、現在時刻に依存したビジネスロジックは引数などで時刻を受けとれるように依存を隔離しておくことです。内部で直接現在時刻に依存してしまうと設定時刻を反映することができなくなるためです。lint などを活用して内部での直接依存を禁止するとベターです。付録 2 にて Ruby の lint ツールである RuboCop での実装例を紹介します。

利用イメージ

Time Injection を使えば、現在時刻に依存した機能も、特別な環境やデータを用意したりすることなく、簡単に動作確認ができるようになります。

実際の利用イメージをみてみましょう。

スタディサプリ小学講座のホーム画面は、毎月季節に応じて背景が変わる素敵な仕様です。現在 12 月はクリスマスの背景 5 です。

スタディサプリ小学講座 12 月背景

Time Injection を使って 1 月を設定してみます。時刻設定用の GUI がこちらです。

Time Injection 設定画面

input に現在時刻を入力して Update ボタンを押すことで設定できます。今は未設定の状態、つまり執筆時点の本当の時刻「2024-12-10」の挙動になります。

1 月にしたいので「2025-01-01」を設定します。

Time Injection 設定画面の入力例

これで設定ができたので小学講座のホーム画面を覗いてみましょう。

スタディサプリ小学講座 1 月背景

1 月のお正月の背景 6 に変わりました。一足早いお正月。時間旅行の気分です。

1 年間運用してみてわかったこと

Time Injection の導入をしたのは 2023 年 6 月なので、もう 1 年以上運用をしています。運用する中でわかったことをいくつか紹介します。

予想以上に利用されている

Time Injection の利用シーンは多岐に渡っていて、QA、E2E テスト、問い合わせの調査対応、非開発者による動作確認などでも使われています。

特に力を発揮しているのが、非開発者による動作確認です。たとえば、学習コンテンツ制作担当者が、意図通りにコンテンツが表示 / レコメンドされることを確認したり、プロダクトマネージャーが仕様整理のために挙動確認をするときなどに使われています。

これまでは、こういった動作確認は開発者に依頼されていました。開発者としては、時刻依存の機能の調査や確認は手間がかかっていたため、ずいぶんと楽になりました。非開発者目線でも、わざわざ開発者に聞かずとも自分で確認ができるのはよい体験と思います。

過去へ戻るのはむずかしい

当たり前の話ではあるのですが、時刻を自由に設定できるといっても、過去へ戻る操作はハードルがあります。システムとしては、当然、時間が巻き戻ることなんて考慮していないため、何かしらの不具合が起こる可能性が高いです。

しかし、運用してみると、過去に戻って動作確認したいケースがそれなりにあることがわかりました。問い合わせの調査などで過去時点の状態を再現したい場合などです。

過去の時刻を Time Injection で設定したときに不具合が起こるのか起こらないのか、どう対処すればいいのか。これはケースバイケースで実装の詳細によります。運用としては、過去日付を設定しようとした場合は注意喚起のアラートを出すようにし、必要であれば開発者へ相談してもらうようにしています。

時刻を引数で渡すことの是非

Time Injection の導入の際に「時刻を引数で渡すとなると、深い呼び出し先で時刻が必要な場合に、バケツリレーで渡すことになり記述が冗長になるのではないか」という懸念がありました。

結論としては 1 年間運用した範囲では、特に問題にはなっていません。

先述した通り、基本的には現在時刻への依存は隔離するべきだと考えています。しかし、バケツリレー問題はコードが大規模になれば開発体験に悪影響を及ぼす可能性もあります。もし今後問題となれば、依存注入のやり方を工夫するなど、何か策を講じることになると思います。

導入における注意点

Time Injection はプロダクトの運用コスト削減に大いに役立っていますが、いくつか注意点があります。

プロダクションコードへの汚染

プロダクションコードへの汚染とは「単体テストの考え方/使い方」で紹介されているアンチパターンです。

プロダクション・コードへの汚染とは、テストでのみ必要とされるコードをプロダクション・コードに加えることを指します。

このアンチパターンの問題は、プロダクションコードの保守コストが大きくなってしまうことです。ただし、プロダクションコードへの汚染が常に悪いわけではなく、プロダクションコードの保守コストの増加と得られるリターンのバランスを見るべきだという話です。

Time Injection もプロダクションコードへの汚染に他なりません。保守コストが発生するわけですから、リターンが得られるかどうかを考える必要があります。

私たちの実装でも、このバランスの観点でいくつか割り切っていることがあります。一つは、オンライン処理のみで設定された時刻を利用し、バッチジョブでは設定された時刻を考慮していない点です。オンライン処理のみでもほとんどのユースケースがカバーできるため、バッチジョブでは実装をしない意思決定をしています。

このように、導入する際は、本当にニーズがありリターンが得られるのかに注意してください。

E2E テストの書きすぎに注意

時刻依存の機能の E2E テストを書くのは通常むずかしく、ほとんど無理といっていいケースも多いです。

Time Injection を導入することで時刻依存の機能の E2E テストが簡単に書けるようになります。

しかし、テストの比重を E2E テストによせることを勧めているわけではありません。むしろ注意したいポイントです。基本に則り、ユニットテストでできることをわざわざ E2E テストで書かないようしましょう。本当に E2E テストが必要かを考えましょう。

私は、Time Injection が真価を発揮しているのは E2E テストではなく、手動での動作確認だと感じています。自動テストに起こすまでではない (リグレッションを気にしているわけではない) が、動作確認をしたいというニーズはよくあります。

おわりに

今回は、現在時刻に依存する機能の動作確認のむずかしさと、それを解決するための基盤「Time Injection」について紹介しました。同様の課題に悩んでいる方の参考になればうれしいです。

付録

付録 1. マイクロサービスアーキテクチャへの応用

実装の項の説明では、シンプルな backend を想定して説明しましたが、スタディサプリ小学・中学講座の実際の構成では複数の backend service が協調しています。

スタディサプリ小学・中学講座のサービス構成の概要図

スタディサプリ小学・中学講座の構成を大胆に省略したのが上の図です。

このようなマイクロサービスアーキテクチャでは、各 backend service で Time Injection で設定した時刻を利用する必要があります。実装としては以下の通りです。

  • gateway でデータストアへ時刻の読み書きをする
  • 以降のアップストリームサービスへのリクエストで設定時刻を HTTP ヘッダー 'X-Injected-Time' として引き回す
  • 各 backend service は HTTP ヘッダー 'X-Injected-Time' の値を利用する

実際の構成ではもっと多くの backend service が存在していますが、全てのサービスで Time Injection を実装しているわけではありません。プロダクションコードへの汚染の項で説明した通り、管理コストの増加とリターンのバランスを考えて、主要な service のみで実装をしています。

付録 2. 現在時刻への直接依存を禁止する lint

実装の項で「現在時刻に依存したビジネスロジックは引数などで時刻を受けとれるように依存を隔離しておくこと」と説明しましたが、lint を作成することでこれを強制することができます。例として Ruby の静的解析ツールの RuboCop のサンプルコードを紹介します。

# lib/custom_cops/no_get_current_time.rb

module CustomCops
  class NoGetCurrentTime < ::RuboCop::Cop::Base
    MESSAGE = 'Do not use methods that get current time (e.g. `Time.current`). You may use `context[:current_time]` instead.'.freeze

    def_node_matcher :get_current_time_by_date?, '(send (... :Date) { :new :yesterday :today :tomorrow :current })'
    def_node_matcher :get_current_time_by_datetime?, '(send (... :DateTime) { :new :now :today :current })'
    def_node_matcher :get_current_time_by_time?, '(send (... :Time) { :new :now :current :current })'
    def_node_matcher :get_current_time_by_time_zone?, '(send (send (... :Time) :zone) { :now :today })'

    def on_send(node)
      return unless
        get_current_time_by_date?(node) ||
        get_current_time_by_datetime?(node) ||
        get_current_time_by_time?(node) ||
        get_current_time_by_time_zone?(node)
      add_offense(node, message: MESSAGE)
    end
  end
end
# spec/custom_cops/no_get_current_time_spec.rb

require 'rails_helper'
require 'rubocop'
require 'rubocop/rspec/support'

require 'custom_cops/no_get_current_time'

RSpec.configure do |config|
  config.include(RuboCop::RSpec::ExpectOffense)
end

RSpec.describe CustomCops::NoGetCurrentTime do
  describe 'on_send' do
    subject(:cop) { CustomCops::NoGetCurrentTime.new }

    context 'methods that get current time' do
      [
        'Date.new',
        'Date.new()',
        'Date.today',
        'Date.yesterday',
        'Date.tomorrow',
        'DateTime.new',
        'DateTime.new()',
        'DateTime.now',
        'DateTime.today',
        'DateTime.current',
        'Time.new',
        'Time.now',
        'Time.current',
        'Time.zone.now',
      ].each do |source|
        context "#{source}" do
          it do
            expect_offense(<<~EOS, source:)
              %{source}
              ^{source} CustomCops/NoGetCurrentTime[...]
            EOS
          end
        end
      end
    end

    context 'methods that do not get current time' do
      [
        'Date.new(2024, 1, 1)',
        'Date.parse',
        'DateTime.new(2024, 1, 1)',
        'DateTime.parse',
        'Time.new(2024, 1, 1)',
        'Time.zone',
        'Time.zone.parse',
      ].each do |source|
        context "#{source}" do
          it { expect_no_offenses(source) }
        end
      end
    end
  end
end
# .rubocop.yml

require:
  - ./lib/custom_cops/no_get_current_time.rb

AllCops:
  DisabledByDefault: true

CustomCops/NoGetCurrentTime:
  Enabled: true
  Include:
    - app/lib/**/*.rb
    - app/models/**/*.rb
    - app/services/**/*.rb
    - spec/**/*.rb # and so on...

  1. ザ・ワールドと呼ばれたことも。
  2. 決して冗談ではなく私自身もやったことがあります。
  3. なぜ依存を注入するのか DI の原理・原則とパターン でもこうした非決定的なふるまいへの依存は「揮発性依存」として依存注入の対象とするべきと整理しています。
  4. これも冗談ではなく、実際に行われたことのある方法です。
  5. スクリーンショットで伝わらないのが残念ですが、実際のアプリでは雪が降ってサンタが動くアニメーションがあります。よければアプリを使ってみてください。
  6. スクリーンショットの左上にちょっとしたカードのようなものが見えると思います。ここに設定時刻を表示しています。Time Injection を使っていることを忘れて「何かがおかしいと悩む」という話が何度かあったため、設定時刻を表示して使っていることを忘れないようにしています。