LoginSignup
3
4

More than 5 years have passed since last update.

勉強会BDD偏<3>RSpecとCapybara - ユーザー認証

Last updated at Posted at 2016-03-09

関連記事

ユーザー認証のテスト(1)

customer_specを修正する。詳細内容は次のリンクをご参考にしていただきたい。

宿題

diff --git a/spec/models/customer_spec.rb b/spec/models/customer_spec.rb
index 9c35634..91556e5 100644
--- a/spec/models/customer_spec.rb
+++ b/spec/models/customer_spec.rb
@@ -19,6 +19,12 @@ RSpec.describe Customer, type: :model do
       expect(customer).not_to be_valid
       expect(customer.errors[column_name]).to be_present
     end
+
+    example "#{column_name}に含まれる半角カナは全角カナに交換して受け入れる" do
+      customer[column_name] = 'アイウ'
+      expect(customer).to be_valid
+      expect(customer[column_name]).to eq('アイウ')
+    end
   end

   %w{family_name given_name}.each do |column_name|
@@ -28,7 +34,7 @@ RSpec.describe Customer, type: :model do
     end

     example "#{column_name}は漢字、ひらがな、カタカナ以外の文字を含まない" do
-      ['A', '1', '@'].each do |value|
+      %w(A 1 @).each do |value|
         customer[column_name] = value
         expect(customer).not_to be_valid
         expect(customer[column_name]).to be_present
@@ -37,6 +43,19 @@ RSpec.describe Customer, type: :model do
   end

   %w(family_name_kana given_name_kana).each do |column_name|
+    example "#{column_name}はカタカナを含んでも良い" do
+      customer[column_name] = 'アイウ'
+      expect(customer).to be_valid
+    end
+
+    example "#{column_name}はカタカナ以外の文字を含まない" do
+      %w(亜 A 1 @).each do |value|
+        customer[column_name] = value
+        expect(customer).not_to be_valid
+        expect(customer.errors[column_name]).to be_present
+      end
+    end
+
     example "#{column_name}に含まれるひらがなはカタカナに交換して受け入れる" do
       customer[column_name] = 'あいう'
       expect(customer).to be_valid

diff --git a/app/models/customer.rb b/app/models/customer.rb
index f83df81..ffd50be 100644
--- a/app/models/customer.rb
+++ b/app/models/customer.rb
@@ -5,10 +5,14 @@ class Customer < ActiveRecord::Base
             format: {with: /\A[\p{Han}\p{Hiragana}\p{Katakana}\u30fc]+\z/, allow_blank: true}
   validates :given_name, presence: true, length: {maximum: 40},
             format: {with: /\A[\p{Han}\p{Hiragana}\p{Katakana}\u30fc]+\z/, allow_blank: true}
-  validates :family_name_kana, presence: true, length: {maximum: 40}
-  validates :given_name_kana, presence: true, length: {maximum: 40}
+  validates :family_name_kana, presence: true, length: {maximum: 40},
+            format: {with: /\A\p{Katakana}+\z/, allow_blank: true}
+  validates :given_name_kana, presence: true, length: {maximum: 40},
+            format: {with: /\A\p{Katakana}+\z/, allow_blank: true}

   before_validation do
+    self.family_name = NKF.nkf('-w', family_name) if family_name
+    self.given_name = NKF.nkf('-w', given_name) if given_name
     self.family_name_kana = NKF.nkf('-wh2', family_name_kana) if family_name_kana
     self.given_name_kana = NKF.nkf('-wh2', given_name_kana) if given_name_kana
   end

ユーザー認証機能の仕様

  • ログイン前のユーザーがトップページのURLにアクセスすると、通常のトップページの代わりにログインフォームが表示される。
  • ユーザーがログインフォームに正しいユーザー名(username)とパスワード(password)を入力して「ログイン」ボタンをクリックすると、通常のトップページが表示される。
  • ログインに失敗した場合は、「ユーザー名またはパスワードが正しくありません。」というメッセージとログインフォームが表示される。
  • ユーザーがトップページの「ログアウト」リンクをクリックすると、「本当にログアウトしますか?」という警告ダイアログが表示され、「OK」ボタンをクリックすると、「ログアウトしました」というメッセージとログインフォームが表示される。
  • ユーザーがログインに成功すると、「ログインポイント」としてユーザーに1ポイントが与えられる。
  • ただし、「ログインポイント」はユーザーごとに1日1回しか与えられない。日の区切りは日本時間午前5時とする。
  • また、土曜日にログインすると、通常の「ログインポイント」の他に「土曜ログインボーナス」として2ポイントが与えられる。

Outside-In

  • ビヘイビア駆動開発の原則の1つ
  • 外側(ユーザーインターフェースなど)から内側(ビジネスロジックやデータ構造)に向かって実装を進める。

ログイン機能のテスト

# spec/features/login_and_logout_spec.rb

require 'rails_helper'

describe 'ログイン' do
  example 'ユーザー認証成功' do
    visit root_path
    within('form#new_session') do
      fill_in 'username', with: 'taro'
      fill_in 'password', with: 'correct_password'
      click_button 'ログイン'
    end
    expect(page).not_to have_css('form#new_session')
  end

  example 'ユーザー認証失敗' do
    visit root_path
    within('form#new_session') do
      fill_in 'username', with: 'taro'
      fill_in 'password', with: 'wrong_password'
      click_button 'ログイン'
    end
    expect(page).to have_css('p.alert', text: 'ユーザー名またはパスワードが正しくありません。')
    expect(page).to have_css('form#new_session')
  end
end 

