Android MockとRoboGuiceでTDD
Androidアプリ開発(に限った話ではないですが)でTDDしたいと思ったときに、テスト対象クラスのフィールドをモックで差し替えたい、と思うことがしばしばあります。依存するクラスの振る舞いを固定化することで、テスト対象オブジェクトの振る舞いだけに着目したテストケースを書くことができるからです。
そんな時に、DIコンテナ上でコードを書いていると便利です。以前、少しだけSeasar2+EasyMockでテストを書いていたことがあったのですが、作成したモックオブジェクトの差し替えを、ほぼ全てSeasar2がやってくれたのでものすごく便利でした。
Android開発でもSeasar2+EasyMockくらい簡単にテストを書きたい!
ということで、
- Android Mockでモックオブジェクトとその振る舞いを定義
- RoboGuiceでモックオブジェクトをテスト対象クラスにインジェクト
ということをやってみました。
準備
RoboGuice導入
roboguice - Project Hosting on Google Code
RoboGuiceは、Android Framework上でDIを実現するためのフレームワークです。Google GuiceというDIライブラリを拡張してAndroidに対応させています。
導入方法については
と、それを受けた
が詳しいのでそっちを参照で。
Android Mock導入
android-mock - Project Hosting on Google Code
Android Mockは、Androidで使えるEasyMockです。
導入方法については、Androdテスト部の方が翻訳されたドキュメント
が詳しいのでそちらを参照してください。
テスト対象コード
さて準備については丸投げしたところで(おい)、いよいよDIを活用したテストコードを書いてみます。
今回テストするのは以下のようなActivityです。
TopActivity
public class TopActivity extends GuiceActivity { @InjectView(R.id.text) private TextView textView; @Inject private Person person; @Override public void onCreate(Bundle savedInstanceState) { super.onCreate(savedInstanceState); setContentView(R.layout.main); if (person.getName().equals("droid")) { person.setColor("green"); } else { person.setColor("white"); } textView.setText(person.getName()); } }
機能は単純で、onCreate()で、personフィールドにセットされたオブジェクトのnameが"droid"だったらsetColor("green")を呼び出し、そうでなければsetColor("white")を呼び出す、というものです。
@Injectが指定されているpersonフィールドへの代入は、Roboguiceが行ってくれていて、「Personインターフェースのinject要求に対しては、Droidクラスを差し込め」ということをApplicationクラス(とそこから呼び出されるModuleクラス)で指定しています。
MyApplication.java
public class MyApplication extends GuiceApplication { @Override protected void addApplicationModules(List<Module> modules) { modules.add(new MyModule()); } }
MyModule.java
public class MyModule extends AbstractAndroidModule { @Override protected void configure() { bind(Person.class).to(Droid.class); } }
さて、先ほどのActivityのテストを書こうとしたときに
- personフィールドがdroidのときと、そうでないときの両ケースをテストしたい
- setColorに対して期待値通りの引数が指定されて呼び出されていることを確認したい
ということが実現できると、テストケースとしてはバッチリです。
これをAndroid MockとRoboGuiceで実現します。
テストコード
public class TestCase extends ActivityUnitTestCase<TopActivity> { final class MyMockModule extends AbstractAndroidModule { private Person mockPerson; public void setMock(Person person) { this.mockPerson = person; } @Override protected void configure() { bind(Person.class).toInstance(mockPerson); } } final class MyMockApplication extends GuiceApplication { private Module myModule; public void setMyModule(Module myModule) { this.myModule = myModule; } @Override protected void addApplicationModules(List<Module> modules) { if (myModule == null) { throw new IllegalArgumentException("Please call setMyModule before running the tests!"); } modules.add(myModule); } MyMockApplication(Context context) { super(); attachBaseContext(context); } } public TestCase() { super(TopActivity.class); } @Override protected void setUp() throws Exception { super.setUp(); } @MediumTest @UsesMocks(Droid.class) public void testMockDroid1() { // create a mock and learn it's behavior Droid mockDroid = AndroidMock.createMock(Droid.class); AndroidMock.expect(mockDroid.getName()).andStubReturn("mock"); mockDroid.setColor("white"); AndroidMock.replay(mockDroid); // set up mock application object Context context = getInstrumentation().getTargetContext(); MyMockApplication application = new MyMockApplication(context); MyMockModule myMockModule = new MyMockModule(); myMockModule.setMock(mockDroid); application.setMyModule(myMockModule); setApplication(application); // start activity Intent intent = new Intent(context, TopActivity.class); TopActivity activity = startActivity(intent, null, null); TextView textView = (TextView) activity .findViewById(com.polysfactory.roboguice_and_mock.R.id.text); // verify assertEquals("mock", textView.getText()); AndroidMock.verify(mockDroid); } @MediumTest @UsesMocks(Droid.class) public void testMockDroid2() { // create a mock and learn it's behavior Droid mockDroid = AndroidMock.createMock(Droid.class); AndroidMock.expect(mockDroid.getName()).andStubReturn("droid"); mockDroid.setColor("green"); AndroidMock.replay(mockDroid); // set up mock application object Context context = getInstrumentation().getTargetContext(); MyMockApplication application = new MyMockApplication(context); MyMockModule myMockModule = new MyMockModule(); myMockModule.setMock(mockDroid); application.setMyModule(myMockModule); setApplication(application); // start activity Intent intent = new Intent(context, TopActivity.class); TopActivity activity = startActivity(intent, null, null); TextView textView = (TextView) activity .findViewById(com.polysfactory.roboguice_and_mock.R.id.text); // verify assertEquals("droid", textView.getText()); AndroidMock.verify(mockDroid); } }
肝となるのは、
final class MyMockModule extends AbstractAndroidModule { ... @Override protected void configure() { bind(Person.class).toInstance(mockPerson); } }
の部分です。このbindによって、Personクラスの@Inject要求に対しては、モックオブジェクトがセットされるようになります。
モックの振る舞いは、各テストメソッドの
Droid mockDroid = AndroidMock.createMock(Droid.class); AndroidMock.expect(mockDroid.getName()).andStubReturn("droid"); mockDroid.setColor("green"); AndroidMock.replay(mockDroid); ... AndroidMock.verify(mockDroid);
の部分で指定しており、モックに対する動作を検証することもできます。
仮にDI(RoboGuice)を使わずに、personフィールドをonCreateの中でnewしていたら、このように簡単にテストを書くことはできません。personフィールドに何がセットされるかについては、newしたクラスの中身が完全に把握していないと分からないからです。
課題
@InjectViewや@InjectExtraのフィールドにモックインジェクションが出来ない
今回のテストでは、@Injectを指定したフィールドに対してモックをセットしています。
@Injectによるインジェクションは、実体に対するマッピングを自分でカスタマイズすることが出来るので、モックの差し替えが容易でしたが、@InjectViewや@InjectExtraなどのRoboguiceが提供するその他のインジェクションタイプに対しては、モックの差し替えはできなそうでした。(Roboguice自体に、モックと差し替えられるような拡張ポイントを用意してあげる必要があると思います。)
実際の開発では、@InjectViewや@InjectExtraもかなり便利なので、是非ともモックに差し替えてテストを書いてみたいところです。
RoboGuiceの性能
上記の方法は元々のアプリケーションがRoboGuiceで作られていることが前提となっています。
RoboGuiceの性能に関しては、Androidで動かすことを前提に、かなり気を使ったコードを書いているように見えますが、DIの宿命としてリフレクションを多用していますし、性能は気になります。
ただこれも、「推測するな、計測せよ」の原則に則ってきちんと計測すべきですね^^;
上記について詳しい方、いらっしゃいましたらアドバイス下さい!!