ポイントシステム(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種類を覚えておけば十分です:

  1. expect(x).to receive(:y)
  2. expect(x).not_to receive(:y)
  3. expect_any_instance_of(X).to receive(:y)
  4. 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_instanceexpect_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 を使ってもいいのではないかと私は考えています。しかし、まだ結論は出ていません。