RSpec における double / spy / instance_double / class_double のそれぞれの違いについて
はじめに
RSpec でモックを作る際の double、spy、instance_double、class_double のそれぞれの違いについて説明します。
TL;DR
-
doubleと比較した際に-
spyは呼び出されるすべてのメソッドを明示的にスタブする必要がない -
instance_doubleは未定義のインスタンスメソッドをスタブしようとした際にエラーになる -
class_doubleは未定義のクラスメソッドをスタブしようとした際にエラーになる
-
double
まずは最も一般的(?)な double から説明します。
class Human
def conduct_experiment
experiment = Experiment.new
experiment.succeed
experiment.fail
end
end
class Experiment
def succeed
'succeed!'
end
def fail
raise StandardError
end
end
上記のコードで Human#conduct_experiment をテストする際に、experiment.fail をスタブして、例外が発生する代わりにメッセージを表示するようにしたかったとします。
その際に以下のようなテストコードを書いたとします。
require_relative '../experiment'
describe Human do
context 'conduct_experiment' do
it 'returns a failure message instead of exception' do
experiment_double = double(Experiment)
allow(Experiment).to receive(:new).and_return(experiment_double)
allow(experiment_double).to receive(:fail).and_return('fail!')
expect(Human.new.conduct_experiment).to eq('fail!')
end
end
end
このテストコードを実行すると、以下のようなエラーが発生します。
Failures:
1) Human conduct_experiment returns a failure message instead of exception
Failure/Error: expect(Human.new.conduct_experiment).to eq('fail!')
#<Double Experiment> received unexpected message :succeed with (no args)
# ./experiment.rb:5:in `conduct_experiment'
# ./spec/experiment.rb:11:in `block (3 levels) in <top (required)>'
「Experiment のモックは succeed というメソッドを知らないよ」というエラーです。
double を使ってモックした場合、呼び出されるすべてのメソッドを明示的にスタブしなければいけません。上記のテストコードは以下のように書き換えると正しく動作します。
require_relative '../experiment'
describe Human do
context 'conduct_experiment' do
it 'returns a failure message instead of exception' do
experiment_double = double(Experiment)
allow(Experiment).to receive(:new).and_return(experiment_double)
+ allow(experiment_double).to receive(:succeed)
allow(experiment_double).to receive(:fail).and_return('fail!')
expect(Human.new.conduct_experiment).to eq('fail!')
end
end
end
experiment_double (Experiment のモック) に succeed メソッドがあるということを教えてあげれば正しく動作します。
spy
double の場合は呼び出されるメソッドすべてを明示的にスタブする必要がありましたが、spy の場合はその必要がありません。
class Human
def conduct_experiment
experiment = Experiment.new
experiment.succeed
experiment.fail
end
end
class Experiment
def succeed
'succeed!'
end
def fail
raise StandardError
end
end
上記のコードにおいて、以下のようなテストコードを書いたとします。
require_relative '../experiment'
describe Human do
context 'conduct_experiment' do
it 'returns a failure message instead of exception' do
experiment_spy = spy(Experiment)
allow(Experiment).to receive(:new).and_return(experiment_spy)
allow(experiment_spy).to receive(:fail).and_return('fail!')
expect(Human.new.conduct_experiment).to eq('fail!')
end
end
end
このテストコードは正しく動作します。spy の場合はすべてのメソッドを受け入れるため、succeed メソッドに関しては明示的にスタブしなくても動作するようになります。
もちろん、succeed メソッドの返り値を変更したい (and_return で別の値を返したい) 場合は明示的にスタブする必要があります。
instance_double
呼び出されるすべてのメソッドを明示的にスタブしなければいけない点は double と同じです。異なるのは、定義されていないインスタンスメソッドをスタブした際にエラーになってくれるかどうかです。
class Human
def conduct_experiment
experiment = Experiment.new
experiment.succeed
experiment.fail
end
end
class Experiment
def succeed
'succeed!'
end
def fail
raise StandardError
end
end
上記のコードにおいて、以下のようなテストコードを書いたとします。
require_relative '../experiment'
describe Human do
context 'conduct_experiment' do
it 'returns a failure message instead of exception' do
experiment_instance_double = instance_double(Experiment)
allow(Experiment).to receive(:new).and_return(experiment_instance_double)
allow(experiment_instance_double).to receive(:succeed)
allow(experiment_instance_double).to receive(:failure).and_return('fail!')
expect(Human.new.conduct_experiment).to eq('fail!')
end
end
end
allow(experiment_double).to receive(:failure).and_return('fail!') という行に注目してください。定義されているのは fail メソッドですが、間違えて failure メソッドをスタブしてしまったとします。
このテストコードを実行すると以下のようなエラーが発生します。
Failures:
1) Human conduct_experiment returns a failure message instead of exception
Failure/Error: allow(experiment_instance_double).to receive(:failure).and_return('fail!')
the Experiment class does not implement the instance method: failure
# ./spec/experiment.rb:10:in `block (3 levels) in <top (required)>'
「Experiment クラスには failure というインスタンスメソッドは実装されていないよ」というエラーです。このように instance_double を使うと未定義のインスタンスメソッドをスタブしようとした際にエラーが発生します。double や spy では上記のエラーは発生しません。
class_double
class_double は、instance_double のクラス版だと考えるとわかりやすいでしょう。instance_double が未定義のインスタンスメソッドを指摘するのに対し、class_double は未定義のクラスメソッドを指摘します。
class Human
def conduct_experiment
Experiment.succeed
Experiment.fail
end
end
class Experiment
class << self
def succeed
'succeed!'
end
def fail
raise StandardError
end
end
end
先ほどまでの Experiment クラスのインスタンスメソッドをすべてクラスメソッドに変更しました。
上記のコードにおいて、今まで通り Experiment クラスのメソッドをスタブするのに加えて、それらのクラスメソッドが定義されているかどうかを検証します。
require_relative '../experiment'
describe Human do
context 'conduct_experiment' do
it 'returns a failure message instead of exception' do
experiment_class_double = class_double(Experiment)
allow(experiment_class_double).to receive(:succeed)
allow(experiment_class_double).to receive(:fail)
allow(Experiment).to receive(:fail).and_return('fail!')
expect(Human.new.conduct_experiment).to eq('fail!')
end
end
end
Experiment クラスのメソッドをインスタンスメソッドからクラスメソッドに変更したため、スタブの仕方が若干変わっていますが、ここで注目してほしいのは追加された以下の 2 行です。
allow(experiment_class_double).to receive(:succeed)
allow(experiment_class_double).to receive(:fail)
class_double を使って生成した experiment_class_double という Experiment クラスのモックを使って、succeed と fail というクラスメソッドが定義されているかどうかを検証しています。
ここで、定義されていないクラスメソッドをスタブしてみましょう。
require_relative '../experiment'
describe Human do
context 'conduct_experiment' do
it 'returns a failure message instead of exception' do
experiment_class_double = class_double(Experiment)
allow(Experiment).to receive(:succeed)
allow(Experiment).to receive(:fail).and_return('fail!')
allow(experiment_class_double).to receive(:succeed)
- allow(experiment_class_double).to receive(:fail)
+ allow(experiment_class_double).to receive(:failure)
expect(Human.new.conduct_experiment).to eq('fail!')
end
end
end
すると以下のようなエラーが発生します。
Failures:
1) Human conduct_experiment returns a failure message instead of exception
Failure/Error: allow(experiment_class_double).to receive(:failure)
the Experiment class does not implement the class method: failure
# ./spec/experiment.rb:12:in `block (3 levels) in <top (required)>'
「Experiment クラスには failure というクラスメソッドは実装されていないよ」というエラーです。このように class_double を使うと未定義のクラスメソッドをスタブしようとした際にエラーが発生します。なお、class_double に関してはすべてのクラスメソッドをスタブする必要はありません。
class_double に関しては他と違って少し特殊な使い方をするようです。筆者自身も上記以外の class_double の使い方がよくわかっておらず、もしかしたらここで紹介した例は副次的な使い方なのかもしれません。
まとめ
RSpec に関してはまだまだ初心者なので最適な使い分けがあまりよくわかっていないのですが、spy よりも double、double よりも instance_double のほうがより厳密なので、基本的には instance_double を使うのが良いのかと考えています。
instance_double ほど厳密にメソッドの定義を検証しなくて良い場合は double を使い、呼び出しているすべてのメソッドをまとめてスタブしたい場合 (返り値がなんでも良い場合に限る) は spy を使う、という使い分けになるのかと思います。
Discussion