🧪

rspecでモックを使うときに気をつけていること

2020/11/03に公開

スタブとは

テストコード内で利用されるもの。
テスト対象のメソッドや処理から、別のメソッドを介してデータを取得したり加工したりする場合に、決まりきった値を返すように設定したものを指す。

class A
  def initialize
    @b = B.new
  end

  def hoge
    ...
    @b.fuga
    ...
  end
 end

A#hoge のユニットテストを書く場合に、B#fuga の細かい挙動については知る必要がなく、A#hoge のテストをするに当たって都合のいい値さえ返してくれればいい。(もちろん B#fuga 別途ユニットテストを書く必要がある。)

テストを記述する際に、そのテストで確認したい内容のために何か都合のいい値を返すように設定したものがスタブである。

モックとは

モックもテストコード内で利用されるもの。
あるメソッドを呼び出す際に、その引数や呼びされる回数を検証するために設定したものがモックである。
上記の例だと、B#fugaA#hoge の中で、引数なしで1回だけ呼び出されている。B#fuga の内部実装に紐付くテストは B#fuga のユニットテストで行うとして、 A#hoge の立場から考えると、B#fuga を意図した引数で意図した回数呼び出せれていることが担保できていればそれで良い。
その際に B#fugaが引数なしで1回だけ呼びされること という内容を設定したモックオブジェクトを使って、テストを記述することになる。

rspecでモックを使うとき

まずはスタブから

以下のサンプルコードで考えたい。

class Article
  def fetch(time: Time.zone.now)
    articles = PublishedArticlesService.new.articles(time)
    ...
  end

Article#fetch と呼ぶと PublishedArticlesService#articles を介して、指定した時刻の時点で公開済になっている記事のリストが取れるイメージ。
(サンプル用なので、命名や設計には目をつぶっていただけると🙏)

さて、このメソッドのユニットテストを書こうとしたときに、PublishedArticlesService#articles の内部までテストの対象にはしたくない。そこで、まずこれをスタブにする。rspecでは

it '期待する値が返ってくること' do
  allow(PublishedArticlesService).to receive(:new).and_return(service)
  allow(service).to receive(:articles).and_return(articles)
  is_expected.to eq ...
end

のように定義する。
まず、PublishedArticlesService.newで、返すオブジェクトをスタブで定義して、そのオブジェクトに対して、articles で返すオブジェクトをスタブとして定義している。(and_return で返している値は let で定義されているものとして扱っている)

さて、この記述はitブロックの中に書いたが、スタブの定義は実行するテストの前処理として扱いたいのでbeforeブロックとして書いた方がテスト対象の見通しが良くなる。

before do
  allow(PublishedArticlesService).to receive(:new).and_return(service)
  allow(service).to receive(:articles).and_return(articles)
end
...
it '期待する値が返ってくること' do
  is_expected.to eq ...
end

ここでテストが成功することを確認する。つまりメソッドのI/Fとして、意図する値を入れたときに意図する値が返ってくるかを確認する。

モックに置き換える

そうしたら、スタブにしていたもののうち、引数や呼び出し回数を検証したい部分をモックとして置き換えていく。
今は、#articles が意図した引数で呼ばれているかを検証したいとして、

allow(service).to receive(:articles).and_return(articles)

から

expect(service).to receive(:articles).with(Time.zone.now).and_return(articles)

と書き直す。Article#fetch を引数無しで呼び出したときと、引数ありで呼び出したときでは、サービスクラスのarticlesの引数が変わってくるので、それをモックを使ってテストしている。
これでモックを使ったテストにすることができた。

スタブとモックを分けて記述する

これでもテストとしては検証したいことができているし、完成としてもいいのだが、自分はもう1ステップ追加してテストのリファクタリングを行っている。
それは「スタブとモックを分ける」ということだ。

前章までで、テストは以下のようになっている。

before do
  allow(PublishedArticlesService).to receive(:new).and_return(service)
  expect(service).to receive(:articles).with(Time.zone.now).and_return(articles)
end
...
it '期待する値が返ってくること' do
  is_expected.to eq ...
end

モックを導入した際に、もともとテストの前条件を記述していたbeforeブロックに、検証のコードが追加されてしまっている。
検証をするexpectはitブロックの中だけで書きたいと考え、これを実現するのが have_received を使ったモックの定義である。

before do
  allow(PublishedArticlesService).to receive(:new).and_return(service)
  allow(service).to receive(:articles).and_return(articles)
end
...
it '期待する値が返ってくること' do
  is_expected.to eq ...
end

it '意図した引数で呼び出されていること' do
  subject
  expect(service).to have_received(:articles).with(Time.zone.now)
end

このように書くことで、スタブはテストの前提条件としてbeforeブロック内に、モックはテスト対象なのでitブロック内にその記述を閉じ込めることができた。

1つ注意点としては、have_received という単語からも想像つくように、この呼出はテスト対象のメソッドを呼び出した後に検証する必要がある。
反対に、receiveを使ったモックでは対象のメソッドを呼び出す前に検証のコードを記述する必要がある。

まとめ

モックを使って検証したい場合は

  1. まずスタブを使って、メソッド単体でのI/Fを検証するテストコードを書く
  2. スタブからモックに置き換える
  3. スタブとモックに分ける

以上の流れでテストを書いている。

やや冗長な面はあるが、前提条件と検証のコードを分離して記述できるので自分は好んで使っている。

Discussion