🧪

RSpec の change マッチャにおけるレシーバとメッセージ、ブロックの違い

2024/04/03に公開

こんにちは。st-1985 です。

RSpecでオブジェクトの状態の変化をテストするには、changeマッチャが便利です。
このマッチャには、レシーバとメッセージを渡す方式と、ブロックを渡す方式の2つの記述方式があります。
今回はレシーバとメッセージを渡す方式を使用していてうまくいかないケースがあったので、そちらについて書こうと思います。

change マッチャ

changeマッチャは、以下のようにレシーバとメッセージ(本例だとobject:an_attribute)を渡して使います。

# my_method を実行すると an_attribute が before から after に変化する事を確認
expect { object.my_method }
    .to change(object, :an_attribute)

change(object, :an_attribute)の箇所はブロックを渡す方式もあります。

# my_method を実行すると an_attribute が before から after に変化する事を確認
expect { object.my_method }
    .to change { object.an_attribute }

同じような働きをしますが、内部では

  • レシーバとメッセージを渡す: object.my_method 実行前と後で、メモ化されたレシーバに対して2回メッセージが実行される
  • ブロックを渡す      : object.my_method 実行前と後で、2回ブロックが実行される

という挙動の違いがあります。
この違いにより、思ったようにテストが動作しない場合があります。

レシーバとメッセージを渡す方式で上手くいかないケース

前述のようにレシーバはメモ化されているため、メッセージがレシーバの状態に依存しており、expectによってレシーバが更新されない場合はテストがうまくいきません。

例えば、APIリクエストによってDBの状態が更新される事をテストするケースを考えてみます。

expect { api_request }
    .to change(
        Model.find_by(identifier:'request_identifier'), :an_attribute
    ) # did not change

Model.find_by(identifier:'request_identifier')はメモ化されるため、取得した時点の状態のまま変わらず、api_requestでもそのインスタンスは更新されないためan_attributeの結果は変わりません。

これはブロックを渡す方式にする事で解消可能です。

expect { api_request }
    .to change {
        Model.find_by(identifier:'request_identifier').an_attribute
    } # changed

こちらでは渡したブロックがapi_request の実行前後で行われるため、データの再取得が行われ変更をテストする事ができます。

まとめ

  • changeマッチャの引数には2種類の方式がある
  • レシーバとメッセージを渡す方式はレシーバがメモ化されて、そのレシーバに対してメッセージが2回実行される
  • ブロックを渡す方式はブロックが2回実行される
  • メッセージがレシーバの状態に依存して、レシーバが更新されない場合はブロックを渡す方式を使うと良い

この記事がお役に立てれば幸いです。

おまけ:ブロックを渡す方式だと上手くいかないケースを考える

プライベートメソッドの実行

ブロックを渡す方式ではプライベートメソッドを直接呼び出せないため上手くいきません。

expect { object.my_method }
    .to change { object.my_private_method } # NoMethodError

レシーバとメッセージを渡す方式ではうまくいきます。

expect { object.my_method }
    .to change(object, :my_private_method) # changed

これは、changeマッチャの内部でレシーバに対して__send__でメッセージを実行しているためになります。

という事は、ブロックを渡す方式でも__send__もしくはsendを利用する事でテストを成立させる事ができます。

expect { object.my_method }
    .to change { object.send(:my_private_method) } # changed

sendを使用する是非は置いておいて、プライベートメソッドに関してもレシーバとメッセージを渡す方式でないと成立しないとは言えないのでこのケースは該当しなそうです。

他にケースが思いつかないので、思いついたら追記したいと思います。

SocialPLUS Tech Blog

Discussion