Rails + RSpecで、メモ化しているメソッドをモックしたら RSpec::Mocks::ExpiredTestDoubleError になったので、調べてみた

Rubyでメモ化したい時、 ||= を使って書くことがあります *1

以下のコードであれば、クラスメソッド twitter を使って、クラスインスタンス変数 @twitterApi::External::Twitter.newインスタンスを設定・メモ化しています。

class Api::External::Client
  def self.twitter
    @twitter ||= Api::External::Twitter.new
  end
end

 
そんな中、メモ化しているメソッドをモックしたテストコードを流したところ RSpec::Mocks::ExpiredTestDoubleError でテストが落ちたため、対応方法をメモしておきます。

 
目次

 

環境

 

エラーの再現手順

プロダクションコード

今回はRailsアプリを作って再現をしてみます。

 
まず、TwitterAPIからツイートやリツイートを取得するようなAPIクライアントがあったとします。

また、ここでは実装していませんが、このAPIクライアントを生成(new)する際は色々手間がかかっているものとします。

class Api::External::Twitter
  def tweet
    'foo'
  end

  def retweet
    'bar'
  end
end

 
次に、外部クライアントを取りまとめているクラスがあり、ここの中で各クライアントをインスタンス化しているとします。

上記で書いた通り、Twitterクライアントの生成が大変なので、今回はここのクラスメソッド twitter() でメモ化をしています。

class Api::External::Client
  def self.twitter
    @twitter ||= Api::External::Twitter.new
  end
end

 
これらのクライアントライブラリを使い、RailsアプリでTwitterからツイートやリツイートを取得して返すようなコントローラを作ったとします。

(tweetがindex、showがretweetに割り当てられているのは特に理由はありません。説明の都合上、1つのコントローラで別のメソッドを実装したかっただけです)

class Api::Memorization::TweetsController < ApplicationController
  def index
    render json: { tweet: Api::External::Client.twitter.tweet }
  end

  def show
    render json: { retweet: Api::External::Client.twitter.retweet }
  end
end

 
routes.rbはこんな感じです。

Rails.application.routes.draw do
  namespace :api do
    namespace :memorization do
      resources :tweets, only: [:index, :show]
    end
  end
end

 
Railsアプリを起動しcurlでアクセスしたところ、こんな感じのレスポンスになります。

% curl http://localhost:3000//api/memorization/tweets
{"tweet":"foo"}

% curl http://localhost:3000//api/memorization/tweets/1
{"retweet":"bar"}

 

テストコード

ここまでで正しく動いていそうなことは確認できたため、次はテストコードを書いてみます。

ただ、プロダクションコードではTwitterから直接ツイート・リツイートを取得している想定なため、そのまま使うと毎回Twitter APIを呼び出してしまうことになります。

そこで、ツイート・リツートしているメソッドをモックで差し替えてみます。

 
まずはツイートを取得するメソッドのテストコードを書きます。

require 'rails_helper'

RSpec.describe 'Api::Memorization::TweetsController', type: :request do
  let(:response_body) { JSON.parse(response.body) }

  describe '[ツイート]: GET /api/memorization/tweets' do
    before do
      # tweetメソッドを差し替え
      twitter = instance_double(Api::External::Twitter)
      allow(Api::External::Twitter).to receive(:new).and_return(twitter)
      allow(twitter).to receive(:tweet).and_return('hello')
    end

    it 'レスポンスが返ってくる' do
      get '/api/memorization/tweets'

      expect(response_body).to eq({ 'tweet' => 'hello' })
    end
  end
end

 
テストを実行するとパスします。

% bundle exec rspec spec/requests/api/memorization/tweets_controller_spec.rb --example '[ツイート]'
Run options: include {:full_description=>/\[ツイート\]/}
.

Finished in 0.0565 seconds (files took 1.13 seconds to load)
1 example, 0 failures

 
続いて、リツイートの方もテストコードを書きます。

