give IT a try

プログラミング、リモートワーク、田舎暮らし、音楽、etc.

Everyday Rails(Rails 4.1版) 第9章「モックとスタブ」に関するQ&A(とお詫び)

はじめに

Everyday RailsのQ&Aページで本の内容に関する質問をいただきました。
回答を書いているとかなり長くなってしまったのと、他の読者のみなさんにも知っておいてもらいたい内容なので、このブログに回答を書きます。

質問

Everyday RailsのQ&Aページより)

本とGithubのコードが異なります。
テストの実行結果が変わらないことと私の理解不足で成否が判断できないのですが本の方が正しいのでしょうか?

https://github.com/everydayrails/rails-4-1-rspec-3-0/blob/master/spec/controllers/contacts_controller_spec.rb

コードでは58行から全部「Contact」が大文字

allow(Contact).to receive(:persisted?).and_return(true)
allow(Contact).to \
  receive(:order).with('lastname, firstname').and_return([contact])
allow(Contact).to \
  receive(:find).with(contact.id.to_s).and_return(contact)
allow(Contact).to receive(:save).and_return(true)

本では小文字「contact」もある

allow(contact).to receive(:persisted?).and_return(true)
allow(Contact).to \
  receive(:order).with('lastname, firstname').and_return([contact])
allow(Contact).to \
  receive(:find).with(contact.id.to_s).and_return(contact)
allow(contact).to receive(:save).and_return(true)

回答

最初に結論からいうと、実はallowで始まる4行のうち、ここで必要となるコードは以下の行だけです。

allow(Contact).to \
  receive(:find).with(contact.id.to_s).and_return(contact)

つまり、本のコードもGitHubのコードも間違っています(というか無駄なコードを書いています。ごめんなさい)。

試しに次のように他の行をコメントアウトしてからテストを実行してください。
おそらくテストはパスするはずです。

before :each do
  # allow(Contact).to receive(:persisted?).and_return(true)
  # allow(Contact).to \
  #   receive(:order).with('lastname, firstname').and_return([contact])
  allow(Contact).to \
    receive(:find).with(contact.id.to_s).and_return(contact)
  # allow(Contact).to receive(:save).and_return(true)

  get :show, id: contact
end

もし全部コメントアウトするとエラーが発生すると思います。
なので、findのスタブだけは必要なことがわかります。

before :each do
  # allow(Contact).to receive(:persisted?).and_return(true)
  # allow(Contact).to \
  #   receive(:order).with('lastname, firstname').and_return([contact])
  # allow(Contact).to \
  #   receive(:find).with(contact.id.to_s).and_return(contact)
  # allow(Contact).to receive(:save).and_return(true)

  get :show, id: contact
