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

概要 原著者の許諾を得て翻訳・公開いたします。 英語記事: Cost of stubs in tests 原文公開日: 2017/12/09 著者: Dmitriy Nesteryuk ソフトウェアテストで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!内部のあらゆる部分 … Continue reading ソフトウェアテストでstubを使うコストを考える(翻訳)