ポイントシステム(3) -- timecop

2013/09/30

前回に引き続き、「ポイントシステム」の実装を続けます。今回は、「ログインポイントはユーザーごとに1日1回しか与えられない」という仕様のテストと実装です。

timecop

時間の経過に関連する仕様のテストを書く際に、便利なのが timecop という Gem パッケージです。あたかも時間旅行をするかのように Time.currentDate.today が返す値を一時的に変更してくれます。

まずは、インストール。Gemfile を次のように変更してください:

# (省略)

group :test do
  gem 'rspec-rails'
  gem 'capybara'
  gem 'factory_girl_rails', '~> 4.2.1'
  gem 'timecop'
end

ターミナルで次のコマンドを実行します。

$ bundle

テストを書く

ログインポイントの正確な仕様は、次の通りです:

  • ユーザーがログインに成功すると、「ログインポイント」としてユーザーに1ポイントが与えられる。
  • ただし、「ログインポイント」はユーザーごとに1日1回しか与えられない。日の区切りは日本時間午前5時とする。
  • また、土曜日にログインすると、通常の「ログインポイント」の他に「土曜ログインボーナス」として2ポイントが与えられる。

今回は第2の仕様のテストを書いて、実装します。

spec/models/customer_spec.rb.authenticate メソッドのエグザンプルグループに次のエグザンプルを挿入します。

  specify '日付変更時刻をまたいで2回ログインすると、ユーザーの保有ポイントが2増える' do
    Time.zone = 'Tokyo'
    date_boundary = Time.zone.local(2013, 1, 1, 5, 0, 0)
    expect {
      Timecop.freeze(date_boundary.advance(seconds: -1))
      Customer.authenticate(customer.username, 'correct_password')
      Timecop.freeze(date_boundary)
      Customer.authenticate(customer.username, 'correct_password')
    }.to change { customer.points }.by(2)
  end

  specify '日付変更時刻をまたがずに2回ログインしても、ユーザーの保有ポイントは1しか増えない' do
    Time.zone = 'Tokyo'
    date_boundary = Time.zone.local(2013, 1, 1, 5, 0, 0)
    expect {
      Timecop.freeze(date_boundary)
      Customer.authenticate(customer.username, 'correct_password')
      Timecop.freeze(date_boundary.advance(hours: 24, seconds: -1))
      Customer.authenticate(customer.username, 'correct_password')
    }.to change { customer.points }.by(1)
  end

まず Time.zone = 'Tokyo' でタイムゾーンを日本時間に設定しています。これは Rails の機能です。次に、適当な日付の午前5時を変数 date_boundary にセットしています。

1番目のエグザンプルでは、日付変更時刻の1秒前に現在時刻を移してログインし、さらに1秒進めてログインしてポイントが2増えることを確認します。2番目のエグザンプルでは日付変更時刻ちょうどにログインした後、翌日の日付変更時刻の1秒前に現在時刻を進めてログインし、ポイントが1しか増えないことを確認します。

現在時刻の移動には Timecop.freeze メソッドを使用します。このメソッドは与えられた時刻を用いて、Time.currentDate.today のスタブを作ります。その結果、テスト対象のシステムには現在時刻が移動したように見える、というわけです。

実装

今回はソースコードの変更箇所を示すだけにします(眠くなってきました…)。

書き換え前のコード:

require 'nkf'
require 'bcrypt'

class Customer < ActiveRecord::Base
  # (省略)

  class << self
    def authenticate(username, password)
      customer = find_by_username(username)
      if customer.try(:password_digest) && BCrypt::Password.new(customer.password_digest) == password
        customer.rewards.create(points: 1)
        customer
      else
        nil
      end
    end
  end
end

書き換え後のコード:

require 'nkf'
require 'bcrypt'

class Customer < ActiveRecord::Base
  # (省略)

  class << self
    def authenticate(username, password)
      customer = find_by_username(username)
      if customer.try(:password_digest) && BCrypt::Password.new(customer.password_digest) == password
        Time.zone = 'Tokyo'
        now = Time.current
        if now.hour < 5
          time0 = now.yesterday.midnight.advance(hours: 5)
          time1 = now.midnight.advance(hours: 5)
        else
          time0 = now.midnight.advance(hours: 5)
          time1 = now.tomorrow.midnight.advance(hours: 5)
        end

        unless customer.rewards.where(created_at: time0...time1).exists?
          customer.rewards.create(points: 1)
        end
        customer
      else
        nil
      end
    end
  end
end

ポイントは where(create_at: time0...time1).exist? というところぐらいでしょうか。time0 から time1 までの間(端点 time1 は含まない)に rewards テーブルに挿入されたレコードの有無を調べています。

これでテストは通ります。しかし、このコードはまだ未完成です。ログイン以外の理由で与えられたポイントもカウントしてしまうからです。この点については、あとで考えることにしましょう。

次回は

実装が不十分である事実よりも大きな問題は、Customer.authenticate メソッドが肥大化してきたことです。このメソッドの本来の役割はユーザーを認証することでした。ポイントを与えるとか与えないとか判断するのは、このメソッドの役割ではないかもしれません。もしかすると、このメソッドを Customer クラスのクラスメソッドとして実装したのは間違いだったのかもしれません。

次回は、そういった観点でリファクタリングによる設計の改善を試みます。では、また。