rspec-mocksを使ってテストで上手に手を抜こう
対象
-
RSpec
での簡単なテストの書き方は基本的に知っている -
rspec-mocks
を使ったテストを知らない|知ってるけど雰囲気で使っている
上記に当てはまる私自身が、テストをより効率的に書くために、広く浅くざっくり調べた内容のため、より詳しく知りたい人は公式ドキュメントなどを漁ってください。
※記事中に出てくるコードの多くは、RSpec によるテストコード内で実行されるコードです。周辺のコードは省略するので、適当に文脈から読み取ってください。
前提
- rspec-core (3.7.1)
- rspec-mocks (3.7.0)
- rspec-rails (3.7.2)
- rspec-support (3.7.1)
rspec-mocksとは
rspec-mocksは、RSpec用のテストダブルフレームワークで、モックとかスタブとかスパイとかサポートしてくれます。
テストダブルとは、テスト対象が依存する他のクラスやオブジェクトの代替品として振る舞ってくれるオブジェクトのことです。(Double=影武者)
なお、テストダブルには以下のような種類があります。ここでは厳密な用語の使い分けはせずに、テストダブルあるいはスタブ、モックという用語を使います。
名称 | 意味 |
---|---|
テストダブル | 依存対象の代替品の総称 |
スタブ | 指定した振る舞いをするモノ |
モック | 指定したアクセス方法と振る舞いをするモノ |
スパイ | アクセス方法、回数を記録するモノ |
フェイク | 本物とほぼ同じ振る舞いをするが、部分的に異なるモノ |
ダミー | 振る舞いを持たないハリボテ |
※ rspec-mocksは、普通に gem で rspec をインストールしていれば付属しているので、特別なインストール作業なしで使うことができます。
rspec-mocksがあると何が嬉しいのか
例えば以下のHoge
クラスは、foo
オブジェクトとbar
オブジェクトに依存しています。
class Hoge
def initialize(foo:, bar:)
@foo = foo
@bar = bar
end
def calc
@foo.calc + @bar.calc
end
end
上記のクラスで、Hoge#calc
の単体テストを書く場合、依存オブジェクトであるfoo#calc
とbar#calc
も実行されるため、以下のような問題が起こることがあります。
-
foo
及びbar
のテストデータを生成する必要がある -
Hoge#calc
が正常に動くには、これらが正常に動いていることが大前提となる - これらが重い処理の場合、
Hoge#calc
はさらに重いテストになってしまう - これらが外部APIなど、通信を要する処理の場合、テストを実行するたびに通信が走ってしまう
以上の問題は全て、テストダブルを用いることで、以下のように解決することができます。
- テストダブルを生成することで、本物を使ったテストデータ生成作業を不要になる
- 依存する処理が全て正常な振る舞いをするテストダブルを生成することで、テスト対象自体の振る舞いだけをテストできるようになる
- 依存する処理がどんなに重くても、テストダブルで置き換えることで一瞬で実行が終わる
- 外部APIのような振る舞いをするテストダブルを生成することで、外部APIを実際に叩かずにテストができる
というメリットまみれの状態を作れるので、さっそく使ってみましょう。
シンプルなテストダブルを作る
まずは、すごく簡単なテストダブルから作っていきましょう。まずはUser
クラスのフリをしたゆーざーだぶるくん
を生成します。
user = instance_double(User, 'ゆーざーだぶる')
user #<InstanceDouble(User) "ゆーざーだぶる">
ゆーざーだぶるくん
は、User
クラスのフリをしていますが、User
クラスのオブジェクトではありません。もしUser
クラスが name
という属性を持っていても、今のままではname
を呼び出すことはできません。
user.name
# RSpec::Mocks::MockExpectationError: #<InstanceDouble(User) "ゆーざーだぶる"> received unexpected message :name with (no args)
以下のように、「nameを効かれたらbobと答えるんだよ」と教えてあげることで、ゆーざだぶるくん
は名前を答えることができるようになります。
user = instance_double(User, 'ゆーざーだぶる', name: 'ぼぶ')
user.name # "ぼぶ"
ただし、User
クラスが持っていない、例えばage
などを教えても、ゆーざだぶるくん
は答えることができません。あくまでUser
クラスのテストダブルなので、本物のUser
オブジェクトでも知らないことをできません。
user = instance_double(User, 'ゆーざーだぶる', age: 26)
# RSpec::Mocks::MockExpectationError: the User class does not implement the instance method: age
これでシンプルなテストダブルを作ることができました。と言っても、これだけ知ってもテストの効率化は難しいです。ステップを進めていくと、その活用方法がだんだんと見えてきます。
特定のオブジェクトの任意のメソッドをスタブにする
rspec-mocks
では、allow
を使うことで、任意のオブジェクトに対してメソッドをスタブすることができます。
例えば以下のクラスのcalc
メソッドは、色々と複雑な計算をした結果を戻してくれるものだとします。
class Foo
def calc
result = sugoku_omoi_shori
return result
end
end
上記クラスを使ったテストを書く際に、allow
を使うことでこれをスタブ化し、sugoku_omoi_shori
を実行すること無く、固定のレスポンスを戻してくれるようにできます。
foo = Foo.new
foo.calc # sugoku_omoi_shori が走る
allow(foo).to receive(:calc).and_return(true)
foo.calc # sugoku_omoi_shori が走らずtrueが戻る
receive
で対象のメソッド名を指定して、and_return
でスタブの戻り値を指定します。and_return
はブロックを渡すように書き換えることも可能です。
allow(foo).to receive(:calc) { false } # 常にfalseが戻る
複数のメソッドをまとめてスタブ化する
例えば以下のFoo
クラスは、Bar
クラスに依存しており、bar
オブジェクトのaction1.action2.action2
といったメソッドチェインを実行しているため、それぞれのメソッドのスタブ化を検討しなければなりません。
class Foo
def initialize(bar:)
@bar = bar
end
def run
if @bar.action1.action2.action3
'OK'
else
'NG'
end
end
end
class Bar
def action1
puts 'sugoi_omoi_shori1'
self
end
def action2
puts 'sugoi_omoi_shori2'
self
end
def action3
puts 'sugoi_omoi_shori3'
true
end
end
この場合、前項のように、receive_messages
を使って、以下のように一つのオブジェクトに対して複数のメソッドをめとめてスタブ化することができます。
allow(bar).to receive_messages(action1: bar, action2: bar, action3: true)
さらに、上記のようなメソッドチェインを使ったケースに限れば、以下のようにreceive_messsage_chain
を使って、メソッドチェイン全体をまとめてスタブ化することができます。
allow(bar).to receive_message_chain(:action1, :action2, :action3) { true }
スタブを複数回呼び出した場合の振る舞いを変える
定義したスタブをテストコード中で複数回呼び出すが、呼び出しごとに結果を固定したくないという場合は、and_return
の引数を複数指定することで、呼び出し回数ごとの結果を定義することができます。
user = User.new
allow(user).to receive(:name).and_return('たろう', 'じろう', 'さぶろう')
user.name # たろう
user.name # じろう
user.name # さぶろう
user.name # さぶろう
上記結果の通り、3パターン定義した場合に4回呼び出すと、先頭に戻って巡回することはなく、3パターン目を返し続けるので注意が必要です。
あるクラスの全てのオブジェクトに対してメソッドをスタブする
これまでは、Foo
クラスをテストする場合、Foo
クラスのオブジェクトを生成して、そのオブジェクトに対してメソッドのスタブを定義してきました。
しかし、テスト対象のコードによっては、コード内で動的にオブジェクトが生成されるなど、スタブすべきオブジェクトが事前に特定できない場合もあります。(※コードの内部に依存が潜んでいることになるので、あまり良い実装でない場合があります)
そういう場合は、以下のようにallow_any_instance_of
を使うことで、特定クラスから生成されてた全てのオブジェクトについて、メソッドをスタブすることができます。
allow_any_instance_of(Foo).to receive(:receive(:calc).and_return(true)
Foo.new.calc # true
Foo.new.calc # true
スパイを使って、任意のメソッドが呼び出されたかを検証する
double
で作成したテストダブルは、事前にスタブするメソッドを定義していないと、呼び出しに答えることは出来ませんでした。
d = double
double.calc
RSpec::Mocks::MockExpectationError: #<Double (anonymous)> received unexpected message :calc with (no args)
from /teachme_gems/gems/rspec-support-3.7.1/lib/rspec/support.rb:97:in `block in <module:Support>'
呼ばれるメソッドがある程度予想がつく状態ならそれでも良いんですが、実際はオブジェクトに対してどんなメッセージングが行われるかは予想がつかない、または数が多い場合などがあります。
そういう場合は、スパイ(spy)
が使えます。スパイは通常のモックとは違い、どんなメソッド呼び出しをしても、自分自身を返すメソッドとして振る舞ってくれます。
s = spy
s.calc
=> #<Double (anonymous)>
そしてスパイは、自身がどんなメソッド呼び出しをされたかを覚えています。例えば以下の例では、スパイはcalc
は呼び出されたのでtrue
だけど、name
は呼び出されていないよということで、例外を吐いています。
s = spy
s.calc
expect(s).to have_received(:calc)
=> true
expect(s).to have_received(:name)
RSpec::Expectations::ExpectationNotMetError: (Double (anonymous)).name(*(any args))
expected: 1 time with any arguments
received: 0 times with any arguments
これを活用することで、テスト対象のメソッドが、特定のメソッドをちゃんと呼んでいるかを検証することができます。呼んで欲しい処理が非常に重い処理の場合、実行はしてほしくないけど、呼ばれていることを確認したい場合に有用です。
例えば以下のFoo
クラスのcalc
メソッドは、Bar
クラスのcalc
メソッドに依存します。Bar#calc
は重い処理なので実行したくないけど、Foo#calc
を呼ぶことでBar#calc
も呼べているかを検証したいとします。
class Foo
def initialize(bar:)
@bar = bar
end
def calc
@bar.calc
end
end
そんなときこそ、bar
をスパイにして、メソッドが呼ばれたことを検証してみます。ついでにスパイである利点を生かして、name
メソッドは呼ばれていないことも検証します。
bar = spy('bar')
foo = foo.new(bar: bar)
foo.calc
expect(bar).to have_received(:calc)
=> true
expect(bar).not_to have_received(:name)
=> true
ここでは、「呼ばれたかどうか」だけを検証しましたが、関連メソッドを使うことで、より具体的に検証することもできます。
例えば、以下のメソッドを活用することで、対象のメソッドが呼ばれた回数を正確に、または曖昧に検証することができます。(重い処理が無駄に複数回呼ばれていないかなど)
メソッド | 機能 |
---|---|
have_received(:calc).once | 1回のみ呼ばれたか?(2回以上はダメ) |
have_received(:calc).twice | 2回のみ呼ばれたか?(1回でも3回でもダメ) |
have_received(:calc).exactly(n).times | ちょうどn回呼ばれたか? |
have_received(:calc).at_least(n).times | 少なくともn回呼ばれたか? |
have_received(:calc).at_most(n).times | n回以上呼ばれていないか |
他にも、以下のメソッドを使うことで、対象のメソッドに引数が正しく渡されたか(or 渡されなかったか)を検証することができます。
メソッド | 機能 |
---|---|
have_received(:calc).with(no_args) | 引数なしで呼ばれたか? |
have_received(:calc).with(any_args) | 任意の引数付きで呼ばれたか? |
have_received(:calc).with(kind_of(Numeric) | 数値の引数付きで呼ばれたか? |
have_received(:calc).with(duck_type(:abs)) | absメソッドを持ったオブジェクトを引数にして呼ばれたか? |
have_received(:calc).with(hash_including(:a => 5)) | :a => 5の値を持つハッシュを引数にして呼ばれたか? |
have_received(:calc).with(array_including(5)) | 5を要素に持つ配列を引数にして呼ばれたか? |
have_received(:calc).with(1, any_args) | 1と任意の引数付きで呼ばれたか? |
さらに、メソッドが呼ばれた順番が重要である場合は、以下のようにordered
を使うことで、実行された順番まで正しくないとテストが落ちるようにすることもできます。
bar = spy('bar')
bar.hoge # hogeとfugaを逆にするとテストが落ちる
bar.fuga
expect(bar).to have_received(:hoge).ordered
expect(bar).to have_received(:fuga).ordered
やや極端な例ですが、これまでの仕組みを組み合わせることも当然可能です。
以下は、「bar#hoge
が2回、それぞれ数値を引数に呼び出され、その後にbar#fuga
が呼ばれている」 ことを検証します。一つでも条件が崩れると落ちるため、厳密にチェックしたい場面であれば有用でしょう。
bar = spy('bar')
bar.hoge(1)
bar.hoge(2)
bar.fuga
expect(bar).to have_received(:hoge).twice.with(kind_of(Numeric)).ordered
expect(bar).to have_received(:fuga).ordered
Discussion