2 つの流儀と進める方向: 状態を見るか、やり取りを見るか
TDDテスト駆動開発 (TDD)実装より先にテストを書き、そのテストを通す形で実装を進める書き方。テストを書く時点が実装より前にあることが要件で、カバレッジを上げる活動とは別。 の流儀の違いは、しばしば「モックモック (Mock)期待する呼ばれ方を先に設定し、その通りに呼ばれたかを検証するテストダブル。検証を自分で行う点がスパイと違う。を使うか使わないか」として語られます。ただし本体はそこではありません。何を見て「正しく動いた」と判定するかが違い、その帰結としてダブルテストダブル (Test Double)テストのために本物の代わりへ差し込むオブジェクトの総称。Dummy・スタブ・スパイ・モック・フェイクの 5 つに分けて呼ぶ。の使い方が変わります。
判定の仕方が変わると、テストが壊れるタイミングが変わります。片方は実装を書き換えたときに黙って通り続け、もう片方は落ちます。どちらが良いかは、そのテストに何を守らせたいかで決まります。
この章で学ぶこと
- 状態検証と相互作用検証の違い
- それぞれのテストが壊れるタイミング
- どこから書き始めるかという方向と、流儀の関係
- 本ガイドがどちらを採るか
この章で扱わないこと
| 観点 | 参照先 |
|---|---|
| どの層にどれだけテストを書くかの配分 | テスト戦略 |
| 置換可能性と契約による設計 | LSP と契約による設計 |
何を見て「通った」とするか
注文を確定して在庫を減らす処理を、2 通りに検証します。
InMemoryInventory と MockInventory は、04 章で書いた Fakeフェイク (Fake)本物と同じ働きをする簡易実装のテストダブル。配列で動くリポジトリのように、書いたものを後から読める。 と Mock を在庫に当てたものです。
状態を見る書き方では、処理の後にオブジェクトやその協力者がどうなっているかを調べます。
test('確定すると在庫が減る', function () {
$inventory = new InMemoryInventory(['sku-1' => 10]);
$useCase = new ShipOrder($inventory);
$useCase->execute($order);
expect($inventory->stockOf('sku-1'))->toBe(9);
});
やり取りを見る書き方では、対象が協力者に対して正しい呼び出しをしたかを調べます。
test('確定すると在庫の引き当てを呼ぶ', function () {
$mock = new MockInventory(expects: 'reserve', with: ['sku-1', 1]);
$useCase = new ShipOrder($mock);
$useCase->execute($order);
$mock->verify();
});
同じ 2 通りを TypeScript で書くと、次のようになります。
// 状態を見る
it('確定すると在庫が減る', () => {
const inventory = new InMemoryInventory({ 'sku-1': 10 })
new ShipOrder(inventory).execute(order)
expect(inventory.stockOf('sku-1')).toBe(9)
})
// やり取りを見る
it('確定すると在庫の引き当てを呼ぶ', () => {
const mock = new MockInventory({ expects: 'reserve', with: ['sku-1', 1] })
new ShipOrder(mock).execute(order)
mock.verify()
})
言語が変わっても、見ている先は同じです。前者は在庫という状態、後者は呼び出しという出来事です。
前者を 状態検証 (state verification)、後者を 相互作用検証 (behavior verification) と呼びます1。
それぞれが壊れるとき
2 つは、壊れる条件が違います。
ShipOrder の中で在庫を減らす手段を変えたとします。reserve() を呼んでいたのを、decrease() を 2 回呼ぶ形に書き換えました。外から見た結果は同じです。
- 状態を見るテストは通ります。 在庫は同じだけ減っているからです
- やり取りを見るテストは落ちます。
reserve()が呼ばれていないからです
これは、やり取りを見るテストの欠点ではありません。何を守らせたいかの違いです。
在庫の減り方だけを守りたいなら、手段が変わっても通ってほしい。状態検証が合います。
一方、「外部の決済 API をちょうど 1 回だけ呼ぶ」ことを守りたいなら、2 回呼ぶ実装に変わったとき落ちてほしい。ここは相互作用検証でしか守れません。結果の状態を見ても、1 回で呼んだか 2 回で呼んだかは分からないからです。
| 守れるもの | 壊れるとき | |
|---|---|---|
| 状態検証 | 結果としてどうなったか | 結果が変わったとき |
| 相互作用検証 | どういう手順で行ったか | 手順が変わったとき (結果が同じでも) |
相互作用検証を広く使うと、リファクタのたびにテストが落ちます。テストが実装の手順に結びつくからです。逆に状態検証だけだと、外部への副作用を確かめられません。
2 つの流儀
どちらを既定にするかで、2 つの流儀が生まれました。
| 流儀 | 既定の検証 | ダブルの使い方 |
|---|---|---|
| classical | 状態検証 | 本物か Fake を使い、扱いにくいものだけ差し替える |
| mockist | 相互作用検証 | 協力者を原則すべてダブルにする |
Fowler は前者を classical、後者を mockist と呼んでいます。地名を使った別名もあり、classical を Detroit、mockist を London と呼ぶ場合があると同記事は述べています1。呼び方は一定していないので、議論では地名でなく「何を検証するか」で確かめた方が早いです。
進める方向
別の軸として、どこから書き始めるかがあります。
- outside-in: 外側から始めます。利用者に近いところで最初のテストを書き、内側へ降りていきます
- middle-out: ドメインから始めます。中心となるモデルを固め、そこから外へ広げます
この 2 つは流儀と無関係ではありません。Fowler は、mockist が outside-in を取り、classical は middle-out を好む傾向があると書いています1。理由は素直です。外側から始めると、内側はまだ存在しないので、ダブルで埋めるしかありません。
ただし、**相関があることと、必ずそうなることは違います。**外側から書き始めて、内側ができた時点でダブルを本物に置き換えていけば、outside-in で進めながら状態検証を主にできます。
本ガイドでの扱い
本ガイドは classical を既定にします。状態検証を主に使い、ダブルは扱いにくい境界にだけ当てます。理由は 2 つです。
**リファクタで落ちないテストの方が、設計を変えやすい。**TDD の 3 拍目は Refactor です。そこで落ちるテストが多いと、拍が回らなくなります。
**ダブルは書いた分だけ、本物とのずれが溜まる。**ダブルは自分で書いた想定です。本物の振る舞いが変わってもダブルは変わらないので、テストは通るのに動かない状態が生まれます。
相互作用検証は、状態では確かめられないものに限って使います。外部への通知、課金、メール送信がその典型で、07 章で扱います。
方向については両方を実演します。06 章から 07 章は middle-out、08 章は outside-in です。同じ題材を 2 方向から書くと、どちらが何を早く決めるのかが見えます。
まとめ
- 流儀の本体は「何を見て通ったとするか」です。状態を見るか、やり取りを見るかの違いです
- 状態検証は手段が変わっても通り、相互作用検証は手段が変わると落ちます。どちらが良いかは守らせたいものによります
- 外部への副作用は状態では確かめられないので、そこは相互作用検証が要ります
- 流儀と進める方向には相関がありますが、組み合わせは固定されていません
- 本ガイドは classical を既定にし、ダブルは境界にだけ当てます
次に読む
- 06 ドメインロジックから始める — middle-out で内側を先に固めます
- 08 歩くスケルトン — 同じ題材を outside-in で書きます