ポイントシステム(1) -- 数値の変化をテストする

2013/09/19

前回でユーザー認証の仕組みがいちおう完成したので、今回からしばらくの間、「ポイントシステム」の仕様をRSpecで記述し、実装していくことにします。

仕様のおさらい

ここで言う「ポイントシステム」とは、いろんなWebサイトで行われている例の仕組みです。つまり、ユーザーはサイト内での行動に応じて「ポイント」と呼ばれる疑似通貨を取得し、貯めたポイントで商品やサービスと交換できるというものです。この連載ではポイント交換のところまで作り込むつもりはないのですが、将来的にそういう仕組みを実装することを念頭に置いて設計します。また、顧客自身およびWebサイトの運営者がポイント増減の記録(発生日時、額、理由、など)を閲覧できる仕組みを(本連載では作らないけれども)将来的に実装するつもりであれこれ備えておきましょう。

さて、ポイント付与に関する仕様はユーザー認証のテスト(1) -- Outside-Inで書きましたが、あらためて載せておきます:

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

今回は1番目の仕様のテストを書いて終わりにします。

数値の変化に関するテストの書き方

1番目の仕様を言い換えると、「ユーザーがログインに成功すると、ユーザーの保有ポイントが1増える」ということです。仮に Customer#points メソッドがユーザーの保有ポイント(整数)で返すとすれば、テストコードは次のようになります。

describe Customer, '.authenticate' do
  let(:customer) { create(:customer, username: 'taro', password: 'correct_password') }

  # (省略)

  specify 'ログインに成功すると、ユーザーの保有ポイントが1増える' do
    expect {
      Customer.authenticate(customer.username, 'correct_password')
    }.to change { customer.points }.by(1)
  end
end

少し複雑なので、記号を使ってエグザンプルの中を書き換えると次のように整理できます。

expect { X }.to change { Y }.by(Z)

X が数値変化の原因となるコードです。Y は変化を調べたい数値を返すメソッドです。そして Z が数値の変化量です。X には「ユーザーがログインに成功する」を意味するコードを、Y には Customer#points メソッドの呼び出しを、そして Z には整数値 1 を書き入れます。

まずはこの状態でテストを実行して、シンタックスエラーなどが起きないことを確認してください。

Failures:

  1) Customer.authenticate ログインに成功すると、ユーザーの保有ポイントが1増える
     Failure/Error: }.to change { customer.points }.by(1)
     NoMethodError:
       undefined method `points' for #<Customer:0xba6b6a10>
     # ./spec/models/customer_spec.rb:111:in `block (3 levels) in <top (required)>'
     # ./spec/models/customer_spec.rb:109:in `block (2 levels) in <top (required)>'

メソッドの仮実装

エグザンプルが正しく書けていることを示すため、Customer#points メソッドを仮実装してテストが正しく失敗することを確認しましょう。

describe Customer, '.authenticate' do
  let(:customer) { create(:customer, username: 'taro', password: 'correct_password') }

  # (省略)

  specify 'ログインに成功すると、ユーザーの保有ポイントが1増える' do
    customer.stub(:points).and_return(0)
    expect {
      Customer.authenticate(customer.username, 'correct_password')
    }.to change { customer.points }.by(1)
  end
end

stub メソッドについては、ユーザー認証のテスト(3)で説明しました。and_return で指定した値(0)を常に返すメソッドとして points メソッドを仮に(テストの間だけ)実装してくれます。

で、結果は次の通り:

Failures:

  1) Customer.authenticate ログインに成功すると、ユーザーの保有ポイントが1増える
     Failure/Error: expect {
       result should have been changed by 1, but was changed by 0
     # ./spec/models/customer_spec.rb:110:in `block (2 levels) in <top (required)>'

pending メソッド

Customer#points メソッドはどういう風に実装したらいいでしょうか。データベースのスキーマを変更しなくてはならないので少し手間がかかりそうです。こんな場合は、pending メソッドを使って「ペンディング」にしておきます。

describe Customer, '.authenticate' do
  let(:customer) { create(:customer, username: 'taro', password: 'correct_password') }

  # (省略)

  specify 'ログインに成功すると、ユーザーの保有ポイントが1増える' do
    pending('Customer#pointsが未実装')
    customer.stub(:points).and_return(0)
    expect {
      Customer.authenticate(customer.username, 'correct_password')
    }.to change { customer.points }.by(1)
  end
end

RSpec の pending メソッドには引数としてペンディングにする理由を説明する文字列を指定します。この引数は省略できます。

テストの実行結果は次のようになります:

$ bin/rspec spec/models/customer_spec.rb
*.............................

Pending:
  Customer.authenticate ログインに成功すると、ユーザーの保有ポイントが1増える
    # Customer#pointsが未実装
    # ./spec/models/customer_spec.rb:108

Finished in 1.08 seconds
30 examples, 0 failures, 1 pending

Randomized with seed 7827

テストの成功を示すドット . の部分が、アスタリスク * に変わっています。また、下から3行目にも 1 pending と表示されています。

テストが失敗したままでは気持ち悪いですが、これなら Git や Mercurial のリポジトリにコミットしても構わないでしょう。

次回は

次回は、Customer#points メソッドを実装します。では、また。