テスト結果

  1 || /home/vagrant/study/oiax/db/schema.rb doesn't exist yet. Run `rake db:migrate` to create it, then try again. If you do not intend to use a database, you
  2 ||
  3 || ログイン
  4 ||   ユーザー認証成功 (FAILED - 1)
  5 ||   ユーザー認証失敗 (FAILED - 2)
  6 ||
  7 || Failures:
  8 ||
  9 spec/features/login_and_logout_spec.rb|5 error|  Failure/Error: visit root_path NameError: undefined local variable or method `root_path' for #<RSpec::Examp
 10 ||
 11 spec/features/login_and_logout_spec.rb|15 error|  Failure/Error: visit root_path NameError: undefined local variable or method `root_path' for #<RSpec::Exam
 12 ||
 13 || Finished in 0.02823 seconds (files took 5.27 seconds to load)
 14 || 2 examples, 2 failures
 15 ||
 16 || Failed examples:
 17 ||
 18 || rspec ./spec/features/login_and_logout_spec.rb:4 # ログイン ユーザー認証成功
 19 || rspec ./spec/features/login_and_logout_spec.rb:14 # ログイン ユーザー認証失敗

ユーザー認証のテスト(2)--アクションの仮実装

ルーティング、そしてアクションの仮実装

  • ユーザー認証を行うアクション、URL、HTTPメソッドの種類を決める。
sessions#create POST /login
  • ルーティング修正
# config/routes.rb
post 'login' => 'sessions#create', as: :login
  • controllerの作成
# app/controllers/sessions_controller.rb
class SessionsController < ApplicationController
  def create
    redirect_to :root
  end
end
  • viewの作成
# app/views/top/_login_form.html.erb
<%= form_tag :login, id: 'new_session' do %>
  <div>
    <%= label_tag 'username', 'ユーザー名' %>
    <%= text_field_tag 'username' %>
  </div>
  <div>
    <%= label_tag 'password', 'パスワード' %>
    <%= password_field_tag 'password' %>
  </div>
  <div>
    <%= submit_tag 'ログイン' %>
  </div>
<% end %>

# bin/erb2slim コマンドで自動変換可能
# usage: bin/erb2slim -d app/views
# erbとslimが一緒に存在する場合、slimよりerbを優先する。
# -dオプションはerbファイルをslimに変換した後、erbファイルを消す。
# app/views/top/_login_form.html.slim
= form_tag :login, id: 'new_session' do
  div
    = label_tag 'username', 'ユーザー名'
    = text_field_tag 'username'
  div
    = label_tag 'password', 'パスワード'
    = password_field_tag 'password'
  div
    = submit_tag 'ログイン'

テスト結果

  • rake db:migrateをしないとschema.rbファイルが生成されないみたい。
  • migrationファイルが存在しなくてもRSpecを実行するたびにこんなエラーメッセージが表示されるのはよくないので、
  • rake db:migrateを実行。
  1 || /home/vagrant/study/oiax/db/schema.rb doesn't exist yet. Run `rake db:migrate` to create it, then try again. If you do not intend to use a database, you
  2 ||
  3 || ログイン
  4 ||   ユーザー認証成功 (FAILED - 1)
  5 ||   ユーザー認証失敗 (FAILED - 2)
  6 ||
  7 || Failures:
  8 ||
  9 spec/features/login_and_logout_spec.rb|11 error|  Failure/Error: expect(page).not_to have_css('form#new_session') expected not to find css "form#new_session
 10 ||
 11 spec/features/login_and_logout_spec.rb|21 error|  Failure/Error: expect(page).to have_css('p.alert', text: 'ユーザー名またはパスワードが正しくありません。')
 12 ||
 13 || Finished in 0.52518 seconds (files took 5.73 seconds to load)
 14 || 2 examples, 2 failures
 15 ||
 16 || Failed examples:
 17 ||
 18 || rspec ./spec/features/login_and_logout_spec.rb:4 # ログイン ユーザー認証成功
 19 || rspec ./spec/features/login_and_logout_spec.rb:14 # ログイン ユーザー認証失敗

ユーザー認証失敗シナリオの実装

  • controllerの修正
# app/controllers/sessions_controller.rb
class SessionsController < ApplicationController
  def create
    if false
      # 未実装
    else
      flash.alert = 'ユーザー名またはパスワードが正しくありません。'
    end
    redirect_to :root
  end
end
  • layoutの修正
    • flashのメッセージを表示する部分テンプレートを作成する。
# app/views/layouts/application.html.slim
doctype html
html
  head
    title
      | Oiax
    = stylesheet_link_tag    'application', media: 'all', 'data-turbolinks-track' => true
    = javascript_include_tag 'application', 'data-turbolinks-track' => true
    = csrf_meta_tags
  body
    = render 'layouts/flashes'
    = yield

# app/views/layouts/_flashes.html.slim
- if flash.alert.present?
  p.alert
    = flash.alert
- if flash.notice.present?
  p.notice
    = flash.notice
  • テスト結果
    • ユーザー認証失敗のテストは通過した。
  1 ||
  2 || ログイン
  3 ||   ユーザー認証成功 (FAILED - 1)
  4 ||   ユーザー認証失敗
  5 ||
  6 || Failures:
  7 ||
  8 spec/features/login_and_logout_spec.rb|11 error|  Failure/Error: expect(page).not_to have_css('form#new_session') expected not to find css "form#new_session
  9 ||
 10 || Finished in 0.40297 seconds (files took 5.27 seconds to load)
 11 || 2 examples, 1 failure
 12 ||
 13 || Failed examples:
 14 ||
 15 || rspec ./spec/features/login_and_logout_spec.rb:4 # ログイン ユーザー認証成功

ユーザー認証のテスト(3)--スタブの活用

アクションの仮実装

  • controllerの修正
  def create
    if customer = Customer.authenticate(params[:username], params[:password])
      session[:customer_id] = customer.id
    else
      flash.alert = 'ユーザー名またはパスワードが正しくありません。'
    end
    redirect_to :root
  end
  • viewの修正
