2020-07-04

instance_doubleとinstance_spyの使い分け

RSpecの #instance_double#instance_spy の使い分けについて考察してみた。個人の見解です。

環境・バージョン

  • ruby 2.7.0p0
  • rspec-mocks 3.9.0

#instance_double#instance_spy の違い・共通点

Verifying Doubles

#instance_double#instance_spy も、Verifying Double と呼ばれるオブジェクトを返すという共通点がある。

これは何かと言うと、テストダブル対象のインスタンスに存在しないメソッドがスタブされた場合や、引数が異なる形式でメソッドが呼び出された場合にエラーを起こしてくれるという機能を持つオブジェクトである。
一方、#double#spy が返すオブジェクトにはそういった機能はなく、存在しないメソッドをコールしても良いし、実際の異なる引数形式でメソッドを呼び出しても良い「何でもあり」な状態になっている。

以下は #double#instance_double でこの挙動を比較したサンプルコード。

# foo.rb
class Foo
  def run
    'foo'
  end
end

# spec/foo_spec.rb
RSpec.describe do
  describe '#run' do
    context 'when using #instance_double' do
      it "doesn't raise error" do
        foo = instance_double(Foo)
        allow(foo).to receive(:run)
        allow(Foo).to receive(:new).and_return(foo)
        # => Foo#runは存在するためエラーは起きない
      end

      it "raises error" do
        foo = instance_double(Foo)
        allow(foo).to receive(:hoge) 
        allow(Foo).to receive(:new).and_return(foo)
        # => Foo#hogeは存在しないためエラー
        # `the Foo class does not implement the instance method: hoge` 
      end
    end

    context 'when using #double' do
      it "doesn't raise error" do
        foo = double(Foo)
        allow(foo).to receive(:hoge)
        allow(Foo).to receive(:new).and_return(foo)
        # => Foo#hogeは存在しないがエラーは起きない
      end
    end
  end
end

詳しくは以下を参照。
https://relishapp.com/rspec/rspec-mocks/v/3-9/docs/verifying-doubles

as_null_object

実装レベルで見ると1点だけ違いがあり、#instance_spy#instance_double の返り値に対して #as_null_object メソッドを呼び出している。

# lib/rspec/mocks/example_methods.rb
def instance_spy(*args)
  instance_double(*args).as_null_object
end

#as_null_object の機能はAPIドキュメントの通り。

Tells the object to respond to all messages. If specific stub values are declared, they'll work as expected. If not, the receiver is returned.

つまり、「全てのメソッド呼び出しに応答するようになり、返り値は指定すればそれを返すが何も指定しない場合はレシーバ自身を返す」という機能が追加される。

「全て」とはあるが、前述のVerifying Doubleにおいてはテストダブル対象のインスタンスに存在するメソッドだけが対象となる。
以下は #spy#instance_spy でこの挙動を比較したサンプルコード。

# foo.rb
class Foo
  def run
    'foo'
  end
end

# spec/foo_spec.rb
RSpec.describe do
  describe '#run' do
    context 'when using #instance_spy' do
      it "raises error" do
        foo = instance_spy(Foo)
        allow(Foo).to receive(:new).and_return(foo)

        Foo.new.hoge
        # => Foo#hogeは存在しないためエラー
        # `the Foo class does not implement the instance method: hoge`
      end
    end

    context 'when using #spy' do
      it "doesn't raise error" do
        foo = spy(Foo)
        allow(Foo).to receive(:new).and_return(foo)

        Foo.new.hoge
        # => Foo#hogeは存在しないがエラーは起きない
      end
    end
  end
end

※ see : https://rspec.info/documentation/3.9/rspec-mocks/RSpec/Mocks/TestDouble.html#as_null_object-instance_method

存在するメソッドを自動でスタブするか否か

上記2点の共通点と違いからもわかることではあるが、#instance_double は呼び出すメソッドを明示的に allow.. で定義する必要があるが、 #instance_spy はこれを明示的に行う必要が無い。
#as_null_object により、#instance_spy はテストダブル対象のクラスに存在するメソッドについては自動で応答するようにしてくれる。

# foo.rb
class Foo
  def run
    'foo'
  end
end

# spec/foo_spec.rb
RSpec.describe do
  describe '#run' do
    context 'when using #instance_double' do
      it "raises error" do
        foo = instance_double(Foo)
        allow(Foo).to receive(:new).and_return(foo)

        Foo.new.run
        # => `#run` を呼べるようにallowで定義していないのでエラー
        # #<InstanceDouble(Foo) (anonymous)> received unexpected message :run with (no args)
      end

      it "doesn't raise error" do
        foo = instance_double(Foo)
        allow(foo).to receive(:run)
        allow(Foo).to receive(:new).and_return(foo)

        Foo.new.run
        # => allowで定義したのでエラーは起きない
      end
    end

    context 'when using #instance_spy' do
      it "doesn't raise error" do
        foo = instance_spy(Foo)
        allow(Foo).to receive(:new).and_return(foo)

        Foo.new.run
        # => エラーは起きない
      end
    end
  end
end

APIドキュメントの説明を見る

そもそも #instance_spy のAPIドキュメントには以下のような説明がある。

Constructs a test double that is optimized for use with have_received against a specific class.

#have_received マッチャでメソッドが呼び出されたかどうかを確認するために最適化されているとのこと。
とはいえ、前述のとおり #instance_double でも allow.. でメソッドをスタブすれば、同じように #have_received で確認できるので、最適化されているというのが具体的に何を指し示しているのかは読み取れなかった。

# foo.rb
class Foo
  def run
    'foo'
  end
end

# spec/foo_spec.rb
RSpec.describe do
  describe '#run' do
    context 'when using #instance_double' do
      it "doesn't raise error" do
        foo = instance_double(Foo)
        allow(foo).to receive(:run)
        allow(Foo).to receive(:new).and_return(foo)

        Foo.new.run
        expect(foo).to have_received(:run) # => pass
      end
    end

    context 'when using #instance_spy' do
      it "doesn't raise error" do
        foo = instance_spy(Foo)
        allow(Foo).to receive(:new).and_return(foo)

        Foo.new.run
        expect(foo).to have_received(:run) # => pass
      end
    end
  end
end

使い分けの方針

以上のような共通点・違いを理解した上で、次のような使い分けをすると良いんじゃないかと考えた。

  • メソッドが呼び出されたかどうかのみに関心があり、返り値や呼び出し時の引数に関心が無い場合は #instance_spy を使う
    • この場合、単純に #instance_double よりもシンプルに書けるため。
  • 呼び出すメソッドの返り値や呼び出し時の引数に関心がある場合は#instance_double を使う
    • この場合はどちらも allow.. で返り値を明示的に定義する必要はあるが、#instance_spy を使う積極的な理由が無い。
    • #instance_spy だとデフォルトでレシーバ自身を返すようになっているが、返り値に関心がある場合はそれにメリットはない。下手に定義忘れをしていても動いてしまうくらいならば、#instance_double にしておけばエラーが起こるので安心

その他参考資料