💎

rspec-mocksを使ってテストで上手に手を抜こう

2022/01/30に公開

対象

  • 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#calcbar#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

参考

GitHubで編集を提案

Discussion