h1 Top#index
p Find me in app/views/top/index.html.slim
p Hello World!
= render 'login_form' unless session[:customer_id]
  • テスト結果
  1 ||
  2 || ログイン
  3 ||   ユーザー認証成功 (FAILED - 1)
  4 ||   ユーザー認証失敗 (FAILED - 2)
  5 ||
  6 || Failures:
  7 ||
  8 app/controllers/sessions_controller.rb|3 error|  Failure/Error: click_button 'ログイン' NoMethodError: undefined method `authenticate' for #<Class:0x007fea3
  9 ||      # ./spec/features/login_and_logout_spec.rb:9:in `block (3 levels) in <top (required)>'
 10 ||      # ./spec/features/login_and_logout_spec.rb:6:in `block (2 levels) in <top (required)>'
 11 ||
 12 app/controllers/sessions_controller.rb|3 error|  Failure/Error: click_button 'ログイン' NoMethodError: undefined method `authenticate' for #<Class:0x007fea3
 13 ||      # ./spec/features/login_and_logout_spec.rb:19:in `block (3 levels) in <top (required)>'
 14 ||      # ./spec/features/login_and_logout_spec.rb:16:in `block (2 levels) in <top (required)>'
 15 ||
 16 || Finished in 2.37 seconds (files took 4.28 seconds to load)
 17 || 2 examples, 2 failures
 18 ||
 19 || Failed examples:
 20 ||
 21 || rspec ./spec/features/login_and_logout_spec.rb:4 # ログイン ユーザー認証成功
 22 || rspec ./spec/features/login_and_logout_spec.rb:14 # ログイン ユーザー認証失敗

メソッドスタブ、あるいはスタブ

  • スタブとは?
    • 本物のメソッドの代わりに一時的に(テストの間だけ)定義されたメソッド。
    • あるメソッドの実装を後回しにしたい、しかしそのメソッドが存在しないとテストが通らないとき使う
  • specの修正
    • stubを追加する。
diff --git a/spec/features/login_and_logout_spec.rb b/spec/features/login_and_logout_spec.rb
index 40db3b0..0762945 100644
--- a/spec/features/login_and_logout_spec.rb
+++ b/spec/features/login_and_logout_spec.rb
@@ -2,6 +2,7 @@ require 'rails_helper'

 describe 'ログイン' do
   example 'ユーザー認証成功' do
+    Customer.stub(:authenticate)
     visit root_path
     within('form#new_session') do
       fill_in 'username', with: 'taro'
@@ -12,6 +13,7 @@ describe 'ログイン' do
   end

   example 'ユーザー認証失敗' do
+    Customer.stub(:authenticate)
     visit root_path
     within('form#new_session') do
       fill_in 'username', with: 'taro'
  • テスト結果
  1 ||
  2 || ログイン
  3 ||   ユーザー認証成功 (FAILED - 1)
  4 ||   ユーザー認証失敗 (FAILED - 2)
  5 ||
  6 || Failures:
  7 ||
  8 spec/features/login_and_logout_spec.rb|5 error|  Failure/Error: Customer.stub(:authenticate) Customer(id: integer, family_name: string, given_name: string,
  9 ||
 10 spec/features/login_and_logout_spec.rb|16 error|  Failure/Error: Customer.stub(:authenticate) Customer(id: integer, family_name: string, given_name: string,
 11 ||
 12 || Deprecation Warnings:
 13 ||
 14 || Using `stub` from rspec-mocks' old `:should` syntax without explicitly enabling the syntax is deprecated. Use the new `:expect` syntax or explicitly enab
 15 ||
 16 ||
 17 || If you need more of the backtrace for any of these deprecations to
 18 || identify where to make the necessary changes, you can configure
 19 || `config.raise_errors_for_deprecations!`, and it will turn the
 20 || deprecation warnings into errors, giving you the full backtrace.
 21 ||
 22 || 1 deprecation warning total
 23 ||
 24 || Finished in 0.0601 seconds (files took 3.08 seconds to load)
 25 || 2 examples, 2 failures
 26 ||
 27 || Failed examples:
 28 ||
 29 || rspec ./spec/features/login_and_logout_spec.rb:4 # ログイン ユーザー認証成功
 30 || rspec ./spec/features/login_and_logout_spec.rb:15 # ログイン ユーザー認証失敗

スタブの戻り値を指定する。

  • モックとスタブの復習
    • モック
    • 本物のオブジェクトのふりをするオブジェクト。
    • テストダブル(test doubles)と呼ばれる。
    • FactoryGirl.build_stubbed()
    • スタブ
    • 本物のオブジェクトのメソッドをオーバーライドし、事前に決められた値を返す。
    • allow(Customer).to receive(:authenticate).with(username, password).and_return(customer)
  • Customer.authenticateはCustomerクラスのインスタンスを返す。
Customer.stub(:authenticate).and_return(Customer.new)
  • テスト結果
    • 手順通りやっていたが、またエラーが発生する。エラーメッセージをもう一度確認。
    • authenticateメソッドが具現されてないと書かれている。
    • stubを利用することになってもメソッドは必要らしい。
[devnote@hooni:~/documents/study/oiax] % bin/rspec spec/features/login_and_logout_spec.rb

ログイン
  ユーザー認証成功 (FAILED - 1)
  ユーザー認証失敗 (FAILED - 2)

Failures:

  1) ログイン ユーザー認証成功
     Failure/Error: Customer.stub(:authenticate).and_return(FactoryGirl.create(:customer))
       Customer(id: integer, family_name: string, given_name: string, family_name_kana: string, given_name_kana: string, created_at: datetime, updated_at: datetime) does not implement: authenticate
     # ./spec/features/login_and_logout_spec.rb:5:in `block (2 levels) in <top (required)>'

  2) ログイン ユーザー認証失敗
     Failure/Error: Customer.stub(:authenticate)
       Customer(id: integer, family_name: string, given_name: string, family_name_kana: string, given_name_kana: string, created_at: datetime, updated_at: datetime) does not implement: authenticate
     # ./spec/features/login_and_logout_spec.rb:16:in `block (2 levels) in <top (required)>'

