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

2013/09/02

前回は、ユーザー認証が失敗するシナリオについてテストを通しました。

今回は、ユーザー認証が成功するシナリオについて、一歩一歩実装を進めていきます。

sessions#create アクションの仮実装

現在の sessions#create アクションのコードは次の通りです:

  def create
    if false
      # 未実装
    else
      flash.alert = 'ユーザー名またはパスワードが正しくありません。'
    end
    redirect_to :root
  end

拙著『改訂新版 基礎Ruby on Rails』で Rails プログラミングを学んだ方は、おそらく次のようなコードを書きたくなるのではないでしょうか。

  def create
    if customer = Customer.authenticate(params[:username], params[:password])
      session[:customer_id] = customer.id
    else
      flash.alert = 'ユーザー名またはパスワードが正しくありません。'
    end
    redirect_to :root
  end

いくつかの理由により将来的にこのコードを変更するつもりですが、いったんこれを採用しましょう。

続いて、app/views/top/index.html.erb を次のように変更します:

<h1>Top#index</h1>
<p>Hello World!</p>

<%= render 'login_form' unless session[:customer_id] %>

セッションオブジェクトの :customer_id キーに値がセットされていない場合のみ、ログインフォームを表示するようにしました。

Customer クラスの authenticate メソッドが未実装であることを除けば、これで良さそうです。

で、テストを実行すると、次のような結果となります:

Failures:

  1) ログイン ユーザー認証成功
     Failure/Error: click_button 'ログイン'
     NoMethodError:
       undefined method `authenticate' for #<Class:0xb7fe3f3c>
     # ./app/controllers/sessions_controller.rb:3:in `create'
     # ./spec/features/login_and_logout_spec.rb:9:in `block (3 levels) in <top (required)>'
     # ./spec/features/login_and_logout_spec.rb:6:in `block (2 levels) in <top (required)>'

  2) ログイン ユーザー認証失敗
     Failure/Error: click_button 'ログイン'
     NoMethodError:
       undefined method `authenticate' for #<Class:0xb7fe3f3c>
     # ./app/controllers/sessions_controller.rb:3:in `create'
     # ./spec/features/login_and_logout_spec.rb:19:in `block (3 levels) in <top (required)>'
     # ./spec/features/login_and_logout_spec.rb:16:in `block (2 levels) in <top (required)>'

正しく「authenticate メソッドが未定義」というエラーが出ています。

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

stump

さて、このテストを通すには Customer.authenticate メソッドを実装する必要があります。しかし、本物のメソッドを実装するには、いくつもの工程を踏まなければなりません。ユーザー名やパスワードダイジェストを記録するためのカラムを customers テーブルに追加し、パスワードの保存と照合に関わるコードを実装する必要があります。もちろんテストも書かなくちゃいけません。類似の機能を何度も作ったことがあれば短時間で終わるかもしれませんが、書籍や Google の助けを借りずにすらすら書ける人はそんなに多くないでしょう。

いま私たちが目指していることは、ユーザー認証機能のユーザーインターフェース(要するに「見た目」)に関するテストを通すことです。Outside-In の原則に従って、最も外側の部分を作っています。外側が未完成な間は、なるべく内側の実装に煩わされるべきではありません。この段階でパスワード情報の記録方法や照合手段について考え始めると、思考の流れが遮断されてしまいます。

あるメソッドの実装を後回しにしたい、しかしそのメソッドが存在しないとテストが通らない…。このジレンマから我々を救ってくれるのが、RSpec のメソッドスタブという機能です。通常は、短くスタブと呼びます。

まずは用例を見てください。spec/features/login_and_logout_spec.rb を少しだけ変更しました:

require 'spec_helper'

describe 'ログイン' do
  specify 'ユーザー認証成功' do
    Customer.stub(:authenticate)
    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

  specify 'ユーザー認証失敗' do
    Customer.stub(:authenticate)
    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

5行目と16行目に Customer.stub(:authenticate) というコードが追加されています。これは、Customer クラスに authenticate というスタブを定義します。スタブ(stub)は、本物のメソッドの代わりに一時的に(テストの間だけ)定義されたメソッドを指す用語です。stub メソッドの引数がスタブの名前となります。

英語の "stub" の原義は「(樹木の)切り株」で、他には「(歯の)折れ残り、(鉛筆やたばこの)使い残り、(入場券の)半券」といった意味があります。どういう経緯でコンピュータ用語の "stub" が生まれたのかは、調べがつきませんでした。ご存じの方がいらっしゃいましたら、ぜひ教えてください。

スタブを定義した結果、テストの結果は次のように変化します:

Failures:

  1) ログイン ユーザー認証成功
     Failure/Error: expect(page).not_to have_css('form#new_session')
     Capybara::ExpectationNotMet:
       expected not to find css "form#new_session", found 1 match: "ユーザー名 パスワード"
     # ./spec/features/login_and_logout_spec.rb:12:in `block (2 levels) in <top (required)>'

