Tech Racho エンジニアの「?」を「!」に。
  • Ruby / Rails関連

ソフトウェアテストでstubを使うコストを考える(翻訳)

概要

原著者の許諾を得て翻訳・公開いたします。

ソフトウェアテストでstubを使うコストを考える(翻訳)

本記事では、テストで何かと見かけるstubについて考察します。stubやmockは便利だと思う人もいれば、そう思わない人もいたりします(stubとmockは別物ですが、両者の違いは本記事の範疇ではないため、まとめてstubと呼ぶことにします: どうかご了承ください)。この話題は私が働いているチームではすっかり落ち着いていたのですが、最近になってまた話題にのぼったので、この際私の考えをざっくりここにまとめることにしました。誤りや見落としがありましたらぜひお知らせください。

かつての私は、依存をstubするのが大好きな開発者でした。テストが簡単に書けますし、読みやすく、しかもシンプルです。

class Customer
  def order_fee
    if inherit_fee?
      company.fee
    else
      fee
    end
  end
end

class Order
  def total
    subtotal + customer.order_fee
  end
end

上のコードから、feeは、それに関連する特定の1人のcustomerまたは1つのcompanyについて定義できることがわかります。依存をstubする(実際はメソッドの呼び出しですが)テストは次のようになります。

describe Order do
  let(:customer) { Customer.new }
  let(:order)    { Order.new(subtotal: 100, customer: customer) }

  describe '#total' do
    context 'feeを持つcustomer' do
      before do
        allow(customer).to receive(:order_fee).and_return(21)
      end

      it 'feeを含む合計額を返す' do
        expect(order.total).to eq(121)
      end
    end

    context 'feeを持たないcustomer' do
      before do
        allow(customer).to receive(:order_fee).and_return(0)
      end

      it 'feeを含まない合計額を返す' do
        expect(order.total).to eq(order.subtotal)
      end
    end
  end
end

customerやcompanyのfeeを設定する代わりに、単にCustomer#order_feeの結果をstubで塞いでいます。「そこは本物のcustomerオブジェクトじゃなくてstubオブジェクトを使うんじゃね?」とツッコまれそうですね。もちろん、できます。

describe Order do
  let(:customer) { instance_double(Customer, order_fee: 21) }
  # テストをここに書く
end

しかしあまり変わり映えしません。もうひとつの方法は、本物のメソッドの呼び出しを持つ本物のオブジェクトを使うことです。

describe Order do
  let(:customer) { Customer.new }
  let(:order)    { Order.new(subtotal: 100, customer: customer) }

  describe '#合計' do
    context 'feeを持つ顧客' do
      before do
        customer.fee = 21
      end

      it 'feeを含む合計額を返す' do
        expect(order.total).to eq(121)
      end
    end

    context 'feeを持たない顧客' do
      before do
        customer.fee = 0
      end

      it 'feeを含まない合計額を返す' do
        expect(order.total).to eq(order.subtotal)
      end
    end
  end
end

私が好きなのは最後のアプローチなので、このままこのテストを使おうと思います。もしかすると「テストで網羅できてないケースがあるよね?feeがcompanyから継承されるケースもテストしなきゃ」とツッコまれるかもしれません。おっしゃるとおりです。しかしCustomer#order_feeの部分はCustomerクラス用に書いたテストでカバーされているのですから、同じことを繰り返す理由はありません。もしここで第3のケースがCustomer#order_feeに追加されたら、あなたならOrder#totalのテストに戻って新しいケースのテストをまたひとつ追加しますか?stubを使うテストの方がよいと思うでしょう。見た目にも簡単ですし、Customer#order_feeの戻り値に注目すればよく、依存で何が発生するかを気にする必要もありません。

安全性を高める結合テストとcontractor test

訳注: contractor testに定訳がないため本記事では英ママとしました

stubを使う単体テストで最も用心しなければならないのは、実際のオブジェクトと協調動作する結合テスト(あるいはcontractor test?)も必要になる点です。そうしたテストがないと、単体テストがgreenになっても本番のコードが失敗します。私たちの結合テストで扱う操作に8つのオブジェクトが関わっているとしましょう。これを保証するには、正常に動作するコードでこれらすべてのオブジェクトにアクセスするようになっていなければなりませんが、これらのオブジェクトですべてのケースをカバーするテストが必要ということではありません。たとえば、Customer#order_feeで実際のcompanyオブジェクトにアクセスするような結合テストがない場合、そのcompanyオブジェクトでcustomerオブジェクトが正常に動作するという証拠もないということになります。