Deprecation Warnings:

Using `stub` from rspec-mocks' old `:should` syntax without explicitly enabling the syntax is deprecated. Use the new `:expect` syntax or explicitly enable `:should` instead. Called from /Users/devnote/Documents/study/oiax/spec/features/login_and_logout_spec.rb:5:in `block (2 levels) in <top (required)>'.

If you need more of the backtrace for any of these deprecations to
identify where to make the necessary changes, you can configure
`config.raise_errors_for_deprecations!`, and it will turn the
deprecation warnings into errors, giving you the full backtrace.

1 deprecation warning total

Finished in 0.026 seconds (files took 2.79 seconds to load)
2 examples, 2 failures

Failed examples:

rspec ./spec/features/login_and_logout_spec.rb:4 # ログイン ユーザー認証成功
rspec ./spec/features/login_and_logout_spec.rb:15 # ログイン ユーザー認証失敗
  • デバッグ
    • 5行と16行からエラーが発生しているので、5行の前にbinding.pryを入れて確認する。
    • authenticateメソッドを入れてcontinueで続行すると、今度はテストを通った。
binding.pry
Cutomer.stub(:authenticate).and_return(FactoryGirl.create(:customer))

# pry
From: /Users/devnote/Documents/study/oiax/spec/features/login_and_logout_spec.rb @ line 6 :

     1: require 'rails_helper'
     2:
     3: describe 'ログイン' do
     4:   example 'ユーザー認証成功' do
     5:     binding.pry
 =>  6:     Customer.stub(:authenticate).and_return(FactoryGirl.create(:customer))
     7:     visit root_path
     8:     within('form#new_session') do
     9:       fill_in 'username', with: 'taro'
    10:       fill_in 'password', with: 'correct_password'
    11:       click_button 'ログイン'

[1] pry(#<RSpec::ExampleGroups::Nested>)> Customer.stub(:authenticate).and_return(FactoryGirl.create(:customer))
RSpec::Mocks::MockExpectationError: Customer(id: integer, family_name: string, given_name: string, family_name_kana: string, given_name_kana: string, created_at: datetime, updated_at: datetime) does not implement: authenticate
from /Users/devnote/Documents/study/oiax/vendor/bundle/ruby/2.2.0/gems/rspec-support-3.3.0/lib/rspec/support.rb:86:in `block in <module:Support>'
[2] pry(#<RSpec::ExampleGroups::Nested>)> Customer.methods.grep /auth*/
=> [:reflect_on_all_autosave_associations, :autoload, :autoload?]
[3] pry(#<RSpec::ExampleGroups::Nested>)> Customer.class_eval do
[3] pry(#<RSpec::ExampleGroups::Nested>)*   def self.authenticate(username, password); 'test'; end
[3] pry(#<RSpec::ExampleGroups::Nested>)*   end
=> :authenticate
[4] pry(#<RSpec::ExampleGroups::Nested>)> Customer.methods.grep /auth*/
=> [:authenticate, :reflect_on_all_autosave_associations, :autoload, :autoload?]
[4] pry(#<RSpec::ExampleGroups::Nested>)> continue
  • デバッグで分かったこと

    • stubを使ってもテスト対象(ここではcustomerモデル)に仮のメソッドを実装しないといけない。
  • モデルの修正

    • テストを通らせるための仮のメソッドを実装する。
diff --git a/app/models/customer.rb b/app/models/customer.rbindex ffd50be..903eae1 100644
--- a/app/models/customer.rb
+++ b/app/models/customer.rb
@@ -16,4 +16,8 @@ class Customer < ActiveRecord::Base
   self.family_name_kana = NKF.nkf('-wh2', family_name_kana) if family_name_kana
   self.given_name_kana = NKF.nkf('-wh2', given_name_kana) if given_name_kana
 end
+
+  def self.authenticate(username, password)
+    'test'
+  end
  • スペックの修正
    • deprecatedされているstubの代わりにallow〜receiveを使うように修正する。
diff --git a/spec/features/login_and_logout_spec.rb b/spec/features/login_and_logout_spec.rb
index 0762945..4bf0558 100644
--- a/spec/features/login_and_logout_spec.rb
+++ b/spec/features/login_and_logout_spec.rb
@@ -2,7 +2,7 @@ require 'rails_helper'

 describe 'ログイン' do
   example 'ユーザー認証成功' do
-    Customer.stub(:authenticate).and_return(FactoryGirl.create(:customer))
+    allow(Customer).to receive(:authenticate).and_return(FactoryGirl.create(:customer))
     visit root_path
     within('form#new_session') do
       fill_in 'username', with: 'taro'
@@ -13,7 +13,7 @@ describe 'ログイン' do
   end

   example 'ユーザー認証失敗' do
-    Customer.stub(:authenticate)
+    allow(Customer).to receive(:authenticate)
     visit root_path
     within('form#new_session') do
     fill_in 'username', with: 'taro'

モック

  • スタブを持つオブジェクト
  • mock:模造品、偽物
  • mock-up:模型
  • double:pure mock、どのクラスのインスタンスであるとも指定せずにモックを作りたい場合に使用。

ユーザー認証のテスト(4)-- YAGNI

FactoryGirl::Syntax::Methods

  • spec/rails_helper.rbへ設定するだけで毎度FactoryGirl.を使う必要がなくなる。
    • FactoryGirl.create → create
    • FactoryGirl.build → build

exampleのグループ化

Customer.authenticateのテスト

  • specの修正
describe Customer, '.authenticate' do
  let(:customer) {create(:customer, username: 'taro', password: 'correct_password')}

  example 'ユーザー名とパスワードに該当するCustomerオブジェクトを返す' do
    result = Customer.authenticate(customer.username, 'correct_password')
    expect(result).to eq(customer)
  end
end
  • テスト結果
    • usernameが定義されていないというエラーが表示される。
# , + n : cursorがあるところのexampleだけを実行する。
  1 || Run options: include {:locations=>{"./spec/models/customer_spec.rb"=>[67]}}
  2 ||
  3 || Customer.authenticate
  4 ||   ユーザー名とパスワードに該当するCustomerオブジェクトを返す (FAILED - 1)
  5 ||
  6 || Failures:
  7 ||
  8 spec/models/customer_spec.rb|68 error|  Failure/Error: let(:customer) {create(:customer, username: 'taro', password: 'correct_password')} NoMethodError: und
  9 ||      # ./spec/models/customer_spec.rb:71:in `block (2 levels) in <top (required)>'
 10 ||
 11 || Finished in 0.02003 seconds (files took 2.87 seconds to load)
 12 || 1 example, 1 failure
 13 ||
 14 || Failed examples:
 15 ||
 16 || rspec ./spec/models/customer_spec.rb:70 # Customer.authenticate ユーザー名とパスワードに該当するCustomerオブジェクトを返す

usernameカラムの追加

  • 既存のマイグレーションファイルを修正
diff --git a/db/migrate/20150914060814_create_customers.rb b/db/migrate/20150914060814_create_customers.rb
index 4d2d6a9..ab8853b 100644
--- a/db/migrate/20150914060814_create_customers.rb
+++ b/db/migrate/20150914060814_create_customers.rb
@@ -1,6 +1,7 @@
 class CreateCustomers < ActiveRecord::Migration
   def change
     create_table :customers do |t|
+      t.string :username, null: false
       t.string :family_name, null: false
       t.string :given_name, null: false
       t.string :family_name_kana, null: false
  • マイグレーションを実施
    • マイグレーションファイルを修正したら当たり前の手順なので覚えておく。
bin/rake db:migrate:reset
bin/rake db:test:prepare
  • テスト結果
    • passwordが定義されていない。
[devnote@hooni:~/documents/study/oiax] % bin/rspec spec/models/customer_spec.rb:67
Run options: include {:locations=>{"./spec/models/customer_spec.rb"=>[67]}}

Customer.authenticate
  ユーザー名とパスワードに該当するCustomerオブジェクトを返す (FAILED - 1)

Failures:

  1) Customer.authenticate ユーザー名とパスワードに該当するCustomerオブジェクトを返す
     Failure/Error: let(:customer) {create(:customer, username: 'taro', password: 'correct_password')}
     NoMethodError:
       undefined method `password=' for #<Customer:0x007ff650ba1cf8>
     # ./spec/models/customer_spec.rb:68:in `block (2 levels) in <top (required)>'
     # ./spec/models/customer_spec.rb:71:in `block (2 levels) in <top (required)>'

