🐡

RSpec における double / spy / instance_double / class_double のそれぞれの違いについて

2021/03/02に公開

はじめに

RSpec でモックを作る際の doublespyinstance_doubleclass_double のそれぞれの違いについて説明します。

TL;DR

  • double と比較した際に
    • spy は呼び出されるすべてのメソッドを明示的にスタブする必要がない
    • instance_double は未定義のインスタンスメソッドをスタブしようとした際にエラーになる
    • class_double は未定義のクラスメソッドをスタブしようとした際にエラーになる

double

まずは最も一般的(?)な double から説明します。

experiment.rb
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 をスタブして、例外が発生する代わりにメッセージを表示するようにしたかったとします。

その際に以下のようなテストコードを書いたとします。

spec/experiment_spec.rb
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 を使ってモックした場合、呼び出されるすべてのメソッドを明示的にスタブしなければいけません。上記のテストコードは以下のように書き換えると正しく動作します。

spec/experiment_spec.rb
 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 の場合はその必要がありません。

experiment.rb
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

上記のコードにおいて、以下のようなテストコードを書いたとします。

spec/experiment_spec.rb
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 と同じです。異なるのは、定義されていないインスタンスメソッドをスタブした際にエラーになってくれるかどうかです。

experiment.rb
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

上記のコードにおいて、以下のようなテストコードを書いたとします。

spec/experiment_spec.rb
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 を使うと未定義のインスタンスメソッドをスタブしようとした際にエラーが発生しますdoublespy では上記のエラーは発生しません。

class_double

class_double は、instance_double のクラス版だと考えるとわかりやすいでしょう。instance_double が未定義のインスタンスメソッドを指摘するのに対し、class_double未定義のクラスメソッドを指摘します

experiment.rb
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 クラスのメソッドをスタブするのに加えて、それらのクラスメソッドが定義されているかどうかを検証します。

spec/experiment_spec.rb
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 クラスのモックを使って、succeedfail というクラスメソッドが定義されているかどうかを検証しています。

ここで、定義されていないクラスメソッドをスタブしてみましょう。

spec/experiment_spec.rb
 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 よりも doubledouble よりも instance_double のほうがより厳密なので、基本的には instance_double を使うのが良いのかと考えています。

instance_double ほど厳密にメソッドの定義を検証しなくて良い場合は double を使い、呼び出しているすべてのメソッドをまとめてスタブしたい場合 (返り値がなんでも良い場合に限る) は spy を使う、という使い分けになるのかと思います。

参考にしたサイト

GitHubで編集を提案

Discussion