end
ActiveRecord::RecordNotFound: Couldn't find Contact with 'id'=1031
./app/controllers/contacts_controller.rb:75:in `set_contact'
./spec/controllers/contacts_controller_spec.rb:65:in `block (4 levels) in <top (required)>'
./spec/rails_helper.rb:40:in `block (3 levels) in <top (required)>'
./spec/rails_helper.rb:39:in `block (2 levels) in <top (required)>'
-e:1:in `<main>'

もう少し詳しく

このスペックで実行されるコントローラのアクションはContactsControllerのshowです。

ContactsControllerからshowアクションで実行される部分だけを抜粋すると次のようになります。

class ContactsController < ApplicationController
  before_action :authenticate, except: [:index, :show]
  before_action :set_contact, only: [:show, :edit, :update, :destroy]

  # 省略

  def show
  end

  # 省略

  private

    def set_contact
      @contact = Contact.find(params[:id])
    end

    # 省略
end

このうち、ActiveRecordに関連する処理はset_contactメソッド内のContact.findだけになります。
なので、データベースへのアクセスをなくして高速化したいのであれば、この処理をスタブに置き換える必要があります。
そのためのコードをスペックのbeforeフックに書きます。

# Contactクラスのfindメソッドが呼ばれたら、モックの連絡先を返す
# (withはfindメソッドの引数の条件指定。この引数と一致したときにモックの連絡先を返す)
allow(Contact).to \
  receive(:find).with(contact.id.to_s).and_return(contact)

この行までコメントアウトしてしまうと、Contact.findは実際にデータベースにアクセスしてしまいます。
しかし、データベースにはテストデータを登録していないので、レコードが見つからずにエラー(ActiveRecord::RecordNotFound)が起きます。

また、ContactsControllerのshowアクションではContacts.find以外、ActiveRecord関連の処理が実行されないため、これ以外のモック化(allowで始まるコード)は必要ありません。

今回の「無駄なコード」が必要になるケース

さらに、今回「無駄なコード」とされた以下の3つのコードについても見ていきます。
以下は本のコードからの抜粋です。

allow(contact).to receive(:persisted?).and_return(true)

allow(Contact).to \
  receive(:order).with('lastname, firstname').and_return([contact])

allow(contact).to receive(:save).and_return(true)

Contactとcontactの違い

まず、Contactcontactの違いを確認しておきましょう。
大文字のContactはContactクラスそのもの(クラスオブジェクト)です。
一方、小文字のcontactlet(:contact)で作成されたモックの連絡先オブジェクトです。

この点を押さえた上で、3つの「無駄なコード」が必要になる場面を考えていきます。

1つめのコードについて

それでは最初のコードの意味を確認しましょう(コードの意味はコメントに記述しています)。

# モックの連絡先に対してpersisted?メソッドが呼ばれたら、trueを返す
allow(contact).to receive(:persisted?).and_return(true)

このコードに関しては以下のようなことが言えます。

  • テスト実行時にpersisted?メソッドが呼び出されるようなアクションがあるなら、このコードには意味が出てくる。
  • trueを返しているので、モックの連絡先はレコードが保存済みであることを示す。つまり、もしこのコードを使うなら、おそらくeditアクションやupdateアクション、destroyアクションになるはず。
    • ただし、本書のサンプルアプリケーションの場合、persisted?メソッドはフレームワーク内で呼ばれることになるので、どのアクションで呼ばれるのか正確に予測するのは難しい。
  • persisted?メソッドはインスタンスメソッドなので、Contact.persisted?をスタブ化しても意味がない(そんなクラスメソッドはないのでどこからも呼ばれない)。つまり、allowメソッドの引数はContactではなく、contactが正解。

2つめのコードについて

2つめのコードも同じように見ていきます。

# Contactクラスに対してorderメソッドが呼ばれたら、配列に入ったモックの連絡先を返す
# (orderメソッドの引数が'lastname, firstname'の場合のみ発動する)
allow(Contact).to \
  receive(:order).with('lastname, firstname').and_return([contact])

つまり、実行時にContact.order('lastname, firstname')が呼ばれるテストでスタブを使うなら、このコードには意味が出てきます。

また、ContactsControllerのindexアクションを見てみると次のようになっているので、もしこのコードを使うのであれば「頭文字を指定せずに連絡先を検索するスペック」になるはずです。

def index
  if params[:letter]
    @contacts = Contact.by_letter(params[:letter])
  else
    @contacts = Contact.order('lastname, firstname')
  end
end

3つめのコードについて

続いて、最後のコードです。

# モックの連絡先に対してsaveメソッドが呼ばれたら、trueを返す
allow(contact).to receive(:save).and_return(true)

ContactsController内でsaveメソッドが呼ばれるのはcreateアクションだけです。

def create
  @contact = Contact.new(contact_params)

  respond_to do |format|
    if @contact.save
      format.html { redirect_to @contact, notice: 'Contact was successfully created.' }
      format.json { render :show, status: :created, location: @contact }
    else
      format.html { render :new }
      format.json { render json: @contact.errors, status: :unprocessable_entity }
    end
  end
end

なので、もし使うのであれば「連絡先を新規作成するスペック」になります。
さらに、この場合だとContact.newがモックの連絡先を返すようにスタブ化する必要もあります。

また、persisted?メソッドと同様、saveメソッドもインスタンスメソッドなので、allowメソッドの引数はContactではなく、contactが正解です。

まとめ

というわけで内容をまとめます。

  • allowメソッドを使って特定のメソッド呼び出しをスタブ化する場合は、テスト実行時にどのメソッドが呼ばれるのかをチェックし、実際に呼ばれるメソッドだけをスタブ化する(呼ばれないメソッドをスタブ化しても意味がない)。
  • クラスメソッドとインスタンスメソッドの区別を付けた上で、allowメソッドに渡す引数を決める(クラスメソッドをスタブ化するならallow(Contact)、インスタンスメソッドならallow(contact))。

長々と書きましたが、ひとことで言うと「本もGitHubも間違いでした」ということになります。
疑問を抱かせてしまって大変申し訳ありませんでした🙇

なお、修正についてですが、本やGitHubのコードを修正しようと思うと原文の修正も必要になるため、Aaronさんとの調整に時間がかかりそうなのと、英語版はすでにRails 5版に移行しているという観点から、本の内容もGtiHubのコードもいったんこのままにさせてください。

Rails 5版ではモックやスタブを使うサンプルコードも完全にリニューアルしています。
具体的なスケジュールは未定ですが、日本語版もRails 5版に無料アップデートする予定です。

blog.jnito.com

あわせて読みたい

RSpecのモックについては僕もQiitaに記事を書いているので、よかったらこちらも参考にしてください。

qiita.com