RSpecの書き方で最近悩んだこと3つとその解決法について

こんにちは、冬になると首がつりやすくなるラクマの高橋です。

ラクマではRubyを主に使って開発しており、テストフレームワークはRSpecを採用しています。

RSpecの書き方について、最近躓いた3選を今回はお届けします。

検証環境

  • ruby: 2.6.5
  • rails: 6.0.3.2
  • rspec-rails: 3.9.0

module単体に対してテストを書きたい

moduleをいろんなクラスで使用することを想定するとき、テストを特定のクラスに依存させて書くのはよくなさそうです。

そんなとき、どうすればよいか悩んだのですが、こんなふうに書くと、特定のクラスに依存せず、moduleをinclude/extendしたクラスのテストができそうでした。

Class.new { include Module } して作成したダミーのクラスに対しテストする

# app/models/concerns/good_module.rb

module GoodModule
  def ok
    'ok!'
  end
end
# spec/models/concerns/good_module_spec.rb

describe GoodModule do
  context '特異メソッドとして使う場合' do
    let(:dummy_class) { Class.new { extend GoodModule } }
    subject { dummy_class.ok }

    it { expect(subject).to eq 'ok!' }
  end

  context 'インスタンスメソッドとして使う場合' do
    let(:dummy_class) { Class.new { include GoodModule } }
    let(:dummy_class_instance) { dummy_class.new }
    subject { dummy_class_instance.ok }

    it { expect(subject).to eq 'ok!' }
  end
end

こんな感じで書いてみます。

$ bundle exec rspec spec/models/concerns/good_module_spec.rb                         
..

Finished in 0.0046 seconds (files took 1.79 seconds to load)
2 examples, 0 failures

実行できました!

インスタンス変数の書き込みメソッドをモックしたい

モックを利用すると、テストしやすい場面があると思います。

# app/models/concerns/sendable.rb

module Sendable
  def send_mail(message, email)
    message.to = email
    puts 'send!'
  end
end

こんな感じの処理を作りましたが、messageクラスをまだ作っていません。 モックにして先にテストだけしてみたいと思います。

# spec/models/concerns/sendable_spec.rb

describe Sendable do
  let(:dummy_class) { Class.new { extend Sendable } }
  let(:message) { double('message') }

  subject { dummy_class.send_mail(message, 'example@example.com') }

  before do
    allow(message).to receive(:to)
  end

  it { subject }
end

いざ実行すると…

$ bundle exec rspec spec/models/concerns/sendable_spec.rb 
F

Failures:

  1) Sendable 
     Failure/Error: message.to = email
       #<Double "message"> received unexpected message :to= with ("example@example.com")
     # ./app/models/concerns/sendable.rb:6:in `send_mail'
     # ./spec/models/concerns/sendable_spec.rb:9:in `block (2 levels) in <top (required)>'
     # ./spec/models/concerns/sendable_spec.rb:15:in `block (2 levels) in <top (required)>'

Finished in 0.01396 seconds (files took 1.74 seconds to load)
1 example, 1 failure

received unexpected message となってしまいました。 これだと想定通りに動きません。

attr_accessorの対象となるインスタンス変数に=をつける

ただ、よく考えてみると、attr_accessorの定義は

Module#attr_accessor (Ruby 3.0.0 リファレンスマニュアル)

def name
  @name
end
def name=(val)
  @name = val
end

こうなので、モックも

# spec/models/concerns/sendable_spec.rb

  before do
    # =を追加する
    allow(message).to receive(:to=)
  end

これが正しかったのでした。

メソッドチェーンをモックする

例えば、Time.zone.now.hourのようなメソッドチェーンが返す値をモックしたいときがあると思います。

# app/models/concerns/checkable.rb

module Checkable
  def ok
    Time.zone.now.hour == 10
  end
end

早速RSpecを書いてみます。

# spec/models/concerns/checkable_spec.rb

describe Checkable do
  let(:dummy_class) { Class.new { extend Checkable } }

  subject { dummy_class.ok }

  before do
    allow(Time).to receive('zone.now.hour').and_return(10)
  end

  it { expect(subject).to be true }
end
$ bundle exec rspec spec/models/concerns/checkable_spec.rb
F

Failures:

  1) Checkable 
     Failure/Error: allow(Time).to receive('zone.now.hour').and_return(10)
       Time does not implement: zone.now.hour
     # ./spec/models/concerns/checkable_spec.rb:11:in `block (2 levels) in <top (required)>'

勘で書いてみましたが、やっぱり無理でした。。

receive_message_chainを使う

そういう場合は、RSpec側にこれを想定したメソッドが用意されているのでそれを利用します。

# spec/models/concerns/checkable_spec.rb

  before do
    allow(Time).to receive_message_chain(:zone, :now, :hour).and_return(10)
  end
$ bundle exec rspec spec/models/concerns/checkable_spec.rb
.

Finished in 0.00916 seconds (files took 1.65 seconds to load)
1 example, 0 failures

これで、無事、メソッドチェーンでも、特定の値を返すことができました。

終わり

RSpecのドキュメントを読むと、こんな書き方できたんだという発見が多いです。 テストに助けられることが多いので、今後も転ばぬ先の杖として、需要にあったテストを書けるように、RSpec力を鍛えていきたいと思いました。