describe '[リツイート]: GET /api/memorization/tweets/1' do
  before do
    # retweetメソッドを差し替え
    twitter = instance_double(Api::External::Twitter)
    allow(Api::External::Twitter).to receive(:new).and_return(twitter)
    allow(twitter).to receive(:retweet).and_return('bye')
  end

  it 'レスポンスが返ってくる' do
    get '/api/memorization/tweets/1'

    expect(response_body).to eq({ 'retweet' => 'bye' })
  end
end

 
作成したテストコードを流してみると、こちらもパスしました。

% bundle exec rspec spec/requests/api/memorization/tweets_controller_spec.rb --example '[リツイート]'

Run options: include {:full_description=>/\[リツイート\]/}
.

Finished in 0.04326 seconds (files took 0.95845 seconds to load)
1 example, 0 failures

 
両方ともパスしたため、最後にすべてのテストコードを実行してみます。

すると、2つ目のテストが失敗してしまいました。

% bundle exec rspec spec/requests/api/memorization/tweets_controller_spec.rb                         
.F

Failures:

  1) Api::Memorization::TweetsController [リツイート]: GET /api/memorization/tweets/1 レスポンスが返ってくる
     Failure/Error: render json: { retweet: Api::External::Client.twitter.retweet }
       #<InstanceDouble(Api::External::Twitter) (anonymous)> was originally created in one example but has leaked into another example and can no longer be used. rspec-mocks' doubles are designed to only last for one example, and you need to create a new one in each example you wish to use it for.

 
RubyMineでの実行結果では、冒頭に以下のメッセージが表示されました。 RSpec::Mocks::ExpiredTestDoubleError のようです。

RSpec::Mocks::ExpiredTestDoubleError: #<InstanceDouble(Api::External::Twitter) (anonymous)> was originally created in one example but has leaked into another example and can no longer be used. rspec-mocks' doubles are designed to only last for one example, and you need to create a new one in each example you wish to use it for.

 

調査

エラーメッセージに

rspec-mocks' doubles are designed to only last for one example, and you need to create a new one in each example you wish to use it for.

とあったことから、メモ化している部分が怪しいと感じました。

 
次に似た事例がないかを調べたところ、以下が見つかりました。どちらもメモ化しているメソッドをモックした時に発生しています。

 

そのため、メモ化しているメソッドをモック時の方法が良くないものと考えられました。

 

対応

上記の記事にある通り、メソッドのメモ化をとりやめれば、テストがパスするようになります。

ただ、今回はメモ化がどうしても必要という前提の時に、どうやってテストを書くかをみていきます。

 

remove_instance_variableでインスタンス変数を削除する

Blogの方で紹介されていた内容です。

テストコードの after にてRubyremove_instance_variable によりメモ化用のインスタンス変数を取り除きます。
Object#remove_instance_variable (Ruby 3.1 リファレンスマニュアル)

require 'rails_helper'

RSpec.describe 'Api::Memorization::TweetsController', type: :request do
  let(:response_body) { JSON.parse(response.body) }

  describe '[ツイート]: GET /api/memorization/tweets' do
    before do
      # tweetメソッドを差し替え
      twitter = instance_double(Api::External::Twitter)
      allow(Api::External::Twitter).to receive(:new).and_return(twitter)
      allow(twitter).to receive(:tweet).and_return('hello')
    end

    # このafterを追加
    after do
      Api::External::Client.remove_instance_variable('@twitter')
    end

    it 'レスポンスが返ってくる' do
      get '/api/memorization/tweets'

      expect(response_body).to eq({ 'tweet' => 'hello' })
    end
  end

  describe '[リツイート]: GET /api/memorization/tweets/1' do
    before do
      # retweetメソッドを差し替え
      twitter = instance_double(Api::External::Twitter)
      allow(Api::External::Twitter).to receive(:new).and_return(twitter)
      allow(twitter).to receive(:retweet).and_return('bye')
    end

    # このafterも追加
    after do
      Api::External::Client.remove_instance_variable('@twitter')
    end

    it 'レスポンスが返ってくる' do
      get '/api/memorization/tweets/1'

      expect(response_body).to eq({ 'retweet' => 'bye' })
    end
  end