Finished in 0.0231 seconds (files took 2.86 seconds to load)
1 example, 1 failure

Failed examples:

rspec ./spec/models/customer_spec.rb:70 # Customer.authenticate ユーザー名とパスワードに該当するCustomerオブジェクトを返す

password属性の定義

  • modelの修正
    • テストを通らせるためのアクセッサを入れてテストを実行
diff --git a/app/models/customer.rb b/app/models/customer.rb
index 903eae1..606d1f4 100644
--- a/app/models/customer.rb
+++ b/app/models/customer.rb
@@ -1,6 +1,8 @@
 require 'nkf'

 class Customer < ActiveRecord::Base
+  # attr_accessor :password
+
   validates :family_name, presence: true, length: {maximum: 40},
             format: {with: /\A[\p{Han}\p{Hiragana}\p{Katakana}\u30fc]+\z/, allow_blank: true}
   validates :given_name, presence: true, length: {maximum: 40},
  • テスト結果
    • 期待したのはcustomerのオブジェクトだったのに、実際には'test'という文字列を返した。
[devnote@hooni:~/documents/study/oiax] % bin/rspec spec/models/customer_spec.rb:67
Run options: include {:locations=>{"./spec/models/customer_spec.rb"=>[67]}}

Customer.authenticate
  ユーザー名とパスワードに該当するCustomerオブジェクトを返す (FAILED - 1)

Failures:

  1) Customer.authenticate ユーザー名とパスワードに該当するCustomerオブジェクトを返す
     Failure/Error: expect(result).to eq(customer)

       expected: #<Customer id: 2, username: "taro", family_name: "山田", given_name: "太郎", family_name_kana: "ヤマダ", given_name_kana: "タロウ", created_at: "2015-09-23 05:36:45", updated_at: "2015-09-23 05:36:45">
            got: "test"

       (compared using ==)

       Diff:
       @@ -1,10 +1,2 @@
       -#<Customer:0x007f99eb6b7880
       - id: 2,
       - username: "taro",
       - family_name: "山田",
       - given_name: "太郎",
       - family_name_kana: "ヤマダ",
       - given_name_kana: "タロウ",
       - created_at: Wed, 23 Sep 2015 14:36:45 JST +09:00,
       - updated_at: Wed, 23 Sep 2015 14:36:45 JST +09:00>
       +"test"

     # ./spec/models/customer_spec.rb:72:in `block (2 levels) in <top (required)>'

Finished in 0.0577 seconds (files took 2.83 seconds to load)
1 example, 1 failure

Failed examples:

rspec ./spec/models/customer_spec.rb:70 # Customer.authenticate ユーザー名とパスワードに該当するCustomerオブジェクトを返す

Customer.authenticateの仮実装

  • modelの修正
diff --git a/app/models/customer.rb b/app/models/customer.rb
index 903eae1..bc677c0 100644
--- a/app/models/customer.rb
+++ b/app/models/customer.rb
@@ -1,6 +1,8 @@
 require 'nkf'

 class Customer < ActiveRecord::Base
+  attr_accessor :password
+
   validates :family_name, presence: true, length: {maximum: 40},
             format: {with: /\A[\p{Han}\p{Hiragana}\p{Katakana}\u30fc]+\z/, allow_blank: true}
   validates :given_name, presence: true, length: {maximum: 40},
