ポイントシステム(8) -- データベーストランザクション
2013/10/06
「ポイントシステム(1) -- 数値の変化をテストする」から8回に渡って作ってきたポイントシステムは今回でひとまず完成です。
土曜ログインボーナス
ポイントシステムでまだ実装されていないのは、「土曜ログインボーナス」の仕組みです。仕様書にはこう書かれています:
また、土曜日にログインすると、通常の「ログインポイント」の他に「土曜ログインボーナス」として2ポイントが与えられる。
日の区切りは日本時間午前5時なので、土曜日とは土曜日の午前5時から日曜日の午前5時まで、と考えることにしましょう。ただし、終端(日曜日の午前5時ちょうど)は土曜日に含まれません。
私が書いたテストコードは次の通りです:
require 'spec_helper' describe RewardManager, '#grant_login_points' do let(:customer) { create(:customer) } let(:date_boundary) { Time.zone.local(2013, 1, 1, 5, 0, 0) } before { Time.zone = 'Tokyo' } specify '土曜日の午前5時直前にログインすると、ユーザーの保有ポイントが1増える' do Timecop.freeze(Time.zone.local(2013, 1, 5, 4, 59, 59)) expect { RewardManager.new(customer).grant_login_points }.to change { customer.points }.by(1) end specify '土曜日の午前5時にログインすると、ユーザーの保有ポイントが3増える' do Timecop.freeze(Time.zone.local(2013, 1, 5, 5, 0, 0)) expect { RewardManager.new(customer).grant_login_points }.to change { customer.points }.by(3) end specify '日曜日の午前5時直前にログインすると、ユーザーの保有ポイントが3増える' do Timecop.freeze(Time.zone.local(2013, 1, 6, 4, 59, 59)) expect { RewardManager.new(customer).grant_login_points }.to change { customer.points }.by(3) end specify '日曜日の午前5時にログインすると、ユーザーの保有ポイントが1増える' do Timecop.freeze(Time.zone.local(2013, 1, 6, 5, 0, 0)) expect { RewardManager.new(customer).grant_login_points }.to change { customer.points }.by(1) end specify '日付変更時刻をまたいで2回ログインすると、Rewardが2回与えられる' do expect { Timecop.freeze(date_boundary.advance(seconds: -1)) RewardManager.new(customer).grant_login_points Timecop.freeze(date_boundary) RewardManager.new(customer).grant_login_points }.to change { customer.rewards.size }.by(2) end specify '日付変更時刻をまたがずに2回ログインしても、Rewardが1回しか与えられない' do expect { Timecop.freeze(date_boundary) RewardManager.new(customer).grant_login_points Timecop.freeze(date_boundary.advance(hours: 24, seconds: -1)) RewardManager.new(customer).grant_login_points }.to change { customer.rewards.size }.by(1) end end
日付変更時刻である午前5時の直前と午前5時ちょうどについて、丁寧にテストを書きました。また、以前書いた「2回ログイン」に関するテストは、保有ポイントの変化ではなく Reward オブジェクトの個数の変化に変えました。
実装はあっけないほど簡単です:
def grant_login_points 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) customer.rewards.create(points: 2) if time0.wday == 6 end end
下から3行目が追加されています。日付または時刻オブジェクトの wday
メソッドは曜日を表す整数値を返します。日曜日が 0 で、土曜日は 6 です。変数 time0
には、ログインポイントの計算に使用する日付(午前5時より前なら前日)の開始時刻(午前5時)なので、time0.wday == 6
ならば「土曜ログインボーナス」を付与してよいということになります。
データベーストランザクション
実は、先ほどの実装には不備があります。それは、データベーストランザクションのことが考慮されていない、ということです。土曜日にログインした顧客に対して、通常のログインポイント 1 を付与した直後に、データベース管理システムが何らかの理由で利用できなくなると、その顧客には「土曜ログインボーナス」が与えられません。ログインも失敗したことになります。そして、その顧客が改めてログインし直したとしても、すでにログインポイント 1 を付与した記録が残っているので、もはや「土曜ログインボーナス」が付与されないということになるのです。
この事態を避けるには、次のようにポイント付与処理全体を ActiveRecord::Base.transaction do ... end
で囲みます:
def grant_login_points 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 ActiveRecord::Base.transaction do unless customer.rewards.where(created_at: time0...time1).exists? customer.rewards.create(points: 1) customer.rewards.create(points: 2) if time0.wday == 6 end end end
さて、この修正を受けてテストの方は書き換えなくてもいいのでしょうか。トランザクション処理が行われていることを確かめなくてもいいのでしょうか。
結論から言えば、このケースではテストを追加する必要はない、と私は考えます。
というのは、あるメソッドの中でデータベーストランザクションが使用されているかどうかを確認するのは、意外に難しいからです。RewardManager#grant_login_points
メソッドの中に ActiveRecord::Base.transaction do ... end
がなければ失敗し、あれば成功するようなエグザンプルを書けないこともありませんが、かなり面倒です。労力に見合わないように私には思えます。
このテーマをさらに追求したい方は、以下のページを参照してください:
- http://stackoverflow.com/questions/10511236/how-to-test-that-a-certain-function-uses-a-transaction-in-rails-and-rspec-2
- http://strix01.blogspot.jp/2012/07/rails-rspecdb.html
次回は
次回の掲載は、少し先のことになりそうです。まだ内容は決めていません。少々お待ちください。では、また。