したがって、オブジェクトをstubすれば単体テストはシンプルになりますが、その分結合テストが複雑になります。さらにCustomerクラスに新しく依存が追加されたときに、あなたなら結合テストをチェックして新しい依存がcustomerオブジェクトで正常に機能するようにしますか?単体テストで実際のオブジェクトを使っておけば、オブジェクトが依存性と協調して動作することはチェック済みになるので、信頼性が高まります。しかしこれも銀の弾丸ではありません。

依存のケースの取りこぼし

最新のテストセットの「ケースの取りこぼし」の話題に戻りましょう。前述のとおり、Customer#order_feeのすべてのケースをOrder#totalのテストでカバーするのはたぶんおかしいでしょう。そのテストはCustomer#order_feeのケースのテストではなくOrder#totalのテストであり、orderオブジェクトとcustomerオブジェクトの協調動作を確認するためのものだからです。したがって、操作に関連するオブジェクトの一部を結合テストで取りこぼしてしまうと、本番のバグをキャッチできる可能性が下がってしまいます。オブジェクトのやりとりは単体テストで既にカバーされているからです。

次のコードはもっと違う実装にできるのではないかとツッコまれるかもしれませんが、テストはパスします。

class Order
  def total
    subtotal + customer.fee
  end
end

確かにテストはパスしますが、subtotalが121に等しい場合にもテストはパスしてしまいます(テスト対象のオブジェクトの設定で何かしくじったのかもしれませんね)。これはTDD(テスト駆動開発: 先にテストを作成/変更してからコードの作成や変更を行う)を行う理由のひとつです。それもこれも信頼性のためです。Customer#order_fee用に書かれたテストを信頼しないのであれば、ActiveRecord#save!は信頼できるでしょうか?ActiveRecord#save!を使うときに、あなたなら以下のケースをテストしますか?

  • DB接続がない場合にエラーをraiseする
  • テーブルが存在しない場合にエラーをraiseする
  • フィールドが存在しない場合にエラーをraiseする
  • ActiveRecord#save!内部のあらゆる部分

コードを書くときには常にメンテナンスコストにも気を遣う必要があります。そのコストはいずれ誰かが払わなければなりません。結合テストでは多数のオブジェクトが関連するので、関連するいくつかのオブジェクトへのアクセスパスを見落とす可能性がうんと高まります。繰り返しますが、バグの可能性が高まれば誰かがバグ修正のコストを支払わなければならなくなります。バグを見逃せば損害が発生することをどうかお忘れなく。営業チームにとっていい迷惑です(もちろんあなたにとっても)。

stubを使う意味がある場合

stubはどんな場合にも避けるべきであると言いたいのではありません。特定の状況では非常に有用です。

SinonJsをご存知でしょうか。このライブラリはAjaxレスポンスをstub化できます。

this.server.respondWith(
  'GET',
  '/some/article/comments.json',
  [200, { 'Content-Type': 'application/json' }, '[{ "id": 12, "comment": "こんちわ" }]']
);

これはstubのユースケースとして完璧です。最も低レベルな部分をstubしているので、コードのさまざまなレイヤにわたるリクエストのテストを実行すれば、コードが本番でちゃんと動作することを検証できます。

ライブラリを書くときにも、ライブラリの依存をstubできます。Faraday gemはこのアプローチのよい適用例です。

テストを高速にするstub

テストが高速になるという理由でstubを好む人もいます。私はこう思います: 普通ならテストが遅くなったときに「プロジェクトでパフォーマンスの問題が発生してるぞ」と誰かが気づく可能性がありますが、stubを使うとテスト中のパフォーマンス低下が隠蔽されてしまいます。

まとめ

コーディング上のこだわりはこの際抜きにして、作業しているコードの一貫性を保つようにしましょう。さもないと、チームに加わった新メンバーが戸惑い、開発速度も落ちてしまいます。

関連記事

Ruby: テストを不安定にする5つの残念な書き方(翻訳)

RSpecで役に立ちそうないくつかのヒント(翻訳)

[Rails] RSpecのモックとスタブの使い方

[Rails] RSpecをやる前に知っておきたかったこと


CONTACT

TechRachoでは、パートナーシップをご検討いただける方からの
ご連絡をお待ちしております。ぜひお気軽にご意見・ご相談ください。