概要
原著者の許諾を得て翻訳・公開いたします。
- 英語記事: 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!
内部のあらゆる部分
コードを書くときには常にメンテナンスコストにも気を遣う必要があります。そのコストはいずれ誰かが払わなければなりません。結合テストでは多数のオブジェクトが関連するので、関連するいくつかのオブジェクトへのアクセスパスを見落とす可能性がうんと高まります。繰り返しますが、バグの可能性が高まれば誰かがバグ修正のコストを支払わなければならなくなります。バグを見逃せば損害が発生することをどうかお忘れなく。営業チームにとっていい迷惑です(もちろんあなたにとっても)。
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を使うとテスト中のパフォーマンス低下が隠蔽されてしまいます。
まとめ
コーディング上のこだわりはこの際抜きにして、作業しているコードの一貫性を保つようにしましょう。さもないと、チームに加わった新メンバーが戸惑い、開発速度も落ちてしまいます。