🕸

Rubocop の RSpec/MessageSpies に対応する際は処理順の書き換えが必要

2022/07/27に公開2

Leaner 開発チームの黒曜(@kokuyouwind)です。

AWS Startup Community Conference 2022 に CfP を出したので通ってほしい今日このごろです。

https://twitter.com/startups_on_aws/status/1547143820868956160

さて、今回は Rubocop 警告の修正で少し手間取った話を書きます。

RSpec/MessageSpies に引っかかったコード

あるクラスで「Rails.logger に特定文字列を出力していることを検証したい」というケースがあり、この spec を以下のように書いていました。

spec/hoge_spec.rb
# ログを出力するクラス
class Hoge
  def log
    Rails.logger.info('test message')
  end
end

# spec
RSpec.describe Hoge do
  describe 'log' do
    let(:logger) { instance_double(ActiveSupport::Logger) }

    before { allow(Rails).to receive(:logger).and_return(logger) }

    it 'ログ出力される' do
      expect(logger).to receive(:info).with('test message')
      described_class.new.log
    end
  end
end

このファイルを標準設定の Rubocop にかけると、以下のように RSpec/MessageSpies ルールに引っかかります。

Offenses:

spec/hoge_spec.rb:18:25: C: RSpec/MessageSpies: Prefer have_received for setting message expectations. Setup logger as a spy using allow or instance_spy.
      expect(logger).to receive(:info).with('test message')
                        ^^^^^^^

1 file inspected, 1 offense detected

「メッセージ受信の検査をする場合は( receive ではなく) have_received を使え」と言っていますね。

RSpec/MessageSpies への対応

ここで当初、あまり深く考えずに「なるほど matcher を書き換えればいいんだな」と解釈して以下のように書き換えました。

    it 'ログ出力される' do
-     expect(logger).to receive(:info).with('test message')
+     expect(logger).to have_received(:info).with('test message')
      described_class.new.log
    end

…おわかりいただけたでしょうか。この書き換えではテストが落ちるようになってしまいます。

Failures:
  1) Hoge log ログ出力される
     Failure/Error: expect(logger).to have_received(:info).with('test message')
       #<InstanceDouble(ActiveSupport::Logger) (anonymous)> expected to have received info, but that object is not a spy or method has not been stubbed.

receive matcher は「メッセージを(これから)受信するか」を検査するのに対し、 have_received は名前の通り「メッセージを(これまでに)受信したか」を検査します。

このため、 receive は「テスト対象処理の前」に書く必要があるのに対し、 have_received は「テスト対象処理の後」に書く必要があります。

つまり、以下のように書き換えるのが正解です。

    it 'ログ出力される' do
-     expect(logger).to receive(:info).with('test message')
      described_class.new.log
+     expect(logger).to have_received(:info).with('test message')
    end

double へのメッセージ受信定義

さきほど「以下のように書き換えるのが正解です」と書きましたが、実際にはこの書き換えだけだと別の原因でテストが失敗します。

Failures:
  1) Hoge log ログ出力される
     Failure/Error: Rails.logger.info('test message')
       #<InstanceDouble(ActiveSupport::Logger) (anonymous)> received unexpected message :info with ("test message")

doubleinstance_double では事前に受信可能なメッセージを指定しておかないと、上述のように received unexpected message が発生してしまいます。

元々の処理順では expect(...).to receive の形で info を受信可能にしていましたが、それが消えてしまったわけですね。

なので以下のように allow(...).to receive の形で受信可能なメッセージを指定します。

    it 'ログ出力される' do
+     allow(logger).to receive(:info)
      described_class.new.log
      expect(logger).to have_received(:info).with('test message')
    end

これでテストが通るようになります。

spy への書き換え

今回のように double に対するメソッド呼び出しの戻り値に興味がない場合は、代わりに spy を使うと受信可能なメッセージの指定が不要になります。

spy を利用する場合は以下のように書き換えます。

-   let(:logger) { instance_double(ActiveSupport::Logger) }
+   let(:logger) { instance_spy(ActiveSupport::Logger) }

    before { allow(Rails).to receive(:logger).and_return(logger) }

    it 'ログ出力される' do
      described_class.new.log
      expect(logger).to have_received(:info).with('test message')
    end

spy では mock と違い、事前に指定されていないメッセージの受信時には nil を返すので、事前の allow(...).to receive が消えてシンプルになりました。

一方で意図しないメッセージを受信してもエラーになってくれないため、厳密な呼び出し検査を行う際には mock のほうが適していそうです。

double, spy と instance_double, instance_spy の違い

コード例では double, spy ではなく instance_double, instance_spy を使っています。

doublespy が任意のメッセージを受信可能なのに対し、 instance_doubleinstance_spy は指定したクラスにインスタンスメソッド定義されたメッセージのみ受信できるという違いがあります。

なお今回のコード例で doublespy を利用すると、以下のように RSpec/VerifiedDoubles ルールに怒られます。

Offenses:
spec/hoge_spec.rb:13:20: C: RSpec/VerifiedDoubles: Prefer using verifying doubles over normal doubles.
    let(:logger) { spy(ActiveSupport::Logger) }

大抵のケースでは特定クラスのインスタンスであることを想定してコードを書くはずなので、 instance_doubleinstance_spy を利用するのが良さそうですね。

まとめ

RSpec/MessageSpies ルールの修正方法と、それに伴う doublespy の違いをまとめました。

receivehave_received というメソッド名を見れば明らかなんですが、よく見ず手癖で修正しようとすると引っかかりやすいので気をつける必要がありそうです。

またこの警告をそもそも受けないよう、普段から「メッセージ受信に対するスタブ」と「メッセージ受信の検証」を分けて考えた上で実装するのが良さそうですね。

宣伝

Leaner Technologies では double と stub を使いこなしたいエンジニアを募集しています!

https://careers.leaner.co.jp/

リーナーテックブログ

Discussion

pentacloudpentacloud

こんにちは。わかりやすく勉強になりました。
.rubocop.ymlファイルを以下のように変更すれば、receiveを使うことができるようになるみたいですね。
https://www.rubydoc.info/gems/rubocop-rspec/1.10.0/RuboCop/Cop/RSpec/MessageSpies

.rubocop.yml
+ EnforcedStyle: receive
hoge_spec.rb
    it 'ログ出力される' do #=> no rubocop error!
      expect(logger).to receive(:info).with('test message')
      described_class.new.log
    end
黒曜黒曜

コメントありがとうございます!
そうですね、おっしゃるとおりRubocop/Rspecの設定によってどちらの記述を標準とするかは変わってきます。
今回はデフォルト設定が have_received になっており、特段のこだわりがなかったため推奨値に従っておこうと書き換えたものになります。
https://github.com/rubocop/rubocop-rspec/blob/master/config/default.yml#L618-L626

ご指摘ありがとうございました!