Ruby
Rails
RSpec
stripe

[Rails5] RSpecでモック化の処理を複数テスト間で共有する

背景

Stripeを利用したアプリケーションのテストを書きたいと思った時に、テストが走る度に毎回 Stripe にリクエストが送信されるのを防ぐため、 Stripe クラスの各メソッドをモック化する必要があった。
example ごとに毎回モック化の処理を書くわけにはいかないので、モック化の処理を複数のファイル間で共有できるように考えてみた。

環境

$ bundle exec rails -v
Rails 5.2.0

$ bundle exec rspec -v
RSpec 3.7
  - rspec-core 3.7.1
  - rspec-expectations 3.7.0
  - rspec-mocks 3.7.0
  - rspec-rails 3.7.2
  - rspec-support 3.7.1

結論

  • spec/support/ 配下に disable_stripe_macros.rb を配置して、共通処理を擁する module を定義する。
  • spec/rails_helper で上記モジュールを include する
  • spec/requests/xxxx/create_spec.rb 内で before { disable_stripe } のように共通処理を呼び出す

この手順でモック化の処理を共有できた。

事前準備

  • 必要なgemは下記の通り。
Gemfile
gem 'stripe'

group :development, :test do
  gem 'rspec-rails'
end
  • rails_helper の下記の行のコメントアウトを外す。
spec/rails_helper
Dir[Rails.root.join('spec/support/**/*.rb')].each { |f| require f }

1. 共通処理を定義する

目標

  • Stripe::Account, Stripe::Plan, Stripe::Customer 等のStripeが提供するクラスについて、 retrivecreate といった頻繁に利用するメソッドをモック化する。

方針

  • double を用いてレスポンスをモック化し、上記のクラスメソッドが呼び出された時にそのモックを返すように設定する。

ソースコード

spec/support/disable_stripe_macros.rb
module DisableStripeMacros
  def disable_stripe
    # 返り値のモックを作る
    response = double("response mock")
    allow(response).to receive(:id).and_return("sample-id")
    allow(response).to receive(:to_json).and_return("{}")

    # 対象のクラスメソッドをまとめてモック化する
    classes = [Stripe::Account, Stripe::Plan, Stripe::Customer]
    method_names = [:create, :retrieve]

    classes.each do |klass|
      method_names.each do |method_name|
        allow(klass).to receive(method_name).and_return(response)
      end
    end
  end
end

解説

# 返り値のモックを作る
response = double("response mock")
allow(response).to receive(:id).and_return("sample-id")
allow(response).to receive(:to_json).and_return("{}")

double を用いてモックオブジェクトを作成し、アプリケーション内でよく使っていた response.id と response.to_json が返り値を持つように設定する。

# 対象のクラスメソッドをまとめてモック化する
classes = [Stripe::Account, Stripe::Plan, Stripe::Customer]
method_names = [:create, :retrieve]

classes.each do |klass|
  method_names.each do |method_name|
    allow(klass).to receive(method_name).and_return(response)
  end
end

例えば allow(Stripe::Account).to receive(:create).and_return(response) と書けば、 Stripe::Account.create がモックオブジェクト response を返すようになる。
それを、よく使うメソッドをまとめてモック化できるようにループして回しているだけ。

2. 共通処理を include する

rails_helper に下記を追加すると、各テスト用のファイルで disable_stripe を呼び出せるようになる。

spec/rails_helper
RSpec.configure do |config|
  # 下記を追加する
  config.include DisableStripeMacros
end

3. 共通処理を呼び出す

テスト用のファイルで呼び出してみる。

spec/requests/xxxx/create_spec.rb
require "rails_helper"

describe "XxxxController#create" do
  before { disable_stripe } # モック化の処理を呼び出す

  it "returns mock object" do
    it "returns mock object" do
      response = Stripe::Account.retrieve("xxx")
      expect(response.id).to eq("sample-id")
      expect(response.to_json).to eq("{}")
    end
  end
end

テストを実行してみる。

$ bundle exec rspec spec/requests/xxxx/create_spec.rb
XxxxController#create
  returns mock object

Finished in 0.0336 seconds (files took 1.44 seconds to load)
1 example, 0 failures

無事にモック化に成功した模様。

注意点

せっかくなので、上記の結論に辿り着くまでに試行錯誤する中で分かったことも追記。
最初は shared_context を利用すれば実現できるかなと思っていたが、この方法には下記2つの問題があった。

  • double は example group 内では呼び出せない
  • shared_context は example group 内でしか呼び出せない

詳細

例えば、下記のように shared_context を定義する。

spec/support/disable_stripe.rb
shared_context "disable stripe" do
  response = double("response mock")
  allow(response).to receive(:id).and_return("sample-id")

  allow(Stripe::Account).to receive(:create).and_return(response)
end

これを下記のように呼び出す。

spec/requests/xxxx/create_spec.rb
require "rails_helper"

describe "XxxxController#create" do
  include_context "disable stripe" # 共通処理を呼び出そうとする

  it "returns mock object" do
    it "returns mock object" do
      response = Stripe::Account.create("xxx")
      expect(response.id).to eq("sample-id")
    end
  end
end

これを実行してみる。

$ bundle exec rspec spec/requests/xxxx/create_spec.rb

An error occurred while loading ./spec/requests/xxxx/create_spec.rb
Failure/Error: response = double("response mock")
  `double` is not available on an example group (e.g. a `describe` or `context` block). It is only available from within individual examples (e.g. `it` blocks) or from constructs that run in the scope of an example (e.g. `before`, `let`, etc).

doubledescribecontext ブロック内では呼び出せないから、 itbeforelet の中で呼び出しなさいよ。」とのこと。
ならばということで、 before ブロックに移動してみる。

spec/requests/xxxx/create_spec.rb
require "rails_helper"

describe "XxxxController#create" do
  before do
    include_context "disable stripe"
  end

  it "returns mock object" do
    it "returns mock object" do
      response = Stripe::Account.create("xxx")
      expect(response.id).to eq("sample-id")
    end
  end
end

これを実行してみる。

$ bundle exec rspec spec/requests/xxxx/create_spec.rb

XxxxController#create
  returns mock object (FAILED - 1)

Failures:

  1) XxxxController#create returns mock object
     Failure/Error: include_context "disable stripe"
       `include_context` is not available from within an example (e.g. an `it` block) or from constructs that run in the scope of an example (e.g. `before`, `let`, etc). It is only available on an example group (e.g. a `describe` or `context` block).
     # ./spec/requests/xxxx/create_spec.rb:5:in `block (2 levels) in <top (required)>'

Finished in 0.02739 seconds (files took 1.39 seconds to load)
1 example, 1 failure

include_contextitbeforelet の中では呼び出せないから、 describecontext ブロック内で呼び出しなさいよ。」とのこと。さっきと真逆じゃないか。
従って、 doubleshared_context は完全に一緒には使えない様子。 module を共有する方針がいいみたい。

参考

書いた後に気付いたんだけど、こんなものもあるんだね。