ポイントシステム(6) -- Message Expectation
2013/10/04
前回の末尾で「次はスパイについて書きます」と予告したのですが、改めていろいろと調べてみると私が思っていたのと違っていて、話がうまくまとまりそうもないことが分かってきました。装飾に使うスパイの写真も選んであったので非常に残念ですが、今回は見送ることにします。
ReceptionDesk
クラスの責任
前回、「残る課題」として、ReceptionDesk#sign_in
のテストが RewardManager
クラスの振る舞いに依存しているため、RewardManager
クラスの仕様変更によってテストが落ちるようになる可能性がある点を指摘しました。
問題のエグザンプルのコードは次の通りです:
specify 'ログインに成功すると、ユーザーの保有ポイントが1増える' do expect { ReceptionDesk.new(customer.username, 'correct_password').sign_in }.to change { customer.points }.by(1) end
これから開発が進んで「土曜日ログインすると通常のログインポイントは別に2ポイント加算される」という仕様が実装されると、曜日によってテストが成功したり失敗したりするようになります。
このような状態のテストを、「壊れやすい(fragile)」と形容します。
実は、先ほどのエグザンプルの説明文を次のように言い換えれば、問題が解決します:
specify 'ログインに成功すると、RewardManager#grant_login_pointsが呼ばれる'
この文は、RewardManager
クラスの仕様に関わらず常に真です。
結局のところ、ログインポイントを付与するか付与しないか、何ポイント付与するのかは、RewardManager
クラスが面倒を見てくれます。受付係としては RewardManager
に「お客様にログインポイントをお出しして!」と伝えれば責任を果たしたことになります。
Message Expectation
言い換え後の文を RSpec のエグザンプルとして表現すると、次のようになります:
specify 'ログインに成功すると、RewardManager#grant_login_pointsが呼ばれる' do expect_any_instance_of(RewardManager).to receive(:grant_login_points) ReceptionDesk.new(customer.username, 'correct_password').sign_in end
1行目を一般的に書き直すと次のようになります:
expect_any_instance_of(X).to receive(:y)
クラス X
の任意のインスタンスの :y
メソッドが呼ばれる、という意味です。なぜ、ここで receive
というメソッドが使われているのでしょうか。Ruby では、オブジェクトからオブジェクトにメッセージが送られる、という比喩でメソッドコールを表現します。つまり、クラス X
の任意のインスタンスが :y
というメッセージを「受け取る(receive)」ことを「期待する(expect)」のです。
このように、あるオブジェクトが特定のメッセージを受け取ることを事前に宣言するタイプのテストを「Message Expectation」と呼びます。
このタイプのテストは、通常のテストとは書き方が異なります。
通常のテストでは、
do_something expect(object).to ...
のように、テストの対象となっているコード(do_something
)を実行した後にそのコードの結果を調べるのですが、「Message Expectation」の場合は、
expect(object).to receive(:a_method) do_something
のように、テストの対象となっているコードを実行する前に、特定のメソッド呼び出しの有無について宣言するのです。
一般的な Message Expectation の書き方
RSpec には様々な Message Expectation の書き方が用意されていますが、とりあえずは次の4種類を覚えておけば十分です:
expect(x).to receive(:y)
expect(x).not_to receive(:y)
expect_any_instance_of(X).to receive(:y)
expect_any_instance_of(X).not_to receive(:y)
前半の2つでは x
が任意のオブジェクト(クラスを含む)を表します。そのオブジェクトが :y
というメッセージを受け取るか受け取らないかを宣言します。
後半の2つでは X
が任意のクラスを表し、そのクラスの任意のインスタンスが :y
というメッセージを受け取るか受け取らないかを宣言します。
例えば RewardManager#grant_login_points
が呼ばれないことを表現するには、次のように書きます:
expect_any_instance_of(RewardManager).not_to receive(:grant_login_points)
ReceptionDesk#sign_in
のエグザンプルグループの末尾に次のコードを加えてください:
specify 'ログインに失敗すると、RewardManager#grant_login_pointsは呼ばれない' do expect_any_instance_of(RewardManager).not_to receive(:grant_login_points) ReceptionDesk.new(customer.username, 'wrong_password').sign_in end
次回は
ReceptionDesk#sign_in
のテストにはまだ改善の余地があります。次回は、context
メソッドの使い方について説明します(気が変わらなければ)。では、また。
補遺 (2013-10-08)
RSpec Mocks リポジトリの Issue #336 で、主要コミッターである Myron Marston が「any_instance
の使用は悪い兆候(code smell)なので、RSpec 3.0 ではデフォルトで無効にしてはどうか」と提案しています。
現時点では結論は出ておらず、早急に any_instance
や expect_any_instance_of
が使えなくなる可能性は低いと思われますが、傾聴に値する議論です。
先ほどの Issue では、なぜ any_instance
の使用が「悪い兆候」なのかは明確に説明されていません。が、おそらくは「あるクラスの任意のインスタンス」では対象が広すぎるということです。
クラス X
のインスタンスはアプリケーションのどこででも、何個でも生まれる可能性があります。そのうちの1つがあるメッセージを受けるかどうかを調べても厳密なテストとは言えない、ということではないかと思われます。
では、any_instance
を使わずに書くとどうなるでしょうか。次のエグザンプルは書き換え前です:
specify 'ログインに成功すると、RewardManager#grant_login_pointsが呼ばれる' do expect_any_instance_of(RewardManager).to receive(:grant_login_points) ReceptionDesk.new(customer.username, 'correct_password').sign_in end
書き換え後は次のようになります(いくつか本連載で説明していないメソッドを使用しています):
specify 'ログインに成功すると、RewardManager#grant_login_pointsが呼ばれる' do reward_manager = double RewardManager.stub(:new).with(customer).and_return(reward_manager) expect(reward_manager).to receive(:grant_login_points) ReceptionDesk.new(customer.username, 'correct_password').sign_in end
こうすれば、特定のオブジェクト customer
を引数にしてインスタンス化された RewardManager
オブジェクトのみを対象に grant_login_points
メソッドが呼び出されたかどうかを調べられます。
書き換えによってテストはより厳密になりましたが、テストコードの量はかなり増えています。厳密さを取るか、簡潔さを取るか。迷うところです。
現時点では、上記のようなエグザンプルでは any_instance
を使ってもいいのではないかと私は考えています。しかし、まだ結論は出ていません。