👻

RSpec でインスタンスメソッドがスタブできないときはインスタンスをモックする必要があるよという話

2021/03/02に公開

はじめに

下記のようなコードを書いたとします。

instance_stub.rb
class Human
  def meet
    greeting = Greeting.new
    greeting.hello
  end
end

class Greeting
  def hello
    'Hello!'
  end
end

上記のコードに対して、Human#meet をテストしたいのですが、Greeting#hello は実際には呼び出したくなかったとします。

そのため、Greeting#hello をスタブする以下のようなテストコードを書いたとします。わかりやすいように、スタブした際の返り値を変更して、返り値が正しく変更されているかどうかを検証しています。

spec/instance_stub_spec.rb
require_relative '../instance_stub'

describe Human do
  context 'meet' do
    it 'returns a stub message instead of a real message' do
      greeting = Greeting.new

      allow(greeting).to receive(:hello).and_return('Hello from stub!')

      expect(Human.new.meet).to eq('Hello from stub!')
    end
  end
end

このコードは正しく動作しません。以下のようなエラーが発生します。

Failures:

  1) Human meet returns a stub message instead of a real message
     Failure/Error: expect(Human.new.meet).to eq('Hello from stub!')

       expected: "Hello from stub!"
            got: "Hello!"

       (compared using ==)
     # ./spec/instance_stub_spec_miss.rb:10:in `block (3 levels) in <top (required)>'

テスト中は Greeting#hello を呼び出したくないのでスタブしようとしていたのに、スタブできていません (Greeting#hello が実際に実行されてしまっています)。

この原因と解決法について説明します。

TL;DR

テストコード内で生成したインスタンスと実際のコード内で生成したインスタンスは別のオブジェクトであるため、スタブしたいインスタンスメソッドを持つクラスのインスタンスをテストコード内で生成しても正しくスタブすることはできない。

正しくスタブするためには、new をスタブする必要がある。

spec/instance_stub_spec.rb
require_relative '../instance_stub'

describe Human do
  context 'meet' do
    it 'returns a stub message instead of a real message' do
-      greeting = Greeting.new
+      greeting_mock = instance_double(Greeting)

-      allow(greeting).to receive(:hello).and_return('Hello from stub!')
+      allow(Greeting).to receive(:new).and_return(greeting_mock)
+      allow(greeting_mock).to receive(:hello).and_return('Hello from stub!')

      expect(Human.new.meet).to eq('Hello from stub!')
    end
  end
end

原因

まず原因に関してですが、行番号を付与して先ほどのテストコードを再掲します。

spec/instance_stub_spec.rb
 1	require_relative '../instance_stub'
 2
 3	describe Human do
 4	  context 'meet' do
 5	    it 'returns a stub message instead of a real message' do
 6	      greeting = Greeting.new
 7
 8	      allow(greeting).to receive(:hello).and_return('Hello from stub!')
 9
10	      expect(Human.new.meet).to eq('Hello from stub!')
11	    end
12	  end
13	end

6 行目で Greeting クラスのインスタンスを生成しています。8 行目で Greeting インスタンスの hello メソッドをスタブして 'Hello from stub!' を返すようにしています。

一見するとこれで Greeting#hello がスタブされるように見えますが、先ほど紹介したように、これだと正しくスタブされていません。

理由は、このテストコード内 (spec/instance_stub_spec.rb) で生成した Greeting インスタンスと、実際のコード内 (instance_stub.rb) で生成した Greeting インスタンスは、どちらも Greeting インスタンスですが、オブジェクトが別だからです。

これを説明するには、以下のプログラムを IRB で実行するとわかりやすいでしょう。

irb(main):001:0> class Foo; end
=> nil
irb(main):002:0> Foo.new == Foo.new
=> false
irb(main):003:0> Foo.new
=> #<Foo:0x00007faa9aafee88>
irb(main):004:0> Foo.new
=> #<Foo:0x00007faa99b1fa58>

中身が空の Foo クラスを作って、2 つの Foo インスタンスを比較していますが、false が返ってきます。

