Rubyで時間に依存した機能をテストする時便利なgem, Timecop

開発 hironemuhironemu

アプリケーションの機能としてある日時になったら「なになに」する、みたいなものはよくあると思います。例えば、最後にアクセスした日から1ヶ月以上過ぎているユーザにメールを送信する、とか。

こういう機能をテストする場合、どうやってテストしようか色々考えると思います。(DBに登録されている時刻を更新したり、対象の関数に日時を渡せるようにしたり)

そこで、今回紹介するtimecopというgemを使うと今回のようなテストも簡単に行えます。主に以下の2つの関数を使って時間を操作します。

  • Timecop.freeze – 指定した時刻に固定する
  • Timecop.travel – 指定した時刻に移動し、その時点から時間が流れる

サンプルを見たほうが早いと思うので、簡単にPadrinoのアプリケーションをrspecでテストしてみたいと思います。

Padrinoのプロジェクトを作成(rspecを使うように指定)

$ padrino g project sample -t rspec
$ cd ./sample
$ bundle install --path .bundle

app/app.rbにテスト対象のコードを記述

/currentにアクセスすると現在時刻を返す簡単なものを作成します。

module Sample
  class App < Padrino::Application
    register Padrino::Rendering
    register Padrino::Mailer
    register Padrino::Helpers

    enable :sessions

    # 現在の時刻を返す
    get :current do
      Time.now.to_s
    end
  end
end

spec/app/app_spec.rb(テストコード)を作成

大きく分けて、Timecopを使わない場合、Timecop.freezeを使った場合、Timecop.travelを使った場合の3つのテストを書いてみます。各コンテキストの最初に1回だけTimecop.freeze, Timecop.travelを実行し時間を操作しています。各exampleでは1秒間のスリープを入れています。(因みにこのサンプルではミリ秒単位のことは考えていないので、場合によってはテストがコケるかも知れません)

# -*- coding: utf-8 -*-
require 'spec_helper'

describe "app.rbのテスト" do
  context "Timecopを使わない場合" do
    it "アクセスした時の時間が表示される" do
      sleep 1
      now = Time.now
      puts "    now: #{now}"
      get "/current"
      last_response.body.should eq now.to_s
    end
    it "アクセスした時の時間が表示される" do
      sleep 1
      now = Time.now
      puts "    now: #{now}"
      get "/current"
      last_response.body.should eq now.to_s
    end
    it "アクセスした時の時間が表示される" do
      sleep 1
      now = Time.now
      puts "    now: #{now}"
      get "/current"
      last_response.body.should eq now.to_s
    end
  end
  context "時間固定(2014/02/25 15:00:00で固定)" do
    before :all do
      Timecop.freeze(Time.new(2014, 2, 25, 15, 0, 0))
    end
    after :all do
      Timecop.return
      puts "    Timecop.returnのあとのTime.now: #{Time.now}"
    end
    it "いつでも、2014/02/25 15:00:00" do
      sleep 1
      get "/current"
      last_response.body.should eq Time.new(2014, 2, 25, 15, 0, 0).to_s
    end
    it "いつでも、2014/02/25 15:00:00" do
      sleep 1
      get "/current"
      last_response.body.should eq Time.new(2014, 2, 25, 15, 0, 0).to_s
    end
    it "いつでも、2014/02/25 15:00:00" do
      sleep 1
      get "/current"
      last_response.body.should eq Time.new(2014, 2, 25, 15, 0, 0).to_s
    end
  end

  context "時間旅行(2014/02/25 15:00:00にタイムトラベル)" do
    before :all do
      Timecop.travel(Time.new(2014, 2, 25, 15, 0, 0))
    end
    after :all do
      Timecop.return
      puts "    Timecop.returnのあとのTime.now: #{Time.now}"
    end
    it "指定した時間の1秒後 2014/02/25 15:00:01" do
      sleep 1
      get "/current"
      last_response.body.should eq Time.new(2014, 2, 25, 15, 0, 1).to_s
    end
    it "指定した時間の2秒後 2014/02/25 15:00:02" do
      sleep 1
      get "/current"
      last_response.body.should eq Time.new(2014, 2, 25, 15, 0, 2).to_s
    end
    it "指定した時間の3秒後 2014/02/25 15:00:03" do
      sleep 1
      get "/current"
      last_response.body.should eq Time.new(2014, 2, 25, 15, 0, 3).to_s
    end
  end
end

テストを実行

$ padrino rake spec
.... 省略
app.rbのテスト
  Timecopを使わない場合
    now: 2014-02-25 14:54:50 +0900
    アクセスした時の時間が表示される
    now: 2014-02-25 14:54:51 +0900
    アクセスした時の時間が表示される
    now: 2014-02-25 14:54:52 +0900
    アクセスした時の時間が表示される
  時間固定(2014/02/25 15:00:00で固定)
    いつでも、2014/02/25 15:00:00
    いつでも、2014/02/25 15:00:00
    いつでも、2014/02/25 15:00:00
    Timecop.returnのあとのTime.now: 2014-02-25 14:54:55 +0900
  時間旅行(2014/02/25 15:00:00にタイムトラベル)
    指定した時間の1秒後 2014/02/25 15:00:01
    指定した時間の2秒後 2014/02/25 15:00:02
    指定した時間の2秒後 2014/02/25 15:00:03
    Timecop.returnのあとのTime.now: 2014-02-25 14:54:58 +0900

この結果を見るとわかると思いますが、Timecop.freezeは指定した時刻で時間を固定します。freezeしたあとのどのタイミングでTime.nowをしてみてもfreezeした時の時刻が取得できると思います。

Timecop.travelの場合はといいますと、指定した時刻から時間が流れている感じになりますので、1秒後にTime.nowとしますと指定した時刻+1秒後の時刻が取得できると思います。

afterで実行しているTimecop.returnは時刻を元に戻すためですので、テストの終わりに必ず実行するようにしておくといいでしょう。

また、Time.now以外にもDate.todayDateTime.nowも同じように偽装されますので、それらを利用している関数のテストにも使うことが出来ます。

ということで、Timecopを使うとテストがシンプルに書けそうですね。