たんたんとがんばる

ruby, Ruby on Rails, React etc.

シリーズGemを読む(Timecop)

Timecopとは?

TimecopはRuby/Railsで時間関連のテストをする場合に便利なGemで、例えば、freezeメソッドを使うとブロック内かTimecop.returnが呼ばれるまで「指定した時間に停止させておく」ことができます。
他にも、指定した時間に進めるtravelメソッドや時間を速めたり(2倍速とか)できるscaleメソッドを持ちます。
今日はそのGemをざっくりと読み進めていきます。

Timecop.freeze(Date.today + 30) do
  assert joe.mortgage_due?
end

ファイル構成

  • lib/timecop.rb(Timecopクラス) ... インターフェースクラス
  • lib/time_extensions.rb(TimeExtensionクラス) ... TimeやDateTime、Dateのモンキーパッチクラス
  • lib/time_stack_item.rb(TimeStackItemクラス) ... ダミーのTimeやDateTime、Dateインスタンスの情報を保持するクラス
  • lib/version.rb ... Gemのバージョン情報

Timecopクラス

さっそく読んでゆきます。
まずは、TimecopクラスはSingletonモジュールをインクルードしており、シングルトンであることが保証されています※

Timecop.travelを例にとって説明してゆきます(他メソッドも同じ流れです)。
Timecop.travelは:travelを引数にsend_travelメソッドを呼び出し(freezeの場合は:freezeが引数になる)、send_travelメソッドはprivateのtravelメソッドを呼び出します。
privateのtravelメソッドは、TimeStackItemクラスのインスタンスを初期化します。
そしてそのインスタンスを自身で配列として管理します(その配列でTimecopのブロックをネストして呼べるようにです)。

  def travel(*args, &block)
    send_travel(:travel, *args, &block)
  end

  private
  (省略)
  def travel(mock_type, *args, &block) #:nodoc:
    raise SafeModeException if Timecop.safe_mode? && !block_given?

    stack_item = TimeStackItem.new(mock_type, *args)

    stack_backup = @_stack.dup
    @_stack << stack_item

    if block_given?
      begin
        yield stack_item.time
      ensure
        @_stack.replace stack_backup
      end
    end
  end

TimeStackItemクラス

TimeStackItemクラスの冒頭に、コメントでこうあります。

# A data class for carrying around "time movement" objects. Makes it easy to keep track of the time
# movements on a simple stack.

自分なりに解釈すると、ダミーのTime/DateTime/Dateインスタンスを作るためのオブジェクトやメソッドが集められているクラスです。
Time.travelの例で言うと、

  • Time.travel(10.days.ago)が与えられたら10.days.agoのTimeオブジェクトを作り(@time)
  • それが「本当の」時間とどれから離れているかを計算し(@travel_offset)
  • @timeと@travel_offsetからダミーのTimeオブジェクトを返すメソッドを作ったりします(def time)

ここではTimeの操作だけを例にとりましたが、DateTime、Dateも同様です。

     def initialize(mock_type, *args)
        raise "Unknown mock_type #{mock_type}" unless [:freeze, :travel, :scale].include?(mock_type)
        @travel_offset  = @scaling_factor = nil
        @scaling_factor = args.shift if mock_type == :scale
        @mock_type      = mock_type
        @time           = parse_time(*args)
        @time_was       = Time.now_without_mock_time
        @travel_offset  = compute_travel_offset
      end

      def time(time_klass = Time) #:nodoc:
        if @time.respond_to?(:in_time_zone)
          time = time_klass.at(@time.dup.localtime)
        else
          time = time_klass.at(@time)
        end

        if travel_offset.nil?
          time
        elsif scaling_factor.nil?
          time_klass.at(Time.now_without_mock_time + travel_offset)
        else
          time_klass.at(scaled_time)
        end
      end

TimeExtensionクラス

さて、TimeStackItemクラスでダミーのTimeやTimeオブジェクトを生成するメソッドを作ったので、あとはこのクラスでTimeなどのnowやnewやparseメソッドをTimeStackItemクラスのメソッドを呼び出すようにパッチしてしまえば良いだけです!

class Time #:nodoc:
  class << self
    def mock_time
      mocked_time_stack_item = Timecop.top_stack_item
      mocked_time_stack_item.nil? ? nil : mocked_time_stack_item.time(self)
    end

    alias_method :now_without_mock_time, :now

    def now_with_mock_time
      mock_time || now_without_mock_time
    end

    alias_method :now, :now_with_mock_time

    alias_method :new_without_mock_time, :new

    def new_with_mock_time(*args)
      args.size <= 0 ? now : new_without_mock_time(*args)
    end

    alias_method :new, :new_with_mock_time
  end
end
>||