はじめに
Everyday RailsのQ&Aページで本の内容に関する質問をいただきました。
回答を書いているとかなり長くなってしまったのと、他の読者のみなさんにも知っておいてもらいたい内容なので、このブログに回答を書きます。
質問
本とGithubのコードが異なります。
テストの実行結果が変わらないことと私の理解不足で成否が判断できないのですが本の方が正しいのでしょうか?
コードでは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の違い
まず、Contact
とcontact
の違いを確認しておきましょう。
大文字のContact
はContactクラスそのもの(クラスオブジェクト)です。
一方、小文字のcontact
はlet(: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版に無料アップデートする予定です。
あわせて読みたい
RSpecのモックについては僕もQiitaに記事を書いているので、よかったらこちらも参考にしてください。