はじめに
皆さん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敗)