Posted at

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


はじめに

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


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 のそれぞれの違いについて


参考にしたサイト