もう少し考察してみましょう。Foo インスタンスを生成した際の返り値を見てみると、インスタンスを生成するたびにオブジェクトの ID が変わっていることがわかります (ちなみに、オブジェクトの ID は Foo.new.object_id で取得することができます)。

つまり、全く同じ Foo.new を実行しても、実行するたびに別のオブジェクトが生成されるため、一致しないのです。

実際のコードとテストコードを再掲します。

instance_stub.rb
 1	class Human
 2	  def meet
 3	    greeting = Greeting.new
 4	    greeting.hello
 5	  end
 6	end
 7
 8	class Greeting
 9	  def hello
10	    'Hello!'
11	  end
12	end
spec/instance_stub_spec.rb
 1	require_relative '../instance_stub'
 2
 3	describe Human do
 4	  context 'meet' do
 5	    it 'returns a stub message instead of a real message' do
 6	      greeting = Greeting.new
 7
 8	      allow(greeting).to receive(:hello).and_return('Hello from stub!')
 9
10	      expect(Human.new.meet).to eq('Hello from stub!')
11	    end
12	  end
13	end

先ほどの話を踏まえて考えると、instance_stub.rb の 3 行目の Greeting.newspec/instance_stub_spec.rb の 6 行目の Greeting.new は、見た目は同じに見えてもオブジェクトが異なります。

そのため、spec/instance_stub_spec.rb の 8 行目は、もし 6 行目で生成した Greeting インスタンスと同じオブジェクトであればスタブできますが、実際には instance_stub.rb の 3 行目で生成した Greeting インスタンスとは別のオブジェクトなのでスタブできていないということになります。

解決法

ではどのようにすれば良いかというと、Greeting インスタンスを生成する際の new メソッドをスタブすれば期待した通りにスタブできます。

spec/instance_stub_spec.rb
require_relative '../instance_stub'

describe Human do
  context 'meet' do
    it 'returns a stub message instead of a real message' do
      greeting_mock = instance_double(Greeting)

      allow(Greeting).to receive(:new).and_return(greeting_mock)
      allow(greeting_mock).to receive(:hello).and_return('Hello from stub!')

      expect(Human.new.meet).to eq('Hello from stub!')
    end
  end
end

先ほどのテストコードでは Greeting インスタンスを直接生成していましたが、今回は Greeting クラスの new をスタブして、greeting_mock という名前のモックを返すようにしています。

そしてこの greeting_mockhello というメソッドをスタブして、'Hello from stub!' を返すようにしています。

このテストコードを実行すると期待した通りに動作します。実際のコードで Greeting インスタンスを生成した際にモックオブジェクトにすげ替えられて、そのモックオブジェクトに対して hello メソッドが呼ばれるため、スタブされる際の返り値である 'Hello from stub!' が返却されます。

new をスタブしなくても良い例

ここまでの内容を理解したところで、自分は混乱してしまいました。それは『使えるRSpec入門・その3「ゼロからわかるモック(mock)を使ったテストの書き方」』を読んだときです。

この記事で紹介されているコード例を引用させていただきます。

# 注:本当に動かす場合はtwitter gemが必要です
require 'twitter'

class WeatherBot
  def tweet_forecast
    twitter_client.update '今日は晴れです'
  end

  def twitter_client
    Twitter::REST::Client.new
  end
end
it 'エラーなく予報をツイートすること' do
  # Twitter clientのモックを作る
  twitter_client_mock = double('Twitter client')
  # updateメソッドが呼びだせるようにする
  allow(twitter_client_mock).to receive(:update)

  weather_bot = WeatherBot.new
  # twitter_clientメソッドが呼ばれたら上で作ったモックを返すように実装を書き換える
  allow(weather_bot).to receive(:twitter_client).and_return(twitter_client_mock)

  expect{ weather_bot.tweet_forecast }.not_to raise_error
end

上記の実際のコードでは twitter_client メソッド内で Twitter::REST::Client クラスのインスタンスを生成しています。そして、テストコードでは Twitter::REST::Clientnew メソッドをスタブしていません。

