1. はじめに
先日、Rx Androidを使ったアプリで、RxPressoとEspressoを使ってUIテストを行う記事、Rx Androidを使ってSlashdot JapanのモバイルニュースのRSSを取得するアプリを作り、RxPressoでテストしてみたを書きました。しかし、データベースからデータを読み込む際、Rx Androidを使い、データをリスト表示するアプリで、開発を効率的に進めるられるまでの道程で紹介されている手法を使って、データベースファイルをテスト用のデータベースファイルに置き換えて、EspressoでUIテストを行おうとしたのですが、テストがListViewの表示終了を待ってくれず、UIのテストができませんでした。次のリンクに従って、Idling Resourceを使うといいという話なのですが、Rx Androidを使用するケースでは、Idling Resouceの実装がかなり難しいようです。
IdlingResource Espresso with RxJava
digitalbuddha/rxEspresso.java
Rx Androidを使ったアプリは作れるようにはなったのですが、そのUIテストをするには学習コストがかなり高いようです。そこで、方針を変えて、非同期処理に、従来のAsyncTaskLoaderを使い、Google Dagger 2で依存性注入を行い、ダミーデータを用いて、UIのテストを行ってみました。題材は、先日紹介したhttps://github.com/andropenguin/SladMobileRssReaderをAsyncTaskLoaderを使うように書き換えたものです。
2. テスト対象アプリ
記事一つひとつを表すクラスは次の通りです。
Item
public class Item { // 記事のタイトル private CharSequence mTitle; // リンク private String mLink; // 記事の本文 private CharSequence mDescription; public Item() { mTitle = ""; mDescription = ""; } public CharSequence getDescription() { return mDescription; } public void setDescription(CharSequence description) { mDescription = description; } public CharSequence getTitle() { return mTitle; } public void setTitle(CharSequence title) { mTitle = title; } public String getLink() { return mLink; } public void setLink(String link) { mLink = link; } }
今回は、例外のテストもできるように、結果データに対応する次のクラスを作りました。
RssData
public class RssData { /** Rssリストの取得の成否を保持する。成功した時: true */ boolean mSuccess = false; /** Rssリスト取得に失敗したとき、エラーメッセージを保持する */ String mErrorMessage; List<Item> mItems; public boolean isSuccess() { return mSuccess; } public void setSuccess(boolean success) { mSuccess = success; } public String getErrorMessage() { return mErrorMessage; } public void setErrorMessage(String errorMessage) { mErrorMessage = errorMessage; } public List<Item> getItems() { return mItems; } public void setItems(List<Item> items) { mItems = items; } }
AsyncTaskLoaderを継承したクラスは、次の通りです。
RssListLoader
public class RssListLoader extends AbstractAsyncTaskLoader<RssData> { private String mParam; public RssData mRssData; public RssListLoader(Context context, String param) { super(context); mParam = param; } @Override public RssData loadInBackground() { mRssData = new RssApi(mParam).getRssData(); } }
ここで、AbstractAsyncTaskLoaderは、Android の非同期処理を行う Loader の起動方法で紹介されている汎用的なクラスです。また、RssApiクラスは、次の通りで、String型の指定したurlから、ネットワークアクセスして、InputStreamを取得して、それをXmlでパースして返すクラスです。
public class RssApi { String mParam; public RssApi(String param) { mParam = param; } public RssData getRssData() { RssData rssData; try { InputStream is = getInputStream(mParam); List<Item> items = parseXml(is); rssData = new RssData(); rssData.setItems(items); rssData.setSuccess(true); } catch (IOException e) { rssData = new RssData(); rssData.setSuccess(false); rssData.setErrorMessage(e.toString()); } catch (XmlPullParserException e) { rssData = new RssData(); rssData.setSuccess(false); rssData.setErrorMessage(e.toString()); } return rssData; } public InputStream getInputStream(String param) throws IOException { URL url = new URL(param); return url.openConnection().getInputStream(); } // XMLをパースする public List<Item> parseXml(InputStream is) throws IOException, XmlPullParserException { List<Item> items = new ArrayList<Item>(); XmlPullParser parser = Xml.newPullParser(); try { parser.setInput(is, null); int eventType = parser.getEventType(); Item currentItem = null; while (eventType != XmlPullParser.END_DOCUMENT) { String tag = null; switch (eventType) { case XmlPullParser.START_TAG: tag = parser.getName(); if (tag.equals("item")) { currentItem = new Item(); } else if (currentItem != null) { if (tag.equals("title")) { currentItem.setTitle(parser.nextText()); } else if (tag.equals("link")) { currentItem.setLink(parser.nextText()); } else if (tag.equals("description")) { currentItem.setDescription(htmlTagRemover(parser.nextText())); } } break; case XmlPullParser.END_TAG: tag = parser.getName(); if (tag.equals("item")) { items.add(currentItem); } break; } eventType = parser.next(); } } catch (IOException e) { e.printStackTrace(); throw e; } catch (XmlPullParserException e) { throw e; } return items; } /** * HTMLタグ削除(すべて) * http://it--trick.appspot.com/article/30051/40001/70001/70002.html * * @param str 文字列 * @return HTMLタグ削除後の文字列 */ public static String htmlTagRemover(String str) { // 文字列のすべてのタグを取り除く return str.replaceAll("<.+?>", ""); } }
3. 依存性を見つける
RssApiクラスを見ると、InputStreamやXmlデータに依存性を注入するのはテストがしにくいようです。RssListLoaderクラスを見ると、mRssDataが、RssDataに依存しています。また、RssDataクラスは、フィールドに、List
4. Google Dagger 2を使う
Google Dagger 2を使ってSharedPreferencesのテストを行ってみたと同様に、Google Dagger 2で依存性注入を行い、テストしやすいコードを書きます。
4.1 build.gradle、app/build.gradleの編集
Google Dagger 2を使ってSharedPreferencesのテストを行ってみたと同様に、build.gradle、app/build.gradleを編集します。
4.2 モジュールクラス
プロダクションコードのモジュールクラスは次のようになります。
RssDataModule
@Module public class RssDataModule { private String mParam; RssDataModule(String param) { mParam = param; } @Provides @Singleton RssData provideRssData() { return new RssApi(mParam).getRssData(); } }
RssApiクラスは、コンストラクタにurlであるparamを必要とするので、RssDataModuleに、String型のparamを引数とするコンストラクタを定義しました。
テスト用のモジュールクラスは、debug/java配下に置き、次のようになります。DebugRssDataModuleのコンストラクタは、モックモードの切り替えをするprovideMocksと、urlであるparamを引数に持ちます。モックモードの場合、provideRssDataメソッドは、mockを返し、そうでない場合は、実際に、インターネットにアクセスして取得したRssDataデータを返します。
DebugRssDataModule
@Module public class DebugRssDataModule { private final boolean mockMode; private String mParam; public DebugRssDataModule(boolean provideMocks, String param) { mockMode = provideMocks; mParam = param; } @Provides @Singleton RssData provideRssData() { if (mockMode) { return mock(RssData.class); } else { return new RssApi(mParam).getRssData(); } } }
4.3 依存性注入を行うクラス
RssListLoaderクラスは次のようになります。
RssListLoader
public class RssListLoader extends AbstractAsyncTaskLoader<RssData> { private String mParam; @Inject public RssData mRssData; public RssListLoader(Context context, String param) { super(context); mParam = param; // ここに書くと、プロダクション環境で、 // android.os.NetworkOnMainThreadException が発生する // App.getInstance().component().inject(this); } @Override public RssData loadInBackground() { // mRssData = new RssApi(mParam).getRssData(); // ここだと、OK App.getInstance().component().inject(this); return mRssData; } }
mRssDataフィールでに、@Injectアノーテーションを付け、
mRssData = new RssApi(mParam).getRssData();
を
App.getInstance().component().inject(this);
に書き換えることにより、依存性注入を行っています。なお、このinjectは、RssListLoaderのコンストラクタ内で呼ぶと、android.os.NetworkOnMainThreadExceptionが発生します。時間のかかる処理は、loadInBackground内に書くべきだから、そうなんでしょう。
4.4 InjectedBaseActivityTestクラス
Google Dagger 2を使ってSharedPreferencesのテストを行ってみたと同様に、テストクラスが継承するInjectedBaseActivityTesクラスを、debug/java配下に置きます。
InjectedBaseActivityTest
public class InjectedBaseActivityTest extends ActivityInstrumentationTestCase2<MainActivity> { @Inject RssData mockRssData; public InjectedBaseActivityTest(Class activityClass) { super(activityClass); } @Override protected void setUp() throws Exception { super.setUp(); App app = (App)getInstrumentation().getTargetContext().getApplicationContext(); app.setMockMode(true); app.component().inject(this); } @Override protected void tearDown() throws Exception { App.getInstance().setMockMode(false); } }
4.5 コンポーネントクラス
RssDataComponentクラスを、debug/java配下に置きます。
RssDataComponent
@Singleton @Component(modules = {DebugRssDataModule.class}) public interface RssDataComponent { void inject(RssListLoader loader); void inject(InjectedBaseActivityTest test); public final static class Initializer { public static RssDataComponent init(boolean mockMode) { String param = RssListFragment.getParam(); return DaggerRssDataComponent.builder() .debugRssDataModule(new DebugRssDataModule(mockMode, param)) .build(); } } }
DebugRssDataModuleクラスのコンストラクタは、paramを引数に持つようにしていたのですが、interfaceはコンストラクタを持てないので、paramをどう設定するかが悩みです。paramを定数フィールドにするのは手ではありますが、urlをinterfaceで固定するのはいい設計ではないです。そこで、RssListFragmentクラスに、urlであるRSS_FEED_URLを返すメソッド
public static String getParam() { return RSS_FEED_URL; }
を定義し、Initializerクラスでは、RssListFragment#getParamメソッドを呼ぶことにより、paramを取得し、DebugRssDataModuleのコンストラクタに渡すことにしました。
4.6 Appクラス
Appクラスを、プロダクションコードに置きます。
App
public class App extends Application { private static App sInstance; private RssDataComponent component; @Override public void onCreate() { super.onCreate(); sInstance = this; component = RssDataComponent.Initializer.init(false); } public static App getInstance() { return sInstance; } public RssDataComponent component() { return component; } public void setMockMode(boolean useMock) { component = RssDataComponent.Initializer.init(useMock); } }
Appをマニフェストファイルに登録します。
5. テストクラス
次のテストクラスを、androidTest/java配下に置きます。
MainActivityTest
public class MainActivityTest extends InjectedBaseActivityTest { private final static int DUMMY_DATA_NUM = 10; private final static int DUMMY_REFRESH_DATA_NUM = 5; public MainActivityTest() { super(MainActivity.class); } @Override protected void setUp() throws Exception { super.setUp(); } @Test public void testSuccess() throws Exception { // ダミーデータ作成 List<Item> items = createDummyItems(DUMMY_DATA_NUM); when(mockRssData.getItems()).thenReturn(items); when(mockRssData.isSuccess()).thenReturn(true); MainActivity activity = getActivity(); Instrumentation.ActivityMonitor monitor = getInstrumentation().addMonitor(MainActivity.class.getName(), null, true); // リストの行数のテスト FragmentManager fragmentManager = activity.getSupportFragmentManager(); Fragment fragment = fragmentManager.findFragmentById(R.id.my_fragment); RssListFragment listFragment = (RssListFragment)fragment; final ListView listView = listFragment.getListView(); assertThat(listView.getCount(), is(DUMMY_DATA_NUM)); // リストの各行の内容表示のテスト for (int i = 0; i < DUMMY_DATA_NUM; i++) { onData(anything()).inAdapterView(withId(android.R.id.list)).atPosition(i) .onChildView(withId(R.id.item_title)) .check(matches(withText("dummy title" + i))); onData(anything()).inAdapterView(withId(android.R.id.list)).atPosition(i) .onChildView(withId(R.id.item_descr)) .check(matches(withText("dummy text" + i))); } getInstrumentation().removeMonitor(monitor); } @Test public void testToDetail() throws Exception { // ダミーデータ作成 List<Item> items = createDummyItems(DUMMY_DATA_NUM); when(mockRssData.getItems()).thenReturn(items); when(mockRssData.isSuccess()).thenReturn(true); getActivity(); Instrumentation.ActivityMonitor monitor = getInstrumentation().addMonitor(MainActivity.class.getName(), null, true); // 遷移先の画面の内容をテストする onData(anything()).inAdapterView(withId(android.R.id.list)).atPosition(0) .perform(click()); onView(withId(R.id.item_detail_title)).check(matches(withText("dummy title0"))); onView(withId(R.id.item_detail_link)).check(matches(withText("http://dummy0.com"))); onView(withId(R.id.item_detail_descr)).check(matches(withText("dummy text0"))); getInstrumentation().removeMonitor(monitor); } @Test public void testRefresh() throws Exception { // ダミーデータ作成 List<Item> items = createDummyItems(DUMMY_DATA_NUM); when(mockRssData.getItems()).thenReturn(items); when(mockRssData.isSuccess()).thenReturn(true); MainActivity activity = getActivity(); Instrumentation.ActivityMonitor monitor = getInstrumentation().addMonitor(MainActivity.class.getName(), null, true); // 更新用データ作成 List<Item> refreshItems = createDummyRefreshItems(DUMMY_REFRESH_DATA_NUM); when(mockRssData.getItems()).thenReturn(refreshItems); when(mockRssData.isSuccess()).thenReturn(true); onView(withId(R.id.action_refresh)).perform(click()); // リストの行数のテスト FragmentManager fragmentManager = activity.getSupportFragmentManager(); Fragment fragment = fragmentManager.findFragmentById(R.id.my_fragment); RssListFragment listFragment = (RssListFragment)fragment; final ListView listView = listFragment.getListView(); assertThat(listView.getCount(), is(DUMMY_REFRESH_DATA_NUM)); // リストの各行の内容表示のテスト for (int i = 0; i < DUMMY_REFRESH_DATA_NUM; i++) { onData(anything()).inAdapterView(withId(android.R.id.list)).atPosition(i) .onChildView(withId(R.id.item_title)) .check(matches(withText("dummy title" + (DUMMY_DATA_NUM +i)))); onData(anything()).inAdapterView(withId(android.R.id.list)).atPosition(i) .onChildView(withId(R.id.item_descr)) .check(matches(withText("dummy text" + (DUMMY_DATA_NUM + i)))); } getInstrumentation().removeMonitor(monitor); } @Test public void testToastIsDisplayedByIOException() throws Exception { when(mockRssData.isSuccess()).thenReturn(false); when(mockRssData.getErrorMessage()).thenReturn("IOException"); getActivity(); onView(withText("IOException")).inRoot(isToast()); } @Test public void testToastIsDisplayedByXmlPullParserException() throws Exception { when(mockRssData.isSuccess()).thenReturn(false); when(mockRssData.getErrorMessage()).thenReturn("XmlPullParserException"); getActivity(); onView(withText("XmlPullParserException")).inRoot(isToast()); } /** * ダミーデータ作成 * @param count * @return */ private List<Item> createDummyItems(int count) { List<Item> items = new ArrayList<Item>(); for (int i = 0; i < count; i++) { Item item = new Item(); item.setTitle("dummy title" + i); item.setLink("http://dummy" + i + ".com"); item.setDescription("dummy text" + i); items.add(item); } return items; } /** * 更新用データ作成 * @param count * @return */ private List<Item> createDummyRefreshItems(int count) { List<Item> refreshItems = new ArrayList<Item>(); for (int i = DUMMY_DATA_NUM; i < DUMMY_DATA_NUM + count; i++) { Item item = new Item(); item.setTitle("dummy title" + i); item.setLink("http://dummy" + i + ".com"); item.setDescription("dummy text" + i); refreshItems.add(item); } return refreshItems; } /** * Matcher that is Toast window. * http://baroqueworksdevjp.blogspot.jp/2015/03/espressotoast.html */ public static Matcher<Root> isToast() { return new TypeSafeMatcher<Root>() { @Override public void describeTo(Description description) { description.appendText("is toast"); } @Override public boolean matchesSafely(Root root) { int type = root.getWindowLayoutParams().get().type; if ((type == WindowManager.LayoutParams.TYPE_TOAST)) { IBinder windowToken = root.getDecorView().getWindowToken(); IBinder appToken = root.getDecorView().getApplicationWindowToken(); if (windowToken == appToken) { // windowToken == appToken means this window isn't contained by any other windows. // if it was a window for an activity, it would have TYPE_BASE_APPLICATION. return true; } } return false; } }; } }
テストしたケースは次の通りです。
* 正常にリスト表示されるか
* リストの行をタップすると、詳細画面に遷移するか
* リスト表示された状態で、更新ボタンを押すと、リストが正常に更新されるか
* IOExceptionやXmlPullParserExceptionの例外が発生した時、Toastが表示されるか
UIのテスト時は、ダミーのデータを使用するため、createDummyItems、createDummyRefreshItemsメソッドを定義しています。InjectedBaseActivityTestで、@Injectアノーテーションを付けたので、mockRssDataを使ってテストをします。DebugRssDataModuleクラスで定義したように、テスト時は、モックモードになっており、RssDataはmockになっています。
正常ケースのテストでは、mocRssDataは
// ダミーデータ作成 List<Item> items = createDummyItems(DUMMY_DATA_NUM); when(mockRssData.getItems()).thenReturn(items); when(mockRssData.isSuccess()).thenReturn(true);
のように、メソッド getItemsとisSuccessに対して値を返すようにして、UIのテストを行っています。
更新ボタンを押した時のテストも同様に行います。
例外のケースのテストでは、mockRssDataは
when(mockRssData.isSuccess()).thenReturn(false); when(mockRssData.getErrorMessage()).thenReturn("IOException");
のように、メソッド isSuccessとgetErrorMessageに対して値を返すようにして、UIのテストを行っています。
6. サンプル公開
本記事で扱ったサンプルプロジェクトを公開します。
andropenguin/Dagger2SladMobileReader
7. 参考サイト
開発を効率的に進めるられるまでの道程
Instrumentation Testing with Dagger, Mockito, and Espresso
Dagger 2: Even sharper, less square
Dependency Injection With Dagger 2 on Android
gk5885/dagger-android-sample
Dagger 2 + Espresso 2 + Mockito