Rubyでメモ化したい時、 ||=
を使って書くことがあります *1。
以下のコードであれば、クラスメソッド twitter
を使って、クラスインスタンス変数 @twitter
に Api::External::Twitter.new
のインスタンスを設定・メモ化しています。
class Api::External::Client def self.twitter @twitter ||= Api::External::Twitter.new end end
そんな中、メモ化しているメソッドをモックしたテストコードを流したところ RSpec::Mocks::ExpiredTestDoubleError
でテストが落ちたため、対応方法をメモしておきます。
目次
環境
エラーの再現手順
プロダクションコード
今回はRailsアプリを作って再現をしてみます。
まず、TwitterのAPIからツイートやリツイートを取得するような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.
とあったことから、メモ化している部分が怪しいと感じました。
次に似た事例がないかを調べたところ、以下が見つかりました。どちらもメモ化しているメソッドをモックした時に発生しています。
- Rails アプリケーションの不安定なテストを撲滅したい 〜system spec のデバッグ方法とテストを不安定にさせる要因〜 - あらびき日記
- ruby on rails - rspec-mocks' doubles are designed to only last for one example - Stack Overflow
そのため、メモ化しているメソッドをモック時の方法が良くないものと考えられました。
対応
上記の記事にある通り、メソッドのメモ化をとりやめれば、テストがパスするようになります。
ただ、今回はメモ化がどうしても必要という前提の時に、どうやってテストを書くかをみていきます。
remove_instance_variableでインスタンス変数を削除する
Blogの方で紹介されていた内容です。
テストコードの after
にてRubyの remove_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
にてRubyの instance_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
このように差し替えてもテストがパスしました。
参考
メモ化について
- マネーフォワード社内PRに見られるRubyの書き方について – (4) 真理値 | Money Forward Money Forward Engineers' Blog
- Ruby: インスタンス変数初期化のメモ化
||=
はほとんどの場合不要|TechRacho by BPS株式会社
クラスインスタンス変数について
ソースコード
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