上記のテストコードは正しく動作するのですが、new メソッドをスタブしていないのになぜ正しくスタブできているのだろうと疑問に思いました。

しかし、落ち着いて考えれば正しくスタブできていることがわかります。Twitter::REST::Client クラスのインスタンスを生成しているだけの twitter_client メソッドを丸ごとスタブしているからです。

このテストコードでは WeatherBot クラスの tweet_forecast メソッドで例外が発生しないことを検証していますが、このメソッド内で呼び出されている twitter_client メソッドを丸ごとスタブしているため、twitter_client メソッドの中身の Twitter::REST::Client.new は実行されていないことになります。

そのため、正しくスタブできているというわけです。

インスタンスを生成するメソッドを別で用意して丸ごとスタブしてみる

先ほどの instance_stub.rb において、上記の項目で取り上げさせていただいた Twitter クライアントのコード風に、インスタンスを生成するだけのメソッドを作って書き換えてみましょう。すると以下のようなコードになります。

method_stub.rb
class Human
  def meet
    greeting_instance.hello
  end

  def greeting_instance
    Greeting.new
  end
end

class Greeting
  def hello
    'Hello!'
  end
end

以前は Human#meetGreeting インスタンスを生成していましたが、Greeting インスタンスを生成するだけの greeting_instance メソッドを用意してそれを呼び出すように変更しました。

上記のコードにおいて、greeting_instance メソッドを丸ごとスタブして Greeting#hello の返り値を変更するようなテストコードを書くと以下のようになります。

spec/method_stub_spec.rb
require_relative '../method_stub'

describe Human do
  context 'meet' do
    it 'returns a stub message instead of a real message' do
      greeting_mock = instance_double(Greeting)

      human = Human.new
      allow(human).to receive(:greeting_instance).and_return(greeting_mock)
      allow(greeting_mock).to receive(:hello).and_return('Hello from stub!')

      expect(human.meet).to eq('Hello from stub!')
    end
  end
end

先ほどの Twitter クライアントのテストコードと同様に、インスタンスを生成するだけの greeting_instance メソッドを丸ごとスタブして、モックオブジェクトを返すようにしています。

そのモックオブジェクトに対して hello メソッドが実行された際に、スタブして 'Hello from stub!' を返すようにしています。

greeting_instance メソッドを丸ごとスタブしているため、Greeting.new は実際には実行されません。そのため、上記のテストコードも正しくスタブできていることになります。

もちろん、Greeting.new の部分をスタブした場合でも期待した通りに動作します。つまり、method_stub.rb のコードに対して、spec/instance_stub_spec.rb のテストコードを実行しても正しく動作するわけです (require_relative でロードするファイルの対象を変更する必要はあります)。

method_stub.rb
class Human
  def meet
    greeting_instance.hello
  end

  def greeting_instance
    Greeting.new
  end
end

class Greeting
  def hello
    'Hello!'
  end
end
spec/instance_stub_spec.rb
require_relative '../method_stub'

describe Human do
  context 'meet' do
    it 'returns a stub message instead of a real message' do
      greeting_mock = instance_double(Greeting)

      allow(Greeting).to receive(:new).and_return(greeting_mock)
      allow(greeting_mock).to receive(:hello).and_return('Hello from stub!')

      expect(Human.new.meet).to eq('Hello from stub!')
    end
  end
end

この場合は、greeting_instance メソッドをスタブする代わりに、その中身である Greeting.new をスタブしていることになります。どちらの場合でもインスタンスの生成部分をスタブしていることになるので、正しく動作します。

まとめ

スタブしたいインスタンスメソッドを持つクラスのインスタンスをテストコード内で生成しても、実際のコード内のインスタンスとは別のオブジェクトなので、インスタンスメソッドを正しくスタブできないよというお話でした。

ところで、この記事ではモックオブジェクトを作る際に instance_double を使用しました。doubleinstance_double の違いについての記事も書きましたので、興味があれば併せてご覧ください。

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

参考にしたサイト

GitHubで編集を提案

Discussion