AsyncTaskLoaderを使ったアプリで、Google Dagger 2とEspressoを使ってUIテストをする

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であるmItems、booleanであるmSuccess、StringであるmErrorMessageを持ち、これらは、容易にダミーデータに置き換えることができます。そこで、mRssDataに依存性注入を行うことにします。

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

コメントを残す

メールアドレスが公開されることはありません。 * が付いている欄は必須項目です