ちょっと話題の記事

[Ruby] よく使うRspecのレシピ集(Rspec3.3)

この記事は公開されてから1年以上経過しています。情報が古い可能性がありますので、ご注意ください。

モバイルアプリサービス部の五十嵐です。

最近Rspecをガッツリ書いたので、調べたことをユースケースごとにまとめてみます。

対象バージョンはRspec3.3です。

リフレクション

Rubyのリフレクションを使用したテストの書き方です。

インスタンス変数を操作したい

インスタンス変数を取得したい場合はinstance_variable_get、設定したい場合はinstance_variable_setを使います。また、instance_variable_setでモックを仕込むことでレシーバオブジェクトのインスタンス変数や動作を操作することができます。

obj = Person.new(name: 'Hoge') # initializerで@nameにnameがセットされる想定
name = obj.instance_variable_get('@name')
expect(name).to eq 'Hoge'

obj.instance_variable_set('@name', 'Fuga')
expect(obj.name).to eq 'Fuga'

プライベートメソッドを実行したい

obj.send(:method)を使います。

expect(obj.send(:private_method)).to ...

引数を渡す場合は、sendメソッドの第二引数以降に渡したい引数をつけます。

expect(obj.send(:private_method, ref1, ref2, ...)).to ...

モック

呼び方はテストダブルやモックやスタブなどもっと細かい分類もありますが、ここでは細かい違いを考慮せず「モック」に統一して書きます。

単純なモックオブジェクトを作りたい

モックオブジェクトを作るにはdoubleを使います。

obj = double('obj')

モックオブジェクトにメソッドを持たせたい場合は、第二引数以降にKeyとValueを指定することで実現できます。

obj = double('obj', count: 10)
expect(obj.count).to eq 10

メソッドのモックを作りたい

doubleでもメソッドと返り値を指定できましたが、レシーバとなるオブジェクトがモックオブジェクトではなく存在するオブジェクトの場合はallow(obj).to receive(:method)を使います。

allow(obj).to receive(:method)

メソッドに返り値をもたせたい場合は、.and_returnを使います。

allow(obj).to receive(:method).and_return(true)
expect(obj.method).to be_truthy

また、呼び出しごとに返り値を変えたい場合は、.and_returnに複数の引数を指定します。

allow(obj).to receive(:method).and_return(true, false)
expect(obj.method).to be_truthy
expect(obj.method).to be_falsy

メソッドチェインするメソッドのモックを作りたい

メソッドチェインするメソッドのモックを作る場合、allow(obj).to receive(:method)を組み合わせることでも書くことはできますが、receive_message_chainを使えばもっと簡単に書くことができます。

allow(obj).to receive_message_chain(:method1, :method2).and_return(true)
expect(obj.method1.method2).to be_truthy

yieldがあるメソッドのモックを作りたい

yieldを含むメソッドのブロック内の動作を検証したい場合、allow(obj).to receive(:method).and_yieldをチェインしてブロックの引数の値を設定します。また、yieldを含むメソッドでyieldが呼ばれた回数を検証したい場合は、yield_controlマッチャーが使えます。

allow(obj).to receive(:each).and_yield(1).and_yield(2).and_yield(3)
expect{ |b| obj.send(:each, &b) }.to yield_control.exactly(3)

モックのメソッドが呼ばれることを検証したい

3通りの方法があります。

一つはallowexpectに変える方法です。allowは定義したモックメソッドが呼ばれなくても何も起きませんが、expectにすることで呼ばれたことを検証できます。また、.withを使うことで引数も検証できます。

expect(obj).to receive(:method)
obj.method

expect(obj).to receive(:method).with(true)
obj.method(true)

expectを使うとexpectの後に実際の処理を書くことになりますので、処理と検証が逆転してしまいます。allowhave_receivedを組み合わせることで、処理→検証の順番にすることができます。

allow(obj).to receive(:method)
obj.method
expect(obj).to have_received(:method)

モックオブジェクトで行うならspyを使います。spydoubleと似たような機能ですが、have_receivedマッチャーを使うことができるようになります。

obj = spy('obj')
expect(obj).to have_received(:method)

メソッド内のローカル変数にモックを仕込みたい

オブジェクトのローカル変数など、簡単にモックにできない場所にはallow_any_instance_ofが使えます。allow_any_instance_ofで定義されたクラスのインスタンスの動きを操作できます。

allow_any_instance_of(DB::Client).to receive(:select).and_return([])
adaptor = DB::Adaptor.new
expect(adaptor.select).to eq [] # DB::Adaptor.selectの中でDB::Client.selectが実行されている想定

テストデータの永続化

Rspecのテストは、デフォルトではトランザクション内で行われ、テストが終了するとデータは破棄されます。テストデータを永続化させるにはRspec.configureのuse_transactional_fixturesfalseにします。しかしほとんどの場合、テストデータを永続化させるとテストの順序などを考える必要があり、テストが書きにくくなると思います。

部分的にテストデータを永続化する場合は、.specファイル内の永続化したいスコープ内でself.use_transactional_fixtures = falseを実行します。こうするとスコープを抜けてもテストデータが残っているので、スコープ内のafterまたはafter(:all)でテストデータを忘れずに削除しましょう。

気をつけている点

そのほか、テストを書く上で気をつけている点です。

letやsubjectを使う

beforeを多用していたら、letsubjectに置き換えられないか考えます。letは実行時に遅延評価され、複数回使われてもスコープ内では同じオブジェクトが使用されます。また、let!は即時実行です。

beforeはデフォルトでbefore(:all)before(:each)です。つまりテストが実行されるたびにbeforeブロック内も実行されます。beforeは必要があるときだけ使いましょう。

またsubjectletと同じ機能ですが、テストのSubject(主語)であることを明示したいときに使います。

it内は簡潔にする

itの中はなるべく簡潔に記述したほうが、「このテストは何をしているのか」が分かりやすくなります。それ以外はletbeforeに移動しましょう。

テストが書きにくくなったらリファクタリングする

テストが書きにくくなったらリファクタリングをしたほうがいいというサインだと思います。見直すポイントは以下のような点があると思います。(まだ語れるほど自信はないのですが...)

  • クラスが大きくなりすぎていないか(なりすぎている場合は責務を考えて分割する)
  • クラス間やメソッド間の依存性を少なくする
  • 依存性の方向が一方向になるように意識する
  • スコープを小さくする

割れ窓を作らない

当たり前のことですが、テストは常に通るようにしておきます。1つでも失敗するテストを許してしまうとその状態に慣れてしまい、どんどんと失敗するテストが増えていき最終的に手がつけられなくなります。

まとめ

Rspecには便利なモック機能がたくあんあり、大抵のテストには困らないと思います。逆にこれらを使ったとしてもテストに困るようであればコードの設計を見直したほうがいいと思っています。私も早くテストが書きやすいコードが自然と書けるようになりたいと思います。

参考

ありがとうございます。