勉強会BDD偏＜４＞RSpecとCapybara - ポイントシステム


## 関連記事

* [勉強会BDD＜１＞RSpecとCapybaraースタート](http://qiita.com/park-jh/items/a6327a96e771857bc78c)
* [勉強会BDD＜２＞RSpecとCapybaraーモデル](http://qiita.com/park-jh/items/07a9367f0bd94abc528b)
* [勉強会BDD＜３＞RSpecとCapybaraーユーザー認証](http://qiita.com/park-jh/items/ed64de86ad9aaa6a3635)
* 勉強会BDD＜４＞RSpecとCapybaraーポイントシステム

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

* http://www.oiax.jp/rails/rspec_capybara_primer/reward_system1.html

### 仕様のおさらい

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

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

* 仕様：ユーザーがログインに成功すると、ユーザーの保有ポイントが１増える。
* Customer#points
* specの修正

```ruby
# spec/models/customer.rbの.authenticateの中

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

# , + n
```

* 説明

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

# X：数値変化の原因になるコード
# Y：変化を調べたい数値を返すメソッド
# Z：数値の変化量
```

* テスト

```
  1 || Run options: include {:locations=>{"./spec/models/customer_spec.rb"=>[107]}mple 'ログインに成功すると、ユーザーの保有ポイントが１増える'
  2 ||
  3 || Customer.authenticate
  4 ||   ログインに成功すると、ユーザーの保有ポイントが１増える (FAILED - 1)
  5 ||
  6 || Failures:
  7 ||
  8 spec/models/customer_spec.rb|110 error|  Failure/Error: }.to change { customer.points }.by(1) NoMethodError: undefined method `points' for #<Customer:0x007f
  9 ||      # ./spec/models/customer_spec.rb:108:in `block (2 levels) in <top (required)>'
 10 ||
 11 || Finished in 0.15944 seconds (files took 2.81 seconds to load)
 12 || 1 example, 1 failure
 13 ||
 14 || Failed examples:
 15 ||
 16 || rspec ./spec/models/customer_spec.rb:107 # Customer.authenticate ログインに成功すると、ユーザーの保有ポイントが１増える
```

### メソッドの仮実装

* specの修正

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

* テスト

```
  1 || Run options: include {:locations=>{"./spec/models/customer_spec.rb"=>[83]}}
  2 ||
  3 || Customer.authenticate
  4 ||   ユーザー名とパスワードに該当するCustomerオブジェクトを返す
  5 ||   パスワードが一致しない場合nilを返す
  6 ||   該当するユーザー名が存在しない場合はnilを返す
  7 ||   パスワード未設定のユーザーを拒絶する
  8 ||   ログインに成功すると、ユーザーの保有ポイントが１増える (FAILED - 1)
  9 ||
 10 || Failures:
 11 ||
 12 spec/models/customer_spec.rb|110 error|  Failure/Error: expect { expected result to have changed by 1, but was changed by 0
 13 ||
 14 || Finished in 0.7688 seconds (files took 2.7 seconds to load)
 15 || 5 examples, 1 failure
 16 ||
 17 || Failed examples:
 18 ||
 19 || rspec ./spec/models/customer_spec.rb:107 # Customer.authenticate ログインに成功すると、ユーザーの保有ポイントが１増える
```

* ペンディング

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

## ポイントシステム(2) -- ログインポイントの付与

### Rewardモデル

* modelの生成

```sh
% bin/rails g model reward
```

* migrationファイルの修正及び適用

```ruby
# db/migrate/20150924070150_create_rewards.rb

class CreateRewards < ActiveRecord::Migration
  def change
    create_table :rewards do |t|
      t.references :customer
      t.integer :points

      t.timestamps null: false
    end
  end
end

# run migration
bin/rake db:migrate
bin/rake db:test:prepare
```

* modelの修正

```ruby
# app/models/customer.rb
class Customer < ActiveRecord::Base
  has_many :rewards
... snip ...

# app/models/reward.rb
class Reward < ActiveRecord::Base
  belongs_to :customer
end
```

* 不要なspecの削除

```sh
% rm spec/models/reward_spec.rb
```

### Customer#pointsメソッドのテストと実装

* specの修正

```ruby
# spec/models/customer_spec.rb

describe Customer, '#points' do
  let(:customer) { create(:customer, username: 'taro') }

  example '関連付けられたRewardのpointsを合計して返す' do
    customer.rewards.create(points: 1)
    customer.rewards.create(points: 5)
    customer.rewards.create(points: -2)

    expect(customer.points).to eq(4)
  end
end
```

* テスト

```
  1 || Run options: include {:locations=>{"./spec/models/customer_spec.rb"=>[116]}}
  2 ||
  3 || Customer#points
  4 ||   関連付けられたRewardのpointsを合計して返す (FAILED - 1)
  5 ||
  6 || Failures:
  7 ||
  8 spec/models/customer_spec.rb|124 error|  Failure/Error: expect(customer.points).to eq(4) NoMethodError: undefined method `points' for #<Customer:0x007f9a113
  9 ||
 10 || Finished in 0.08741 seconds (files took 2.9 seconds to load)
 11 || 1 example, 1 failure
 12 ||
 13 || Failed examples:
 14 ||
 15 || rspec ./spec/models/customer_spec.rb:119 # Customer#points 関連付けられたRewardのpointsを合計して返す
 16 ||
```

* modelの修正

```ruby
class Customer < ActiveRecord::Base
... snip ...
  def points
    rewards.sum(:points)
  end

def self.authenticate(username, password)
... snip ...
```

* テスト

```sh
[devnote@hooni:~/documents/study/oiax] % bin/rspec spec/models/customer_spec.rb:116
Run options: include {:locations=>{"./spec/models/customer_spec.rb"=>[116]}}

Customer#points
  関連付けられたRewardのpointsを合計して返す

Finished in 0.12071 seconds (files took 5.2 seconds to load)
1 example, 0 failures
```

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

### timecop

* 時間の経過に関連する仕様のテストで使う。
* Time.currentやDate.todayが返す値を一時的に変更してくれる。
* Timecop.freeze：現在時刻の移動

### 仕様

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

```diff
diff --git a/spec/models/customer_spec.rb b/spec/models/customer_spec.rb
index 6b753c1..e4b767d 100644
--- a/spec/models/customer_spec.rb
+++ b/spec/models/customer_spec.rb
@@ -111,6 +111,29 @@ describe Customer, '.authenticate' do
       Customer.authenticate(customer.username, 'correct_password')
     }.to change { customer.points }.by(1)
   end
+
+  example '日付変更時刻をまたいで２回ログインすると、ユーザーの保有ポイントが２増える' do
+    Time.zone = 'Tokyo'
+    date_boundary = Time.zone.local(2015, 9, 27, 5, 0, 0)
+    expect {
+      Timecop.freeze(date_boundary.advance(seconds: -1))
+      binding.pry
+      Customer.authenticate(customer.username, 'correct_password')
+      Timecop.freeze(date_boundary)
+      Customer.authenticate(customer.username, 'correct_password')
+    }.to change { customer.points }.by(2)
+  end
+
+  example '日付変更時刻をまたがずに２回ログインしても、ユーザーの保有ポイントは１しか増えない' do
+    Time.zone = 'Tokyo'
+    date_boundary = Time.zone.local(2015, 9, 27, 5, 0, 0)
+    expect {
+      Timecop.freeze(date_boundary)
+      Customer.authenticate(customer.username, 'correct_password')
+      Timecop.freeze(date_boundary)
+      Customer.authenticate(customer.username, 'correct_password')
+    }.to change { customer.points }.by(1)
+  end
 end

 describe Customer, '#points' do
```

* テスト

```
  1 || Run options: include {:locations=>{"./spec/models/customer_spec.rb"=>[127]}}
  2 ||
  3 || Customer.authenticate
  4 ||   日付変更時刻をまたがずに２回ログインしても、ユーザーの保有ポイントは１しか増えない (FAILED - 1)
  5 ||
  6 || Failures:
  7 ||
  8 spec/models/customer_spec.rb|130 error|  Failure/Error: expect { expected result to have changed by 1, but was changed by 2
  9 ||
 10 || Finished in 0.33854 seconds (files took 2.73 seconds to load)
 11 || 1 example, 1 failure
 12 ||
 13 || Failed examples:
 14 ||
 15 || rspec ./spec/models/customer_spec.rb:127 # Customer.authenticate 日付変更時刻をまたがずに２回ログインしても、ユーザーの保有ポイントは１しか増えない
```

* 実装

```diff
diff --git a/app/models/customer.rb b/app/models/customer.rb
index b6c4766..5cb51ba 100644
--- a/app/models/customer.rb
+++ b/app/models/customer.rb
@@ -33,7 +33,19 @@ class Customer < ActiveRecord::Base
   def self.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)
+      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
```

## ポイントシステム(4) -- サービスオブジェクト

### プライベートメソッドとして分離

```diff
diff --git a/app/models/customer.rb b/app/models/customer.rb
index 5cb51ba..a3c9738 100644
--- a/app/models/customer.rb
+++ b/app/models/customer.rb
@@ -33,22 +33,27 @@ class Customer < ActiveRecord::Base
   def self.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
+      grant_login_points_to(customer)
       customer
     else
       nil
     end
   end
+
+  private
+  def grant_login_points_to(customer)
+    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
+  end
end
```

### サービスオブジェクトを利用

* serviceの生成

```sh
% mkdir app/services
% vi app/services/reward_service.rb
```

* ソースコードの修正

```diff
[devnote@hooni:~/documents/study/oiax] % git --no-pager diff --cached
diff --git a/app/models/customer.rb b/app/models/customer.rb
index 5cb51ba..cbef2b9 100644
--- a/app/models/customer.rb
+++ b/app/models/customer.rb
@@ -33,19 +33,7 @@ class Customer < ActiveRecord::Base
   def self.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
+      RewardService.new(customer).grant_login_points
       customer
     else
       nil
diff --git a/app/services/reward_service.rb b/app/services/reward_service.rb
new file mode 100644
index 0000000..a73e450
--- /dev/null
+++ b/app/services/reward_service.rb
@@ -0,0 +1,23 @@
+class RewardService
+  attr_accessor :customer
+
+  def initialize(customer)
+    self.customer = customer
+  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
+
+    unless customer.rewards.where(created_at: time0...time1).exists?
+      customer.rewards.create(points: 1)
+    end
+  end
+end
```

* テスト

```
devnote@hooni:~/documents/study/oiax] % bin/rspec spec/models/customer_spec.rb:83
Run options: include {:locations=>{"./spec/models/customer_spec.rb"=>[83]}}

Customer.authenticate
  ユーザー名とパスワードに該当するCustomerオブジェクトを返す
  パスワードが一致しない場合nilを返す
  該当するユーザー名が存在しない場合はnilを返す
  パスワード未設定のユーザーを拒絶する
  ログインに成功すると、ユーザーの保有ポイントが１増える
  日付変更時刻をまたいで２回ログインすると、ユーザーの保有ポイントが２増える
  日付変更時刻をまたがずに２回ログインしても、ユーザーの保有ポイントは１しか増えない

Finished in 1.34 seconds (files took 2.7 seconds to load)
7 examples, 0 failures

```

* specの修正

```diff
[devnote@hooni:~/documents/study/oiax] % git --no-pager diff --cached
diff --git a/spec/models/customer_spec.rb b/spec/models/customer_spec.rb
index d85b30a..a1dfa80 100644
--- a/spec/models/customer_spec.rb
+++ b/spec/models/customer_spec.rb
@@ -111,28 +111,6 @@ describe Customer, '.authenticate' do
       Customer.authenticate(customer.username, 'correct_password')
     }.to change { customer.points }.by(1)
   end
-
-  example '日付変更時刻をまたいで２回ログインすると、ユーザーの保有ポイントが２増える' do
-    Time.zone = 'Tokyo'
-    date_boundary = Time.zone.local(2015, 9, 27, 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
-
-  example '日付変更時刻をまたがずに２回ログインしても、ユーザーの保有ポイントは１しか増えない' do
-    Time.zone = 'Tokyo'
-    date_boundary = Time.zone.local(2015, 9, 27, 5, 0, 0)
-    expect {
-      Timecop.freeze(date_boundary)
-      Customer.authenticate(customer.username, 'correct_password')
-      Timecop.freeze(date_boundary)
-      Customer.authenticate(customer.username, 'correct_password')
-    }.to change { customer.points }.by(1)
-  end
 end

 describe Customer, '#points' do
diff --git a/spec/services/reward_service_spec.rb b/spec/services/reward_service_spec.rb
new file mode 100644
index 0000000..489c695
--- /dev/null
+++ b/spec/services/reward_service_spec.rb
@@ -0,0 +1,28 @@
+require 'rails_helper'
+
+describe RewardService, '#grant_login_points' do
+  let(:customer) { create(:customer) }
+
+  example '日付変更時刻をまたいで２回ログインすると、ユーザーの保有ポイントが２増える' do
+    Time.zone = 'Tokyo'
+    date_boundary = Time.zone.local(2015, 9, 27, 5, 0, 0)
+    expect {
+      Timecop.freeze(date_boundary.advance(seconds: -1))
+      RewardService.new(customer).grant_login_points
+      Timecop.freeze(date_boundary)
+      RewardService.new(customer).grant_login_points
+    }.to change { customer.points }.by(2)
+  end
+
+  example '日付変更時刻をまたがずに２回ログインしても、ユーザーの保有ポイントは１しか増えない' do
+    Time.zone = 'Tokyo'
+    date_boundary = Time.zone.local(2015, 9, 27, 5, 0, 0)
+    expect {
+      Timecop.freeze(date_boundary)
+      RewardService.new(customer).grant_login_points
+      Timecop.freeze(date_boundary)
+      RewardService.new(customer).grant_login_points
+    }.to change { customer.points }.by(1)
+  end
+end
```

## ポイントシステム(5) -- サービスオブジェクト

* ReceptionService

```diff
[devnote@hooni:~/documents/study/oiax] % git --no-pager diff --cached
diff --git a/app/controllers/sessions_controller.rb b/app/controllers/sessions_controller.rb
index 1374715..13ba6ce 100644
--- a/app/controllers/sessions_controller.rb
+++ b/app/controllers/sessions_controller.rb
@@ -1,6 +1,6 @@
 class SessionsController < ApplicationController
   def create
-    if customer = Customer.authenticate(params[:username], params[:password])
+    if customer = ReceptionService.new(params[:username], params[:password]).sign_in
       session[:customer_id] = customer.id
     else
       flash.alert = 'ユーザー名またはパスワードが正しくありません。'
diff --git a/app/models/customer.rb b/app/models/customer.rb
index cbef2b9..f7809f2 100644
--- a/app/models/customer.rb
+++ b/app/models/customer.rb
@@ -29,14 +29,4 @@ class Customer < ActiveRecord::Base
   def points
     rewards.sum(:points)
   end
-
-  def self.authenticate(username, password)
-    customer = find_by_username(username)
-    if customer.try(:password_digest) && BCrypt::Password.new(customer.password_digest) == password
-      RewardService.new(customer).grant_login_points
-      customer
-    else
-      nil
-    end
-  end
 end
diff --git a/app/services/reception_desk_service.rb b/app/services/reception_desk_service.rb
new file mode 100644
index 0000000..66547b3
--- /dev/null
+++ b/app/services/reception_desk_service.rb
@@ -0,0 +1,18 @@
+class ReceptionService
+  attr_accessor :username, :password
+
+  def initialize(username, password)
+    self.username = username
+    self.password = password
+  end
+
+  def sign_in
+    customer = Customer.find_by_username(username)
+    if customer.try(:password_digest) && BCrypt::Password.new(customer.password_digest) == password
+      RewardService.new(customer).grant_login_points
+      customer
+    else
+      nil
+    end
+  end
+end
diff --git a/spec/models/customer_spec.rb b/spec/models/customer_spec.rb
index d85b30a..57db71e 100644
--- a/spec/models/customer_spec.rb
+++ b/spec/models/customer_spec.rb
@@ -80,7 +80,7 @@ describe Customer, 'password=' do
   end
 end

-describe Customer, '.authenticate' do
+xdescribe Customer, '.authenticate' do
   let(:customer) {create(:customer, username: 'taro', password: BCrypt::Password.create('correct_password'))}

   example 'ユーザー名とパスワードに該当するCustomerオブジェクトを返す' do
diff --git a/spec/services/reception_desk_service_spec.rb b/spec/services/reception_desk_service_spec.rb
new file mode 100644
index 0000000..f79ba10
--- /dev/null
+++ b/spec/services/reception_desk_service_spec.rb
@@ -0,0 +1,34 @@
+require 'rails_helper'
+
+describe ReceptionService, '#sign_in' do
+  let(:customer) {create(:customer, username: 'taro', password: BCrypt::Password.create('correct_password'))}
+
+  example 'ユーザー名とパスワードに該当するCustomerオブジェクトを返す' do
+    result = ReceptionService.new(customer.username, 'correct_password').sign_in
+    expect(result).to eq(customer)
+  end
+
+  example 'パスワードが一致しない場合nilを返す' do
+    result = ReceptionService.new(customer.username, 'wrong_password').sign_in
+    expect(result).to be_nil
+  end
+
+  example '該当するユーザー名が存在しない場合はnilを返す' do
+    result = ReceptionService.new('hanako', 'any_string').sign_in
+    expect(result).to be_nil
+  end
+
+  example 'パスワード未設定のユーザーを拒絶する' do
+    customer.update_column(:password_digest, nil)
+    result = ReceptionService.new(customer.username, '').sign_in
+    expect(result).to be_nil
+  end
+
+  example 'ログインに成功すると、ユーザーの保有ポイントが１増える' do
+    # pending 'Customer#pointsが未実装'
+    # allow(customer).to receive(:points).and_return(0)
+    expect {
+      ReceptionService.new(customer.username, 'correct_password').sign_in
+    }.to change { customer.points }.by(1)
+  end
+end
```