@@ -18,6 +20,6 @@ class Customer < ActiveRecord::Base
   end

   def self.authenticate(username, password)
-    'test'
+    find_by_username(username)
   end
 end
  • テスト結果
[devnote@hooni:~/documents/study/oiax] % bin/rspec spec/models/customer_spec.rb:67
Run options: include {:locations=>{"./spec/models/customer_spec.rb"=>[67]}}

Customer.authenticate
  ユーザー名とパスワードに該当するCustomerオブジェクトを返す

Finished in 0.03701 seconds (files took 2.82 seconds to load)
1 example, 0 failures

FactoryGirlの修正

  • customerモデルの全体テストをすると失敗してしまう。
  • ここで忘れないようにもう一度説明する。
    • specファイルからrspecを実行するとき、vimでマッピングされているキーを使う。
    • ,n → vim上でカーソルが位置されているexampleまたはdescribeだけをrspecで実行する。
    • ,c → vim上で開いているファイルのすべてのexampleを実行する。
    • この勉強会では実習環境を構築してその上で行っているので、実習環境が構築されている場合は自動でできるはず。
    • 実習環境の構築については「勉強会BDD<1>」を参考。
Failures:

  1) ログイン ユーザー認証成功
     Failure/Error: allow(Customer).to receive(:authenticate).and_return(FactoryGirl.create(:customer))
     ActiveRecord::StatementInvalid:
       PG::NotNullViolation: ERROR:  null value in column "username" violates not-null constraint
       DETAIL:  Failing row contains (7, null, 山田, 太郎, ヤマダ, タロウ, 2015-09-23 14:51:06.164856, 2015-09-23 14:51:06.164856).
       : INSERT INTO "customers" ("family_name", "given_name", "family_name_kana", "given_name_kana", "created_at", "updated_at") VALUES ($1, $2, $3, $4, $5, $6) RETURNING "id"
     # ./spec/features/login_and_logout_spec.rb:5:in `block (2 levels) in <top (required)>'
  • スキーマの変更されたので、FactoryGirlの修正も必要だった。
  • specの修正
diff --git a/spec/factories/customers.rb b/spec/factories/customers.rb
index 3119d6b..eaddcd3 100644
--- a/spec/factories/customers.rb
+++ b/spec/factories/customers.rb
@@ -1,5 +1,6 @@
 FactoryGirl.define do
   factory :customer do
+    username 'taro'
     family_name '山田'
     given_name '太郎'
     family_name_kana 'ヤマダ'

YAGNI

  • テストを書き、テストを通すための最小限のコードを実装する。
  • この短いサイクルを繰り返す。
  • YAGNI:You ain't gonna need it.(ソフトウェア開発ではあとで使うだろうと思って作った物の大半は無駄になる。)
  • ソースコードを乱雑し、保守性を低下させる。
  • テスト駆動の短い開発サイクルを繰り返すことで、作りすぎの弊害を避けられる。

ユーザー認証のテスト(5)-- パスワード情報の保存

exampleの追加

  • specの修正
diff --git a/spec/models/customer_spec.rb b/spec/models/customer_spec.rb
index 0304f20..cfb2687 100644
--- a/spec/models/customer_spec.rb
+++ b/spec/models/customer_spec.rb
@@ -71,4 +71,14 @@ describe Customer, '.authenticate' do
     result = Customer.authenticate(customer.username, 'correct_password')
     expect(result).to eq(customer)
   end
+
+  example 'パスワードが一致しない場合nilを返す' do
+    result = Customer.authenticate(customer.username, 'wrong_password')
+    expect(result).to be_nil
+  end
+
+  example '該当するユーザー名が存在しない場合はnilを返す' do
+    result = Customer.authenticate('hanako', 'any_string')
+    expect(result).to be_nil
+  end
end
  • テスト結果
    • 保存形式の詳細は決めずに、テストを通すごとだけを目指して実装したので、失敗した。
  1 || Run options: include {:locations=>{"./spec/models/customer_spec.rb"=>[67]}}
  2 ||
  3 || Customer.authenticate
  4 ||   ユーザー名とパスワードに該当するCustomerオブジェクトを返す
  5 ||   パスワードが一致しない場合nilを返す (FAILED - 1)
  6 ||   該当するユーザー名が存在しない場合はnilを返す
  7 ||
  8 || Failures:
  9 ||
 10 spec/models/customer_spec.rb|77 error|  Failure/Error: expect(result).to be_nil expected: nil got: #<Customer id: 12, username: "taro", family_name: "山田",
 11 ||
 12 || Finished in 0.07173 seconds (files took 2.94 seconds to load)
 13 || 3 examples, 1 failure
 14 ||
 15 || Failed examples:
 16 ||
 17 || rspec ./spec/models/customer_spec.rb:75 # Customer.authenticate パスワードが一致しない場合nilを返す

パスワード情報を保存するためのカラムを追加

  • modelの修正
diff --git a/app/models/customer.rb b/app/models/customer.rb
index bc677c0..ab08f8b 100644
--- a/app/models/customer.rb
+++ b/app/models/customer.rb
@@ -19,7 +19,16 @@ class Customer < ActiveRecord::Base
     self.given_name_kana = NKF.nkf('-wh2', given_name_kana) if given_name_kana
   end

+  before_save do
+    self.password_digest = password
+  end
+
   def self.authenticate(username, password)
-    find_by_username(username)
+    customer = find_by_username(username)
+    if customer && password == customer.password_digest
+      customer
+    else
+      nil
+    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 0.04363 seconds (files took 2.78 seconds to load)
3 examples, 0 failures

パスワード情報の保存に関するexampleを追加

  • specの修正
diff --git a/spec/models/customer_spec.rb b/spec/models/customer_spec.rb
index cfb2687..8192210 100644
--- a/spec/models/customer_spec.rb
+++ b/spec/models/customer_spec.rb
@@ -64,6 +64,22 @@ describe Customer, 'バリデーション' do
   end
 end

