設計ツールとしてのモックの使い方について考える。
導入
先日、"Mock Roles, not Objects"の日本語版「ロールをモックせよ」を公開しました。この論文は2004年に書かれたもので、著者はSteve Freeman氏、Nat Pryce氏、Tim Mackinnon氏、Joe Walnes氏という豪華メンバーです。また、Steve Freeman氏とNat Pryce氏は『Growing Object-Oriented Software, Guided by Tests (Addison-Wesley Signature Series (Beck))』(いわゆるGOOS)の著者でもあり、"Mock Roles, not Object"で語られている思想はGOOSのベースになっているとも言えます。
今回は、この"Mock Roles, not Objects"(以下、MRnO)で語られているモックの基本的な考え方について、GOOSを意識しつつ掘り下げてみたいと思います。
設計手法としてのモック
まずは"MRnO"で紹介されているサンプルを元に具体的なコードを見ていきましょう(なお、このエントリで紹介するサンプルは"MRnO"を元にjMock2に移植したものです)。実装する仕様は以下の通りです:
オブジェクトをロードするフレームワークに対してキーを元にした検索を行い、その結果をキャッシュするコンポーネントを考えてほしい。ロードされてから一定時間が経つと、そのインスタンスは使えなくなる。そこで、時々リロードをしなければならなくなる。
Mock Roles, not Objects(p.7)
最初はシンプルな正常ケースを書きます。
@Test public void キャッシュされていないオブジェクトはロードする() throws Exception { final ObjectLoader mockLoader = context.mock(ObjectLoader.class); context.checking(new Expectations() { { oneOf(mockLoader).load("KEY1"); will(returnValue("VALUE1")); } }); TimedCache cache = new TimedCache(mockLoader); assertThat((String) cache.lookup("KEY1"), is("VALUE1")); }
「cache.lookup()のタイミングで、TimedCacheのコンストラクタに渡されているmockLoaderのloadが呼び出され、その際には"VALUE1"が返されるのでlookupの戻り値も"VALUE1"になる」というからくりです。重要なことですが、この時点ですでにObjectLoaderというコラボレーター(隣接オブジェクト)が発見されています。図示するとこんな感じ:
1つ補足しておくと、ここでObjectLoaderをモック化しているのは、それが外部リソース(DBなど)にアクセスしているっぽかったり、サードパーティーライブラリを想定していたりするからではありません。テスト対象のオブジェクトが動作する上で必要なコラボレーターを、テストを書きながら発見することがモックを使ったTDDの中核です。
この後、2回目に呼ばれたときにはObjectLoaderを呼び出さずにキャッシュを使うテストも当然書かなければいけないのですが、アサート文が2つになるだけですので省略します。キャッシュを実装した段階でコードはこうなっています:
public class TimedCache { final private ObjectLoader loader; final private Map<String, Object> cachedValues = new HashMap<String, Object>(); public TimedCache(ObjectLoader loader) { this.loader = loader; } public Object lookup(String key) { if (!cachedValues.containsKey(key)) { cachedValues.put(key, loader.load(key)); } return cachedValues.get(key); } }
さて、ここで時間の概念を導入しましょう。仕様書にあるこの一文ですね。「ロードされてから一定時間が経つと、そのインスタンスは使えなくなる。そこで、時々リロードをしなければならなくなる。」これに対応すると、テストはこうなります:
@Test public void タイムアウト後のキャッシュされたオブジェクトはロードする() throws Exception { final Clock mockClock = context.mock(Clock.class); final ObjectLoader mockLoader = context.mock(ObjectLoader.class); final ReloadPolicy mockPolicy = context.mock(ReloadPolicy.class); final Timestamp loadTime = new Timestamp("2011/09/17 00:00:00.000"); final Timestamp fetchTime = new Timestamp("2011/09/17 00:00:01.000"); // 1秒後 final Timestamp reloadTime = new Timestamp("2011/09/17 00:00:02.000"); // 2秒後 context.checking(new Expectations() { { exactly(3).of(mockClock).getCurrentTime(); will(onConsecutiveCalls(returnValue(loadTime), returnValue(fetchTime), returnValue(reloadTime))); exactly(2).of(mockLoader).load("KEY"); will(onConsecutiveCalls(returnValue("VALUE"), returnValue("NEW-VALUE"))); atLeast(1).of(mockPolicy).shouldReload(loadTime, fetchTime); will(returnValue(true)); } }); TimedCache cache = new TimedCache(mockLoader, mockClock, mockPolicy); assertThat("ロードされたオブジェクト", (String) cache.lookup("KEY"), is("VALUE")); assertThat("キャッシュされたオブジェクト", (String) cache.lookup("KEY"), is("NEW-VALUE")); }
図に表すとこんな感じ:
モックを使ったTDDのユニットテストが通常のTDDと大きく違う点が、実はここに現れています。通常のTDDの場合、こうしたコラボレーターが登場するのは「レッド・グリーン・リファクタリング」のうち、主に「リファクタリング」のプロセスになります。まずは動くようにした上で、より適切な責務分割を目指すわけですね。
それに対して、モックを使うとコラボレーターは、テストを書いている段階すなわちレッドの手前で登場します。実際に写経してみるとわかるのですが、登場するオブジェクトとそうしたオブジェクト間のインタラクションを相当はっきりイメージできていないと、テストを書くことができません。
この意味で、モックを使ったテスト駆動開発ではテストを書くことが設計することに直結するわけです。ただし、ここで設計されるのはあくまでオブジェクト間のインタラクションであり、クラスではありません。「では、クラスはどうやって設計するのか?」という疑問が当然出てくるわけですが、GOOSを読むと、いったんはインターフェイスの匿名クラスとして実装し、あとから名前のついた適切なクラスを抽出するというスタイルがとられていることがわかります。そうして抽出されたクラスの内部で責務があいまいだと感じた場合には、再びユニットテストを書いてコラボレーターを発見するというプロセスを繰り返すことになります。
まず受け入れテストを書く
これが、このアプローチが「外から内へ」と言われる理由です。外側(=システムへのエントリポイント)に近い場所からユニットテストを書き、インターフェイスを発見しながら内側へと入っていくわけです。すると当然、「最初のテストはどこから書き始めるのか」という疑問が生じると思いますが、それに対する答えは「まず受け入れテストを書く」ということになります。全体を図示するとこんな感じでしょうか:
実際には、受け入れテストをグリーンにする過程で必要に応じてユニットテストを書きつつインターフェイスを発見し、グリーンになった後も責務があいまいなクラスに対してまたユニットテストを書き、という具合にループが2重になっています。一見、受け入れテストの話とユニットテストの話は別々のものであるようにも見えます。しかし、モックを使ったユニットテストが、オブジェクト間のインタラクションという内側にフォーカスしている分、システムが全体として正しく動いているかという外側を明示的に意識する必要があると考えれば、両者をまとめて1つのプロセスとする考え方は筋が通っていると言えるでしょう。「受け入れテスト+モックを使ったユニットテスト」をセットで考えれば、ユニットテストを書くという行為自体が従来のリファクタリングフェーズに当たると考えることができるかもしれません。
なお、受け入れテストから始めるというこの考え方は、ふるまい駆動開発(BDD:behaviour-driven development)とも強い親和性があります。すなわち、「システムが何をすべきかをテストで表現する」ことにより、どこから始めればよいか、どうすればそのフィーチャの実装が完了したと言えるのかという問いに同時に答えることができるのです。
最後に
プログラマにとって、コーディングをしながら構造を発見していくプロセスというのはかなりエキサイティングに感じられるものですが、現実問題として本当に何もないところからこれを始めるのはいつになったら終わるのか全然わからないという怖さがあります。したがって、ある程度は事前に分析/設計を行い、大まかな構成のメドを立てておく必要があるということになります。よく批判される"Big Design Up Front"を避けつつも、事前の分析/設計を適切に行うバランス感覚が重要だということですね(それが簡単にできれば苦労はしないという話ですが)。
モックに興味を持たれた方は、ぜひ「ロールをモックせよ」を読んでみてください。ここで取り上げたこと以外にも重要な示唆が色々と書かれています。また、サンプルコードはこちらで公開しています。