ポイントシステム(3) -- timecop
2013/09/30
前回に引き続き、「ポイントシステム」の実装を続けます。今回は、「ログインポイントはユーザーごとに1日1回しか与えられない」という仕様のテストと実装です。
timecop
時間の経過に関連する仕様のテストを書く際に、便利なのが timecop
という Gem パッケージです。あたかも時間旅行をするかのように Time.current
や Date.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.current
や Date.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
クラスのクラスメソッドとして実装したのは間違いだったのかもしれません。
次回は、そういった観点でリファクタリングによる設計の改善を試みます。では、また。