ポイントシステム(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 がなければ失敗し、あれば成功するようなエグザンプルを書けないこともありませんが、かなり面倒です。労力に見合わないように私には思えます。

このテーマをさらに追求したい方は、以下のページを参照してください:

次回は

次回の掲載は、少し先のことになりそうです。まだ内容は決めていません。少々お待ちください。では、また。