+describe Customer, 'password=' do
+  let(:customer) {build(:customer, username: 'taro')}
+
+  example '生成されたpassword_digestは60文字' do
+    customer.password = 'any_string'
+    customer.save!
+    expect(customer.password_digest).not_to be_nil
+  end
+
+  example '空文字を与えるとpassword_digestはnil' do
+    customer.password = ''
+    customer.save!
+    expect(customer.password_digest).to be_nil
+  end
+end
+
 describe Customer, '.authenticate' do
   let(:customer) {create(:customer, username: 'taro', password: 'correct_password')}
  • テスト結果
    • nilを期待していたのに、""を返した。
  1 || Run options: include {:locations=>{"./spec/models/customer_spec.rb"=>[67]}}
  2 ||
  3 || Customer password=
  4 ||   生成されたpassword_digestは60文字
  5 ||   空文字を与えるとpassword_digestはnil (FAILED - 1)
  6 ||
  7 || Failures:
  8 ||
  9 spec/models/customer_spec.rb|79 error|  Failure/Error: expect(customer.password_digest).to be_nil expected: nil got: ""
 10 ||
 11 || Finished in 0.04693 seconds (files took 2.74 seconds to load)
 12 || 2 examples, 1 failure
 13 ||
 14 || Failed examples:
 15 ||
 16 || rspec ./spec/models/customer_spec.rb:76 # Customer password= 空文字を与えるとpassword_digestはnil

bcryptについて

  • ここではパスワード情報を保持するためにbcryptを使う。

  • bcryptとは

    • パスワードを暗号化して保持してくれる。
    • passwordとpassword_confirmationというアトリビュートが追加されてvalidationで使う。
  • bcryptと使うためには

    • password_digestというカラムを追加する。
    • bcrypt-rubyというgemを追加する。

テストを通す

  • gemの追加
diff --git a/Gemfile b/Gemfile
index 0e5b1c6..ee310a2 100644
--- a/Gemfile
+++ b/Gemfile
@@ -8,6 +8,7 @@ gem 'jquery-rails'
 gem 'turbolinks'
 gem 'jbuilder', '~> 2.0'
 gem 'sdoc', '~> 0.4.0', group: :doc
+gem 'bcrypt-ruby'

 # Memcached Client
 gem 'dalli'
  • modelの修正
diff --git a/app/models/customer.rb b/app/models/customer.rb
index bc677c0..9c7a21c 100644
--- a/app/models/customer.rb
+++ b/app/models/customer.rb
@@ -1,4 +1,5 @@
 require 'nkf'
+require 'bcrypt'

 class Customer < ActiveRecord::Base
   attr_accessor :password
@@ -19,7 +20,16 @@ class Customer < ActiveRecord::Base
     self.given_name_kana = NKF.nkf('-wh2', given_name_kana) if given_name_kana
   end

+  before_save do
+    self.password_digest = password if password.present?
+  end
+
   def self.authenticate(username, password)
-    find_by_username(username)
+    customer = find_by_username(username)
+    if customer && BCrypt::Password.new(customer.password_digest) == customer.password
+      customer
+    else
+      nil
+    end
   end
  • テスト結果
[devnote@hooni:~/documents/study/oiax] % bin/rspec spec/models/customer_spec.rb:67
Run options: include {:locations=>{"./spec/models/customer_spec.rb"=>[67]}}

Customer password=
  生成されたpassword_digestは60文字
  空文字を与えるとpassword_digestはnil

Finished in 0.0356 seconds (files took 3.06 seconds to load)
2 examples, 0 failures
  • 補足
    • BCrypt::Password.new(customer.password_digest) == customer.password
    • 一見すると暗号化されているパスワードを復号化して平文のパスワードと比較している。
    • 平文のパスワードを暗号化してDB上の暗号化されているパスワードと一致するのか確認するのが一般的だ。
    • それなのになぜそうだろうか?
    • before_saveのpasswordはすでにbcryptによって暗号化されたパスワードが保持されている。
    • bcryptの中を覗いてみると==(引数)がオーバーライドされていて引数を暗号化し比較している。
    • 勉強会でこんな質問があったので補足で追記する。

テストの穴を埋める。

  • specの修正
diff --git a/spec/models/customer_spec.rb b/spec/models/customer_spec.rb
index 8192210..7297bf7 100644
--- a/spec/models/customer_spec.rb
+++ b/spec/models/customer_spec.rb
@@ -97,4 +97,10 @@ describe Customer, '.authenticate' do
     result = Customer.authenticate('hanako', 'any_string')
     expect(result).to be_nil
   end
+
+  example 'パスワード未設定のユーザーを拒絶する' do
+    customer.update_column(:password_digest, nil)
+    result = Customer.authenticate(customer.username, '')
+    expect(result).to be_nil
+  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オブジェクトを返す (FAILED - 1)
  パスワードが一致しない場合nilを返す (FAILED - 2)
  該当するユーザー名が存在しない場合はnilを返す
  パスワード未設定のユーザーを拒絶する (FAILED - 3)

Failures:

  1) Customer.authenticate ユーザー名とパスワードに該当するCustomerオブジェクトを返す
     Failure/Error: result = Customer.authenticate(customer.username, 'correct_password')
     BCrypt::Errors::InvalidHash:
       invalid hash
     # ./app/models/customer.rb:29:in `new'
     # ./app/models/customer.rb:29:in `authenticate'
     # ./spec/models/customer_spec.rb:87:in `block (2 levels) in <top (required)>'

  2) Customer.authenticate パスワードが一致しない場合nilを返す
     Failure/Error: result = Customer.authenticate(customer.username, 'wrong_password')
     BCrypt::Errors::InvalidHash:
       invalid hash
     # ./app/models/customer.rb:29:in `new'
     # ./app/models/customer.rb:29:in `authenticate'
     # ./spec/models/customer_spec.rb:92:in `block (2 levels) in <top (required)>'

  3) Customer.authenticate パスワード未設定のユーザーを拒絶する
     Failure/Error: result = Customer.authenticate(customer.username, '')
     BCrypt::Errors::InvalidHash:
       invalid hash
     # ./app/models/customer.rb:29:in `new'
     # ./app/models/customer.rb:29:in `authenticate'
     # ./spec/models/customer_spec.rb:103:in `block (2 levels) in <top (required)>'