end

 
こうするとテストがパスします。

 

instance_variable_setでインスタンス変数にnilを設定する

こちらは stackoverflow にて紹介されていた内容です。

テストコードの after にてRubyinstance_variable_set によりメモ化用のインスタンス変数に nil を設定します。

require 'rails_helper'

RSpec.describe 'Api::Memorization::TweetsController', type: :request do
  let(:response_body) { JSON.parse(response.body) }

  describe '[ツイート]: GET /api/memorization/tweets' do
    before do
      # tweetメソッドを差し替え
      twitter = instance_double(Api::External::Twitter)
      allow(Api::External::Twitter).to receive(:new).and_return(twitter)
      allow(twitter).to receive(:tweet).and_return('hello')
    end

    # このafterを追加
    after do
      Api::External::Client.instance_variable_set('@twitter', nil)
    end

    it 'レスポンスが返ってくる' do
      get '/api/memorization/tweets'

      expect(response_body).to eq({ 'tweet' => 'hello' })
    end
  end

  describe '[リツイート]: GET /api/memorization/tweets/1' do
    before do
      # retweetメソッドを差し替え
      twitter = instance_double(Api::External::Twitter)
      allow(Api::External::Twitter).to receive(:new).and_return(twitter)
      allow(twitter).to receive(:retweet).and_return('bye')
    end

    # このafterも追加
    after do
      Api::External::Client.instance_variable_set('@twitter', nil)
    end

    it 'レスポンスが返ってくる' do
      get '/api/memorization/tweets/1'

      expect(response_body).to eq({ 'retweet' => 'bye' })
    end
  end
end

 
この方法でもテストがパスします。

 

メモ化メソッドをモックに差し替える

今回のメモ化メソッドを再掲します。

class Api::External::Client
  def self.twitter
    @twitter ||= Api::External::Twitter.new
  end
end

 

今まではメモ化メソッドの中で使っている Api::External::Twitter.new をモックに差し替えてきました。

ただ、よく見ると今回は Api::External::Twitter.new の結果しかメモ化していない単純な内容のため、メモ化メソッド自体をモックに差し替えることもできそうです。

つまり

allow(Api::External::Twitter).to receive(:new).and_return(twitter)

allow(Api::External::Client).to receive(:twitter).and_return(twitter)

へと差し替えます。

全体像はこちら。

require 'rails_helper'

RSpec.describe 'Api::Memorization::TweetsController', type: :request do
  let(:response_body) { JSON.parse(response.body) }

  describe '[ツイート]: GET /api/memorization/tweets' do
    before do
      # tweetメソッドを差し替え
      twitter = instance_double(Api::External::Twitter)
      allow(Api::External::Client).to receive(:twitter).and_return(twitter) # 差し替え
      allow(twitter).to receive(:tweet).and_return('hello')
    end

    it 'レスポンスが返ってくる' do
      get '/api/memorization/tweets'

      expect(response_body).to eq({ 'tweet' => 'hello' })
    end
  end

  describe '[リツイート]: GET /api/memorization/tweets/1' do
    before do
      # retweetメソッドを差し替え
      twitter = instance_double(Api::External::Twitter)
      allow(Api::External::Client).to receive(:twitter).and_return(twitter) # 差し替え
      allow(twitter).to receive(:retweet).and_return('bye')
    end

    after do
      Api::External::Client.instance_variable_set('@twitter', nil)
    end

    it 'レスポンスが返ってくる' do
      get '/api/memorization/tweets/1'

      expect(response_body).to eq({ 'retweet' => 'bye' })
    end
  end
end

 
このように差し替えてもテストがパスしました。

 

参考

メモ化について

 

クラスインスタンス変数について

 

ソースコード

Githubに上げました。
https://github.com/thinkAmi-sandbox/rails_7_0_rspec_mock_sample

 
今回のプルリクはこちらです。
https://github.com/thinkAmi-sandbox/rails_7_0_rspec_mock_sample/pull/1

*1:対象のメソッドが nil を返す可能性がある場合は、完全なメモ化にはなりませんが