次世代のモックフレームワークであるJMockitの基本的な使い方
以前のモックフレームワークの技術的制約
今まで私が担当してきたプロジェクトにおいては、モックオブジェクトを使ったJUnitの単体試験はjMockとEasyMockのいずれかのフレームワークを利用して行ってきました。しかし、これらのフレームワークはJavaプラットフォームにおけるコード自動生成の考え方の変遷で説明したように動的プロキシーに基づいているため、以下のような制約がありました。
- モック化する対象の型はインターフェースを実装しているか、継承可能なクラスであること
- モック化するメソッドはfinal、static、privateでないこと*1
- モック化するロジックはコンストラクターの呼び出しではないこと
- モックオブジェクトをテスト対象クラスにDIかパラメーター経由で引き渡すことが可能であること
- モック化する場合はクラス全体をモック化する必要があること(getterやsetterなどは本物の実装を使い、複雑なロジックを持つ特定のメソッドのみをモック化するといったことは不可能)
もちろん、テストファースト開発やリファクタリングによって、こうした制約を回避しながら試験しやすいコードを記述することが大切なのですが、場合によっては上記の制約があまりにも大きく、試験を通すためだけの目的で過剰に複雑な設計を強いられていたという事実があることも否定できません。
ここで紹介するJMockitというフレームワークを使うと、上記の問題をほぼ完全に解決することが可能です。JMockit自体はお互いに独立した複数のAPIから構成されており、従来のモックフレームワークに比べると相当大規模になっています。ここでは、基本的な使い方と上記の問題点がどのように解決されるのかについての具体例に絞って説明させていただきます。JMockitの包括的な説明はかなり良く書かれたチュートリアルがありますのでそちらを参照してください。
http://jmockit.googlecode.com/svn/trunk/www/tutorial.html
JMockitのインストール
とりあえず試してみるだけならJMockitのインストールは非常に簡単です。実際、以下からダウンロードし、
Google Code Archive - Long-term storage for Google Code Project Hosting.
適当なフォルダーに展開したらクラスパスにjmockit.jarを追加するだけです。ただし、ここでのポイントはJUnitのライブラリーより先にjmockitが読み込まれるようにする必要があるという点です。たとえば、eclipseの場合、
のようにビルドパスの設定で順番を指定します。なお、Java6でなくてJava5の場合はVMオプションに-javaagent:jmockit.jarを付ける必要があるみたいです。また、JUnit4を使う場合バージョン4.5以降が必要なようです。詳しくは以下に説明があります。
http://jmockit.googlecode.com/svn/trunk/www/gettingStarted.html
それから、ビルドプロセスに組み込む場合、AntやMavenの設定方法は以下に説明があります。
http://jmockit.googlecode.com/svn/trunk/www/tutorial/RunningTests.html#ant
JMockitの最も基本的な使い方
JMockitには大きく分けて「Expectations API」「Verification API」「Annotations API」の3通りのAPIが用意されていますが、とりあえず従来のjMockのような試験を置き換えることを考えるのであれば、他の2種類のAPIはいったん完全に無視して「Expectations API」の使い方のみを理解すれば十分だと思います。
たとえば、典型的なSpringアプリケーションのサービス層で以下のようなインターフェースがあったとします。
public interface PersonService { void persistPerson(Person person); }
そして、このサービスインターフェースを実装する以下のようなクラスがあったとします。
@Service public class PersonServiceImpl implements PersonService { @Inject private PersonDao personDao; @Override public void persistPerson(Person person) { long id = personDao.persist(person); person.setId(id); } }
ここでは、サービスクラスにPersonDaoというインターフェースを実装するDAOクラスがDIされており、また、DAOクラスがPersonデータを永続化した際に採番されたIDを戻り値として返すということを想定しています。この場合、このサービスクラスの単体試験を行うためには、サービスクラスが依存するPersonDaoの振る舞いを模倣するモックオブジェクトを作成し、インジェクションしてやる必要があります。このような試験は伝統的なモックフレームワークでも簡単に記述できますが、JMockitの場合、以下のように記述することができます。
package test; import static org.junit.Assert.assertEquals; import mockit.Deencapsulation; import mockit.Expectations; import mockit.Mocked; import org.junit.Before; import org.junit.Test; public class PersonServiceImplTest { // テスト対象クラス PersonServiceImpl target = new PersonServiceImpl(); // テスト対象クラスの動作に必要なフィクスチャ // PersonDaoのモックフィールド // モックオブジェクトは自動的に生成されてこのフィールドにインジェクションされる。 @Mocked PersonDao personDao; // モック化されない普通のオブジェクト Person person = new Person(); @Before public void setUp() throws Exception { // テスト対象クラスにモックDAOをインジェクションする。 // ここではJMockitのリフレクションユーティリティを使ってprivateフィールドに設定している。 Deencapsulation.setField(target, personDao); // パラメーターオブジェクトを初期化 person.setAge(30); person.setName("test"); } @Test public void persistPerson() { // モックオブジェクトに対して期待動作を宣言 new Expectations() {{ personDao.persist(person); result = 1L; }}; // テスト対象クラスの呼び出し target.persistPerson(person); // テスト呼出し後の確認 assertEquals(1L, (long)person.getId()); // モックオブジェクトが期待通り呼び出されたことは自動的にベリファイされる。 } }
基本的に今までjMockなどを使ったことのある人であれば、すぐに理解できる内容であると思います。
- モック化したい対象の型(PersonDao)をテストクラスのフィールドで宣言し@Mockedアノテーションを付加する。
- setUp()メソッドでテスト対象クラスにモックオブジェクトをインジェクションする。
- テストメソッドの先頭でExpectationsの無名内部クラスを生成し、そのインスタンス初期化ブロック中でモックオブジェクトに対する期待動作を宣言する。(ここではpersonDaoのpersist()メソッドが1回だけ呼び出され、戻り値は1Lとなることを宣言している。)
- テスト対象オブジェクトを実際に呼び出す。
- 必要に応じてテスト対象呼び出し後の状態を確認する。
という流れになります。
モックオブジェクトを宣言する3種類の場所
先ほどの基本的な例ではテストクラスの@Mockedアノテーションつきのフィールドとして宣言することで、単体試験の実行に必要なモックオブジェクトが自動的に代入されることを説明しました。この方法は同一テストクラスの複数のテストメソッド間で同じモックオブジェクトを共有する場合には便利です。しかし、特定のテストメソッドのみで使用したいモックオブジェクトがある場合は、以下の別の方法が提供されています。
テストメソッドのパラメーターとしてモックオブジェクトを宣言する
普通、JUnitのテストメソッドにパラメーターを宣言することはありませんが、JMockitの場合は特殊な記法として、以下のようにテストメソッドのパラメーターとして宣言されたクラスは自動的にモックオブジェクトとなります。(@Mockedを付けても良いが通常は省略される。)
@Test public void persistPerson(final PersonDao personDao) { // テスト対象クラスにモックDAOをインジェクションする Deencapsulation.setField(target, personDao); // モックオブジェクトに対して期待動作を宣言 new Expectations() {{ personDao.persist(person); result = 1L; }}; // テスト対象クラスの呼び出し target.persistPerson(person); // テスト呼出し後の確認 assertEquals(1L, (long)person.getId()); // モックオブジェクトが期待通り呼び出されたことは自動的にベリファイされる }
Expectaionsの内部クラスのフィールドとしてモックオブジェクトを宣言する
以下のように、Expectaionsの内部クラスのフィールドとして宣言されたクラスも自動的にモックオブジェクトとなります。Expectaionsごとにローカルなモックオブジェクトを利用したい場合に便利です。この場合も@Mockedは省略可能です。
@Test public void persistPerson() { // モックオブジェクトに対して期待動作を宣言 new Expectations() { PersonDao personDao; { // テスト対象クラスにモックDAOをインジェクションする setField(target, personDao); personDao.persist(person); result = 1L; }}; // テスト対象クラスの呼び出し target.persistPerson(person); // テスト呼出し後の確認 assertEquals(1L, (long)person.getId()); // モックオブジェクトが期待通り呼び出されたことは自動的にベリファイされる }
厳密な呼び出し順序の確認が不要で単にスタブ化したい場合
@Mockedの代わりに@NonStrictを使うと指定した型に対するモックの呼び出し順序と呼び出し回数に対するチェックがかからなくなります。また、new Expectations()の代わりにnew NonStrictExpectations()と記述することで、特定の期待動作に限定して呼び出しチェックをはずすことができます。
モックオブジェクトが複数回呼び出される場合の記述方法
同一パラメーターで同じモックオブジェクトが複数呼び出される場合、Expectaionsの宣言の宣言の中でresultフィールドに複数の結果を再代入することで、代入した順番にモックオブジェクトに異なる戻り値を返却させることができます。
@Test public void persistPerson() { // モックオブジェクトに対して期待動作を宣言 new Expectations() {{ personDao.persist(person); result = 1L; result = 2L; result = 3L; }}; // テスト対象クラスの呼び出し target.persistPerson(person); assertEquals(1L, (long)person.getId()); target.persistPerson(person); assertEquals(2L, (long)person.getId()); target.persistPerson(person); assertEquals(3L, (long)person.getId()); // モックオブジェクトが期待通り呼び出されたことは自動的にベリファイされる }
なお、別の書き方として、resultフィールドに対する代入を行う代わりにreturns()メソッドを呼び出す以下の形式で記述することもできます。
@Test public void persistPerson() { // モックオブジェクトに対して期待動作を宣言 new Expectations() {{ personDao.persist(person); returns(1L, 2L, 3L); }}; // テスト対象クラスの呼び出し target.persistPerson(person); assertEquals(1L, (long)person.getId()); target.persistPerson(person); assertEquals(2L, (long)person.getId()); target.persistPerson(person); assertEquals(3L, (long)person.getId()); // モックオブジェクトが期待通り呼び出されたことは自動的にベリファイされる }
同じパラメーターで戻り値が同じ場合、あるいは戻り値がvoidの場合は以下のようにtimesフィールドを用いて呼び出し回数を宣言する方法もあります。
@Test public void persistPerson() { // モックオブジェクトに対して期待動作を宣言 new Expectations() {{ personDao.persist(person); result = 3L; times = 3; }}; ... }
もちろん、モック呼び出しのパラメーターが異なったり、複数のモックオブジェクトを呼び出す場合は、new Expectations()の中で順番に必要な回数だけ呼び出してやることができます。さらに、そのnew Expectations()の呼び出しパターンが複数回繰り返される場合には、以下のようにExpectationsのコンストラクターに数値を渡すことで繰り返し回数を指定できます。
@Test public void persistPerson() { // モックオブジェクトに対して期待動作を宣言 new Expectations(3) {{ personDao.persist(person); result = 3L; }}; ... }
モックオブジェクトに例外を発生させる場合
モックオブジェクトに例外を発生させて例外発生パスの試験を行うことは簡単です。resultフィールドに発生させたい例外を代入すればよいだけです。ここでは3回のモック呼び出しに対して3回目のみ例外を発生させる例を書いています。
@Test public void persistPerson() { // モックオブジェクトに対して期待動作を宣言 new Expectations() {{ personDao.persist(person); result = 1L; result = 2L; result = new RuntimeException("test"); }}; // テスト対象クラスの呼び出し target.persistPerson(person); assertEquals(1L, (long)person.getId()); target.persistPerson(person); assertEquals(2L, (long)person.getId()); // 3回目の呼び出しで例外が発生 try { target.persistPerson(person); } catch (RuntimeException ex) { assertEquals("test", ex.getMessage()); } }
モックオブジェクトのパラメーターのあいまいなマッチング
今までの例のようにExpectationsブロックの中でモックオブジェクトを普通に呼び出した場合、パラメーターのマッチングはequals()比較となります。jMockにもあるように、あいまいなマッチングをさせることもできます。具体的な方法については、以下を参照してください。
http://jmockit.googlecode.com/svn/trunk/www/tutorial/BehaviorBasedTesting.html#hamcrest
モックに渡されたパラメーターの中身の確認とパラメーターオブジェクトの変更を行う場合
ここからが、jMockなどの伝統的なモックライブラリーでは実装が困難となるところであり、jMockitの本領を発揮する領域となります。
今までの例ではPersonDaoが戻り値としてIDを返し、その戻り値をPersonServiceImpl側で設定するという想定で話をしてきました。しかし、実際にはDAOクラスの側でIDを採番したのだから、メソッド呼び出しの副作用としてDAOがPersonに対して自動的にIDフィールドを設定するということの方が自然です。ただし、この場合モックの呼び出しに伴う副作用として引き渡されたパラメーターの状態を変更する必要があるため、単純に戻り値や例外をモックに返却させるという今までのExpectationsの記述では不十分です。JMockitではモック呼び出しの直後にDelegationと呼ばれるオブジェクトを記述することで、モックに渡されたパラメーターをチェックしたり変更したりすることが簡単にできるようになっています。*2
@Test public void persistPerson() { // モックオブジェクトに対して期待動作を宣言 new Expectations() {{ personDao.persist(person); result = new Delegate() { // ブロックに呼び出しのパラメーターが渡される void persist(Person person) { assertEquals(30, person.getAge()); assertEquals("test", person.getName()); person.setId(1L); // パラメーターの状態を書き換えられる } }; }}; // テスト対象クラスの呼び出し target.persistPerson(person); assertEquals(1L, (long)person.getId()); }
メソッドパラメーターの状態を書き換えるのはJavaでは一般的ではありませんが、outパラメーターとして使ったり、Collecting Parameter実装パターンを利用したりするような場合にはまれにパラメーターの状態変更が必要になります。ここで紹介した方法を使うことで、このような場合にも簡単に対処することができます。
DAOがDIではなくてメソッド内部で直接newされている場合
DIを使うことで、今までのようにモックオブジェクトをインジェクションして簡単に試験することができました。もちろんこれはDIの重要なメリットなのですが、場合よってはDIの環境が利用できなかったり、わざわざDIを使うことが大げさだったりするような場合もあるかもしれません。仮にPersonServiceImplの実装が以下のようになっていたとします。
@Override public void persistPerson(Person person) { PersonDao personDao = new PersonDaoImpl(); personDao.persist(person); }
従来このようなケースをきちんと単体試験することは非常に難しかったのですが、JMockitでは以下のようにすることで難なく対処できます。
import static org.junit.Assert.assertEquals; import mockit.Deencapsulation; import mockit.Delegate; import mockit.Expectations; import mockit.Mocked; import org.junit.Before; import org.junit.Test; @SuppressWarnings("unused") public class PersonServiceImplTest { // テスト対象クラス PersonServiceImpl target = new PersonServiceImpl(); // テスト対象クラスの動作に必要なフィクスチャ // PersonDaoのモックフィールド // モックオブジェクトは自動的に生成されてこのフィールドにインジェクションされる // capture属性で指定した最大回数まで新規にnewされたインスタンスで置き代わる @Mocked(capture = 1) PersonDao personDao; // モック化されない普通のオブジェクト Person person = new Person(); @Before public void setUp() throws Exception { // パラメーターオブジェクトを初期化 person.setAge(30); person.setName("test"); } @Test public void persistPerson() { // モックオブジェクトに対して期待動作を宣言 new Expectations() {{ personDao.persist(person); result = new Delegate() { // ブロックに呼び出しのパラメーターが渡される void persist(Person person) { assertEquals(30, person.getAge()); assertEquals("test", person.getName()); person.setId(1L); // パラメーターの状態を書き換えられる } }; }}; // テスト対象クラスの呼び出し target.persistPerson(person); assertEquals(1L, (long)person.getId()); } }
この場合のポイントは@Mockedアノテーションでcapture属性を指定しているところです。試験実行中に新しいインスタンスがnewされた場合は、captureで指定された最大回数まで自動的にモックで置き換え、さらにフィールドに対して再代入してくれます。慣れないと非常に不思議な感じがしますが、バイトコードを書き換えてしまうことでこのようなマジックが実現されてしまいます。
staticメソッドやfinalクラスのモック化
JMockitではstaticメソッドやfinalなメソッド、クラスをモックで置き換えてしまう事すら可能です。当然JDKの標準クラスに対しても適用できます。以下の例は検証目的以外では無意味な内容ですが、本来は空のListを返すべきemptyList()メソッドに対して、[1,2,3]という要素を含んだListを返すようにExpectationsを記述することで、実際に本物のColletionsのメソッドの動作を変更しています。
@Mocked final Collections collections = null; @Test public void sampleTest() { new Expectations() {{ Collections.emptyList(); result = Arrays.asList(1,2,3); }}; assertEquals(Arrays.asList(1,2,3), Collections.emptyList()); }
ちなみに、
@Mocked final Collections collections = null;
の記述はこのようなインスタンスの存在しないUtilsクラスをモック化する際のイディオムです。final宣言し、nullを代入しておくことで無駄にモックオブジェクトのインスタンスを生成しない動作となります。
部分的なモック化
クラスをモック化する場合に、getterやsetterなど間違いようのない部分の実装はモックにせず本物の実装を利用しながら、部分的なロジックの部分のみをモックとして置き換えたい場合があります。そうすることでgetterの呼び出しなどをわざわざExpectationとして宣言する必要がなくなります。部分的なモック化に対しては以下の2種類の方法が提供されています。
静的な部分モック化
モック化する範囲が静的にあらかじめ決まる場合は、以下のように@Mockedアノテーションのmethods属性かvalue属性でモック対象のメソッド(inverse指定すれば論理を逆転してモック対象外のみ指定することも可)を指定できます。たとえば、以下の例ではPersonというPOJOのsayHello()メソッドのみモックで置き換えていますが、getter、setterのロジックは本物のクラスの実装を流用しています。
@Mocked("sayHello") Person person; @Test public void sampleTest() { person.setAge(30); person.setName("test"); new Expectations() {{ person.sayHello(); result="Hello World"; }}; assertEquals("Hello World", person.sayHello()); assertEquals(30, person.getAge()); assertEquals("test", person.getName()); }
なお、対象メソッドは複数指定でき、また正規表現のようなパターンマッチも可能です。詳しくは以下の参照してください。
http://jmockit.googlecode.com/svn/trunk/www/tutorial/BehaviorBasedTesting.html#staticPartial
動的な部分モック化
モック化する対象が静的に決まらない場合、以下のようにExpectationのコンストラクターにモック対象のクラスか、インスタンスを指定する記法が利用できます。以下はインスタンスを指定する場合です。
@Test public void sampleTest() { final Person person = new Person(); person.setAge(30); person.setName("test"); new Expectations(person) {{ // コンストラクタで部分モック化対象を指定(インスタンス指定) person.sayHello(); result="Hello World"; }}; assertEquals("Hello World", person.sayHello()); assertEquals(30, person.getAge()); assertEquals("test", person.getName()); }
一方、以下はクラスを指定する場合です。
@Test public void sampleTest() { final Person person = new Person(); person.setAge(30); person.setName("test"); new Expectations(Person.class) {{ // コンストラクタで部分モック化対象を指定(クラス指定) person.sayHello(); result="Hello World"; }}; assertEquals("Hello World", person.sayHello()); assertEquals(30, person.getAge()); assertEquals("test", person.getName()); }
クラス指定の場合はその特定のクラスのメソッドのみがモック化の対象範囲となり、インスタンス指定の場合は継承されているものも含めてすべてのメソッドがモック化の対象となります。いずれの場合もExpectationsのブロックの中で実際に呼び出されたメソッドのみがモック実装で置換され、それ以外メソッドは本物の実装が利用されることになります。