はじめに
皆さんstubは好きですか?私は正直嫌いです。しかし、外部と通信を行う機能を作成したりする時には切ってもきれない便利な機能です。今回はそんな機能を使うにあたって沼ったポイントがあったので共有していきます。
開発環境
- Ruby 3以上
- Ruby on Rails 6以上
- Minitest
最初に結論
Object.newをstubする際は、.newされる前に引数が評価されるので注意が必要です。
これだけではわかりづらいと思うので、コードで例を見ながら解説します。
ケーススタディ
以下のような外部とやり取りを行う、GetterServiceがあったとします。外部通信を行うのはGetterClientなので、テストを行う際はGetterClient.newをstubすることになります。
class GetterService < ServiceBase
def call
pp '1'
data_list = getter_client.fetch_data_list
pp '2'
Formatter.format(data_list)
end
private
def getter_client
pp '3'
GetterClient.new(Authorizer.authorizer)
pp '4'
end
end
class Authorizer
def authorizer
pp '5'
end
end
以下はそのテストコードの一例です。
class InsertServiceTest < ActiveSupport::TestCase
test '情報を取得して整形できる' do
client_mock = Minitest::Mock.new
client.expect(:fetch_data_list, 'dummy_response')
GetterClient.stub(:new, client_mock) do
res = GetterService.new.call
# 以下resのassert処理
end
end
end
ここで問題
上記のコードに出力用として「1〜5」の数値をppしていますが、テストを実行した場合、どの順番でこれらが表示されると思いますか?
- 「1→3→4→2」と表示される
- 「1→3→5→4→2」と表示される
- どれも表示されない。エラーになる
正解
正解は2の「1→3→5→4→2」と表示される、です!
...ゑ?どうして「5」が表示されるの?GetterClientが.newされる時にstubされるから引数のApiAuthorizer.authorizerは呼ばれないはずでは?
そう思った方、私と同じです。仲良くしましょう。
解説
ここでのポイントは、「GetterClientが.newされるときにstubしているからAuthorizer.authorizerは呼ばれないのでは?」という部分です。
確かにGetterClientが.newされた段階でstubは正常に働いており、その後本来のGetterClientが呼ばれることはありません。そう、 「.newされた段階」でstubが効いているのであって、.newされる前はstubが適応されていない のです。
具体的には、GetterClient.new(Authorizer.authorizer)では、GetterClientが.newされる前にAuthorizer.authorizerが評価されています。つまり、.newの引数が評価されてから、.newが実行されるという順序になっているのです。
Rubyの評価順序について
Rubyのリファレンスマニュアルを見てみると、
まずレシーバ式を評価してレシーバとなるオブジェクトを得ます。レシーバ式が省略された場合は呼び出しを行っているブロックのself がレシーバです。
続いて引数式を左から右の順番で評価し、レシーバに対してメソッドの検索を行います。検索が失敗したら例外 NameError を発生、成功したらメソッドを実行します。
とあります。まとめると、
- レシーバ式(ここでは
GetterClient)が評価される - 引数式(
Authorizer.authorizer)が左から右の順で評価される - レシーバに対してメソッドが実行される
コードで表すと以下のようになります。
class ExampleService
def call
ExampleObject.new(call_first, call_second)
end
private
def call_first
pp '1'
end
def call_second
pp '2'
end
end
class ExampleObject
def initialize(callable1, callable2)
@callable1 = callable1
@callable2 = callable2
pp '3'
end
end
ExampleService.new.call
# "1"
# "2"
# "3"
このコードでは、ppが評価される順序に従ってppを配置しています。実際にコンソールで実行してみると、上記の順番で表示されるはずです。
つまり、Object.newをstubしようとすると、.newの中身が評価された後にstubが効くことになります。
まとめ
Object.newをstubする際も含めて、Rubyの言語仕様として、.newの引数が先に評価されることを覚えておくといつか役に立つかもしれません。今回の例のような単純な処理であれば問題ありませんが、テスト環境で動作しないような処理が含まれている場合には、原因が特定しづらくなる可能性があるので、注意が必要です(1敗)