確かに NoMethodError は出なくなりました。

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

次のステップは、「ユーザー認証成功」のシナリオにおいては、Customer.authenticate メソッドが Customer クラスのインスタンスを返すようにすることです。

それはとても簡単です。spec/features/login_and_logout_spec.rb の5行目を次のように修正してください。

Customer.stub(:authenticate).and_return(Customer.new)

and_return は、スタブの戻り値を指定するメソッドです。

さあ、テストの結果はどうなるでしょうか:

Failures:

  1) ログイン ユーザー認証成功
     Failure/Error: expect(page).not_to have_css('form#new_session')
     Capybara::ExpectationNotMet:
       expected not to find css "form#new_session", found 1 match: "ユーザー名 パスワード"
     # ./spec/features/login_and_logout_spec.rb:12:in `block (2 levels) in <top (required)>'

あれれ、変化がないですね。なぜでしょうか。

FactoryGirl.create

何が起きているのかデバッグコードを埋め込んで調べてみましょう。app/controllers/sessions_controller.rb の3行目に p customer を追加して…

  def create
    if customer = Customer.authenticate(params[:username], params[:password])
      p customer
      session[:customer_id] = customer.id
    else
      flash.alert = 'ユーザー名またはパスワードが正しくありません。'
    end
    redirect_to :root
  end

テストを実行すると、テストの結果が表示される前にターミナルに変数 customer の中身が表示されます。

#<Customer id: nil, family_name: nil, given_name: nil, family_name_kana: nil, given_name_kana: nil, created_at: nil, updated_at: nil>

ああ、分かりましたね。Customer.new で作ったオブジェクトはデータベースに保存されていないので id がセットされていないのです。

FactoryGirl に再登場願いましょう。spec/features/login_and_logout_spec.rb の5行目を次のように修正してください。

Customer.stub(:authenticate).and_return(FactoryGirl.create(:customer))

以前、Customer モデルのバリデーション周りのテストを書いた際には、データベースに保存する必要がなかったので、FactoryGirl.build を使っていましたが、今回は FactoryGirl.create でデータベースに保存します。

これでテストが通ります。

$ bin/rspec spec/features/login_and_logout_spec.rb
..

Finished in 0.16719 seconds
2 examples, 0 failures

Randomized with seed 5639

通ることを確認したら、デバッグコードを削除してください。そして、bin/rspec コマンドを実行して、他のテストもすべて通ることを確認してください。

モック

mockup

以上のように RSpec のスタブ機能を用いると、思考の流れを妨げかねない重い実装作業を一時的にスキップして目下の実装作業を進めることができます。

RSpec 用語では、スタブを持つオブジェクト(クラスを含む)をモック(mock)と呼びます。上記の例では、Customer クラスがモックです。

英語の "mock" は「まがいもの」という意味です。"mockup" または "mock-up" という形で「模型」という意味でも使われます。

RSpec には stub メソッド以外にもモックを作る手段が用意されています。double メソッドです。

このメソッドについては別の機会に紹介しますが、double メソッドによって作られるモックは「純粋なモック(pure mock)」と呼ばれることだけ覚えておきましょう。特にどのクラスのインスタンスであるとも指定せずにモックを作りたいケースがあり、その時に利用します。

stub メソッドで作られたモックは、「純粋なモック」と対比して「部分的なモック(partial mock)」と呼ばれます。基本的には本物なのだけれどもスタブだけが「まがいもの(mock)」である、という意味です。

コンピュータ用語の「モック」には、「テストスタブ」、「テストダブル」、「テストスパイ」、「フェイクオブジェクト」、「ダミーオブジェクト」などの類義語が多数あり、人によって微妙に語法が異なります。最も影響力を持ったのが『XUnit Test Patterns』(2007)を書いたGerard Meszarosによる分類です。Martin Fowler は彼の議論を整理して、最も包括的な用語として「テストダブル」を挙げ、それを「ダミー」、「フェイク」、「スタブ」、「スパイ」、「モック」に分類しています(TestDouble)。彼らの定義によれば、モックは特別な存在です。あるオブジェクトがテストの間にどのようなメッセージ(メソッドコール)を受信するかを調べるために事前に仕様を与えられたテストダブルが「モック」です。モックはテストが完了した時にメッセージ受信の記録と仕様を照らし合わせ、合致しなければ例外を投げます。しかし、RSpec の現在の開発メンバーたちは、「モック」という用語を Meszaros/Fowler の「テストダブル」と同じような包括的・総称的な意味で用いています。そして、メソッドコールの記録をチェックする機能を、モックの必須条件ではなく単なる一つの側面として捉えています。

次回は

次回の「ユーザー認証のテスト(4)」からは、Customer.authenticate メソッドの実装に入ります。では、また。