Finished in 0.0581 seconds (files took 2.91 seconds to load)
4 examples, 3 failures

Failed examples:

rspec ./spec/models/customer_spec.rb:86 # Customer.authenticate ユーザー名とパスワードに該当するCustomerオブジェクトを返す
rspec ./spec/models/customer_spec.rb:91 # Customer.authenticate パスワードが一致しない場合nilを返す
rspec ./spec/models/customer_spec.rb:101 # Customer.authenticate パスワード未設定のユーザーを拒絶する

テストを通す

  • modelの修正
diff --git a/app/models/customer.rb b/app/models/customer.rb
index 9c7a21c..415f442 100644
--- a/app/models/customer.rb
+++ b/app/models/customer.rb
@@ -26,7 +26,7 @@ class Customer < ActiveRecord::Base

   def self.authenticate(username, password)
     customer = find_by_username(username)
-    if customer && BCrypt::Password.new(customer.password_digest) == customer.password
+    if customer.try(:password_digest) && BCrypt::Password.new(customer.password_digest) == password
       customer
     else
       nil
  • テスト結果
    • 手順通りにやってもテストが通らない。
[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オブジェクトを返す (FAILED - 1)
パスワードが一致しない場合nilを返す (FAILED - 2)
該当するユーザー名が存在しない場合はnilを返す
パスワード未設定のユーザーを拒絶する

Failures:

1) Customer.authenticate ユーザー名とパスワードに該当するCustomerオブジェクトを返す
   Failure/Error: result = Customer.authenticate(customer.username, 'correct_password')
   BCrypt::Errors::InvalidHash:
     invalid hash
   # ./app/models/customer.rb:29:in `new'
   # ./app/models/customer.rb:29:in `authenticate'
   # ./spec/models/customer_spec.rb:88:in `block (2 levels) in <top (required)>'

2) Customer.authenticate パスワードが一致しない場合nilを返す
   Failure/Error: result = Customer.authenticate(customer.username, 'wrong_password')
   BCrypt::Errors::InvalidHash:
     invalid hash
   # ./app/models/customer.rb:29:in `new'
   # ./app/models/customer.rb:29:in `authenticate'
   # ./spec/models/customer_spec.rb:93:in `block (2 levels) in <top (required)>'

Finished in 0.05406 seconds (files took 3 seconds to load)
4 examples, 2 failures

Failed examples:

rspec ./spec/models/customer_spec.rb:87 # Customer.authenticate ユーザー名とパスワードに該当するCustomerオブジェクトを返す
rspec ./spec/models/customer_spec.rb:92 # Customer.authenticate パスワードが一致しない場合nilを返す
  • specの修正
    • テストコードに不具合があったので、修正する。
diff --git a/spec/models/customer_spec.rb b/spec/models/customer_spec.rb
index 8192210..ab59045 100644
--- a/spec/models/customer_spec.rb
+++ b/spec/models/customer_spec.rb
@@ -81,7 +81,7 @@ describe Customer, 'password=' do
 end

 describe Customer, '.authenticate' do
-  let(:customer) {create(:customer, username: 'taro', password: 'correct_password')}
+  let(:customer) {create(:customer, username: 'taro', password: BCrypt::Password.create('correct_password'))}
  • 再テスト結果
[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 0.56011 seconds (files took 2.8 seconds to load)
4 examples, 0 failures

ユーザー認証のテスト(6)-- スタブを外す

スタブを外す

  • specの修正
diff --git a/spec/features/login_and_logout_spec.rb b/spec/features/login_and_logout_spec.rb
index 4bf0558..096dedd 100644
--- a/spec/features/login_and_logout_spec.rb
+++ b/spec/features/login_and_logout_spec.rb
@@ -1,8 +1,9 @@
 require 'rails_helper'

 describe 'ログイン' do
+  before { create(:customer, username: 'taro', password: BCrypt::Password.create('correct_password')) }
   example 'ユーザー認証成功' do
-    allow(Customer).to receive(:authenticate).and_return(FactoryGirl.create(:customer))
+    # allow(Customer).to receive(:authenticate).and_return(FactoryGirl.create(:customer))
     visit root_path
     within('form#new_session') do
       fill_in 'username', with: 'taro'
@@ -13,7 +14,7 @@ describe 'ログイン' do
   end

   example 'ユーザー認証失敗' do
-    allow(Customer).to receive(:authenticate)
+    # allow(Customer).to receive(:authenticate)
     visit root_path
     within('form#new_session') do
       fill_in 'username', with: 'taro'
  • テスト結果
[devnote@hooni:~/documents/study/oiax] % bin/rspec spec/features/login_and_logout_spec.rb

ログイン
  ユーザー認証成功
  ユーザー認証失敗

Finished in 0.70919 seconds (files took 2.88 seconds to load)
2 examples, 0 failures

要点

  • テストを通すのに必要最小限のコードを書く。
  • exampleの追加とメソッドの仮実装及びスタブ化を繰り返す。
  • 最終的には複雑な機能を持つクラスとメソッドを完成する。

サンプルの手順(外側 -> 内側 -> 外側)

  • 外側のユーザーインターフェースから実装を始める。(view)
  • 必要な機能のメソッドを実装する。(controller)
  • 情報の保存いう最も内側の機能を実装する。(model)
  • 逆順で遡る。必要な機能のメソッドの仕上げをする。
  • ユーザーインターフェースのテストからスタブを外す。
3
4
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
3
4