他のテストがタイムトラベルに巻き込まれないよう確実に Timecop.return する(for RSpec)

テストコード内で Timecop.return し忘れると、後続のテストでも Timecop.freezeTimecop.travel によるタイムトラベルを続行してしまうため、正常な時刻に戻らず、全く関係のないテストが落ちることがある。

例えば、以下のように Timecop.return を記述し忘れるケースがある。 今日これに遭遇してしまったので、Timecop.return するいくつかの方法を知識整理も兼ねてまとめてみたいと思う。

describe Foo do
  before do
    Timecop.freeze(2000, 10, 10, 9, 0, 0)
  end

  it { some_code }
end

一番単純な方法は、after ブロックで return することである。これで example 実行後にちゃんと時間がもとに戻る。

describe Foo do
  before do
    Timecop.freeze(2000, 10, 10, 9, 0, 0)
  end

  after do
    Timecop.return
  end

  it { some_code }
end

あるいは、メソッドブロックで freeze させたいコード片を囲っても良い。こっちのほうが Ruby らしい。

describe Foo do
  it do
    Timecop.freeze(2000, 10, 10, 9, 0, 0) { some_code }
  end
end

このようなイケてる shared context を定義すれば、spec から Timecop を隠蔽できる。美しい。

RSpec.shared_context 'with frozen time' do |options|
  let(:frozen_time) { options[:at] } if options && options[:at]

  around do |e|
    Timecop.freeze(frozen_time) { e.run }
  end
end
describe Foo do
  include_context 'with frozen time', at: Time.current
  it { some_code }
end

このように Timecop.return する方法はいくつかあるが、今回は example 実行後に必ず Timecop.return を呼び出す Helper を作った。

module RSpec
  module TimecopReturnHelper
    def self.included(rspec)
      rspec.after(:each) do |_|
        Timecop.return
      end
    end
  end
end
RSpec.configure do |config|
  config.include RSpec::TimecopReturnHelper
end

これによって、他のテストのタイムトラベルに巻き込まれることを確実に防げるし、二度と Timecop.return を書き忘れない。

spec 側ですでに Timecop.return している場合は、二重に Timecop.return への呼び出しが発生するが、これによってテスト時間が延びることは今のところなさそうである。