Rx Androidを使ってSlashdot JapanのモバイルニュースのRSSを取得するアプリを作り、RxPressoでテストしてみた

1. はじめに

Rx Androidをなぜ使用するかについては、【翻訳】AsyncTask と AsyncTaskLoader を rx.Observable に置き換える – RxJava Android Patternsが詳しい。

Rx Androidは、非同期の処理に使えるが、Espresso UI testing with RxJavaの記事によると、記事を書いた人が開発したnovoda/rxpressoを利用すると、Rx Androidを使用したアプリのUIテストを容易に行うことができるそうなので、RxPressoを使用してみた。

本記事では、Slashdot JapanのモバイルニュースのRSSを取得して、リスト表示、リストの行をタップすると、ニュース詳細を見れるサンプルアプリを作成し、また、RxPressoとEspressを使用して、UIのテストを行った。

2. app/build.gradleの編集

dependenciesに次の行を追加します。

    // Mockito Dependencies
    debugCompile('com.google.dexmaker:dexmaker-mockito:1.2') {
        exclude module: 'hamcrest-core'
    }
    debugCompile 'com.google.dexmaker:dexmaker:1.2'
    debugCompile('org.mockito:mockito-core:1.10.19') {
        exclude module: 'hamcrest-core'
    }

    // Espresso 2 Dependencies
    androidTestCompile('com.android.support.test.espresso:espresso-core:2.2') {
        exclude module: 'support-annotations'
    }
    androidTestCompile('com.android.support.test:runner:0.2') {
        exclude module: 'support-annotations'
    }
    androidTestCompile('com.android.support.test:rules:0.2') {
        exclude module: 'support-annotations'
    }

    compile('io.reactivex:rxandroid:0.23.0') {
        exclude module: 'rxjava'
    }
    compile 'io.reactivex:rxjava:1.0.10'

    androidTestCompile('com.novoda:rxpresso:0.1.5') {
        exclude module: 'support-annotations'
    }

また、このままだとビルドエラーが起きるので、androidブロックに、次の記述を追加します。

    packagingOptions {
        exclude 'LICENSE.txt'
    }

3. 記事一つひとつに対するItemクラスの定義

記事一つひとつに対する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;
    }
}

Slashdot JapanのRSSデータを見ると、title、link、descriptionタグがあるので、Itemクラスにフィールド、mTitle、mLink、mDescription を持たせ、セッター、ゲッターを定義しました。

4. ItemのListを持つクラスの定義

Rx AndroidのObservable.OnSubscribeクラスのコンストラクタは、引数に、パラメータ型を持てないようなので、Listを持つクラス RssDataを作ります。

public class RssData {
    List<Item> mItems;

    public void setItems(List<Item> items) {
        mItems = items;
    }

    public List<Item> getItems() {
        return mItems;
    }
}

セッターとゲッターを定義しています。

5. Web APIを定義するinterfaceを作る

RSSデータ、RssDataを、メソッド名は、getRssDataとします。また、引数は、urlなので、String型とします。Rx Androidを使用するために、次のinterfaceを定義します。

import rx.Observable;

public interface DataRepository {
    Observable<RssData> getRssData(String param);
}

Rx Androidを使用するため、RssDataをObservableで包んでいます。

6. interfaceを実装する

5で定義したinterfaceを実装します。

import android.util.Xml;
import org.xmlpull.v1.XmlPullParser;
import org.xmlpull.v1.XmlPullParserException;
import rx.Observable;
import rx.Subscriber;

import java.io.IOException;
import java.io.InputStream;
import java.net.URL;
import java.util.ArrayList;
import java.util.List;


public class ConcreteDataRepository implements DataRepository {
    @Override
    public Observable<RssData> getRssData(final String param) {
        return Observable.create(
                new Observable.OnSubscribe<RssData>() {
                    @Override
                    public void call(Subscriber<? super RssData> subscriber) {
                        try {
                            List<Item> result = null;
                            URL url = new URL(param);
                            InputStream is = url.openConnection().getInputStream();
                            result = parseXml(is);
                            RssData rssData = new RssData();
                            rssData.setItems(result);
                            subscriber.onNext(rssData);
                        } catch (IOException e) {
                            subscriber.onError(e);
                        } catch (XmlPullParserException e) {
                            subscriber.onError(e);
                        }
                    }
                }
        );
    }

    // 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("<.+?>", "");
    }
}

ここで、interfaceで定義したgetRssDataメソッドを次のように実装しています。

    @Override
    public Observable<RssData> getRssData(final String param) {
        return Observable.create(
                new Observable.OnSubscribe<RssData>() {
                    @Override
                    public void call(Subscriber<? super RssData> subscriber) {
                        try {
                            List<Item> result = null;
                            URL url = new URL(param);
                            InputStream is = url.openConnection().getInputStream();
                            result = parseXml(is);
                            RssData rssData = new RssData();
                            rssData.setItems(result);
                            subscriber.onNext(rssData);
                        } catch (IOException e) {
                            subscriber.onError(e);
                        } catch (XmlPullParserException e) {
                            subscriber.onError(e);
                        }
                    }
                }
        );
    }

Java 1.8を使うと、簡潔に書けるようですが、本記事では、Java 1.6での書き方を行っているため、コードが複雑になっていますが、Android StudioやIntelliJのコード補完で容易にコードを書くことができるはずです。このコードでは、paramで指定されたurlからInputStreamを取得し、Xmlでパースし、結果、ListをRssData型にしています。そして、onNextに渡しています。onNextはひとつの値の処理が終わるたびに実行されます。また、IOExceptionや、XmlPullParserExceptionの例外が起きた場合は、例外をonErrorに渡します。

parseXmlメソッドの実装は、第5回 RSSリーダーの要、パース機能を知るの記事を参考にしました。

7. Applicationを継承したクラスAppを定義する

テストクラスからも、Rx Androidを利用して定義したメソッドにアクセスできるように、Applicationクラスを継承したクラスAppを定義します。

import android.app.Application;
import com.sarltokyo.sladmobilerssreader.data.ConcreteDataRepository;
import com.sarltokyo.sladmobilerssreader.data.DataRepository;

public class App extends Application {

    private static App sInstance;

    @Override
    public void onCreate() {
        super.onCreate();

        sInstance = this;
    }

    public static App getInstance() {
        return sInstance;
    }

    private DataRepository repository = new ConcreteDataRepository();

    public DataRepository getRepository() {
        return repository;
    }

    public void setRepository(DataRepository repository) {
        this.repository = repository;
    }
}

プロダクションコード、テストコードの両方から、Appインスタンスを取得できるように、Appインスタンスを返すgetInstanceメソッドを定義しています。また、プロダクションコードで、Web APIの本番用実装を利用できるように、repository にConcreteDataRepositoryのインスタンスを入れます。プロダクションコードでは、FragmentのonActivityCreatedメソッド内で、

dataRepository = App.getInstance().getRepository();

とすることにより、dataRepositoryにConcreteDataRepositoryのインスタンスを入れ、テスト時には、setRepositoryメソッドを呼ぶことにより、テスト用のDataRepositoryを使用するようにします。

また、Manifestファイルに、このAppを登録しておきます。これを忘れると、アプリが落ちるし、テストもできません。あと、ネットワークにアクセスするので、パーミッションも追加しておきます。

<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
    package="com.sarltokyo.sladmobilerssreader.app" >

    <uses-permission android:name="android.permission.INTERNET" />

    <application
        android:name="com.sarltokyo.sladmobilerssreader.App"
        android:allowBackup="true"
        android:icon="@mipmap/ic_launcher"
        android:label="@string/app_name"
        android:theme="@style/AppTheme" >
        <activity
            android:name=".MainActivity"
            android:label="@string/app_name" >
            <intent-filter>
                <action android:name="android.intent.action.MAIN" />

                <category android:name="android.intent.category.LAUNCHER" />
            </intent-filter>
        </activity>
    </application>

</manifest>

8. Fragmentから、Rx Androidを利用する

Fragmentから、Rx Androidを利用します。RssListFragmentクラスを次のように実装します。

import android.os.Bundle;
import android.support.v4.app.ListFragment;
import android.view.View;
import android.widget.ListView;
import android.widget.Toast;
import com.sarltokyo.sladmobilerssreader.App;
import com.sarltokyo.sladmobilerssreader.adapter.RssListAdapter;
import com.sarltokyo.sladmobilerssreader.data.DataRepository;
import com.sarltokyo.sladmobilerssreader.data.Item;
import com.sarltokyo.sladmobilerssreader.data.RssData;
import rx.Observer;
import rx.Subscription;
import rx.android.schedulers.AndroidSchedulers;
import rx.schedulers.Schedulers;

import java.util.ArrayList;
import java.util.List;

public class RssListFragment extends ListFragment {
    private final static String TAG = RssListFragment.class.getSimpleName();

    public static final String RSS_FEED_URL = "http://rss.rssad.jp/rss/slashdot/mobile.rss";
    private List<Item> mItems;
    private RssListAdapter mAdapter;

    private Subscription subscription;
    private DataRepository dataRepository;

    // アイテムがタップされたときのリスナー
    public interface OnListItemClickListener {
        public void onListItemClick(int position, Item item);
    }

    // アイテムがタップされたときのリスナー
    private OnListItemClickListener mOnListItemClickListener;

    // アイテムがタップされたときのリスナーをセット

    public void setOnListItemClickListener(OnListItemClickListener l) {
        mOnListItemClickListener = l;
    }

    @Override
    public void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
    }

    @Override
    public void onDestroy() {
        super.onDestroy();
    }

    @Override
    public void onActivityCreated(Bundle savedInstanceState) {
        super.onActivityCreated(savedInstanceState);

        dataRepository = App.getInstance().getRepository();

        // Itemオブジェクトを保持するためのリストを生成し、アダプタに追加する
        mItems = new ArrayList<Item>();
        mAdapter = new RssListAdapter(getActivity(), mItems);

        // アダプタをリストビューにセットする
        setListAdapter(mAdapter);

        getRssDataByRxJava(RSS_FEED_URL);
    }

    // リストのアイテムがタップされたときに呼び出される
    @Override
    public void onListItemClick(ListView l, View v, int position, long id) {
        super.onListItemClick(l, v, position, id);
        showDetail(position);
    }

    private void showDetail(int position) {
        // タップされたときのリスナーのメソッドを呼び出す
        if (mOnListItemClickListener != null) {
            Item item = (Item)(mAdapter.getItem(position));
            mOnListItemClickListener.onListItemClick(position, item);
        }
    }

    protected void refreshRss() {
        getRssDataByRxJava(RSS_FEED_URL);
    }

    void setRssResult(RssData rssData) {
        mItems = rssData.getItems();
        // todo: ダブリコード
        mAdapter = new RssListAdapter(getActivity(), mItems);
        setListAdapter(mAdapter);
    }

    void setRssError() {
        Toast.makeText(getActivity(), "error", Toast.LENGTH_LONG).show();
    }

    void getRssDataByRxJava(String param) {
        subscription = dataRepository.getRssData(param)
                .subscribeOn(Schedulers.io())
                .observeOn(AndroidSchedulers.mainThread())
                .subscribe(new RssDataObserver());
    }

    private class RssDataObserver implements Observer<RssData> {
        @Override
        public void onCompleted() {
            // nop
        }

        @Override
        public void onError(Throwable e) {
            setRssError();
        }

        @Override
        public void onNext(RssData rssData) {
            setRssResult(rssData);
        }
    }

}

RssListFragmentのonActivityCreatedメソッド内で、

        dataRepository = App.getInstance().getRepository();

によって、プロダクション用ConcreteDataRepositoryインスタンスをdataRepoitoryに入れています。こうすることにより、6で定義した、プロダクション用getRssDataを呼ぶことができます。

Rx Androidを使用しているコードは、次の部分です。

    void setRssResult(RssData rssData) {
        mItems = rssData.getItems();
        // todo: ダブリコード
        mAdapter = new RssListAdapter(getActivity(), mItems);
        setListAdapter(mAdapter);
    }

    void setRssError() {
        Toast.makeText(getActivity(), "error", Toast.LENGTH_LONG).show();
    }

    void getRssDataByRxJava(String param) {
        subscription = dataRepository.getRssData(param)
                .subscribeOn(Schedulers.io())
                .observeOn(AndroidSchedulers.mainThread())
                .subscribe(new RssDataObserver());
    }

    private class RssDataObserver implements Observer<RssData> {
        @Override
        public void onCompleted() {
            // nop
        }

        @Override
        public void onError(Throwable e) {
            setRssError();
        }

        @Override
        public void onNext(RssData rssData) {
            setRssResult(rssData);
        }
    }

getRssDataByRxJavaメソッド内で、

* subscribeOn(Schedulers.io()): タスクを実行したいスレッドが何か(Schedulers.io)
* observeOn(AndroidSchedulers.mainThread()): 結果を受け取るスレッドは何か(AndroidSchedulers.mainThread)
* subscribe: Operatorで加工した値を受け取る

RssDataObserverクラスのそれぞれのメソッドでは、データを加工します。例外の場合、onErrorで、setErrorメソッドを呼び、Toastを表示します。onNextでは、一つのの値が終わるたびに実行されるので、setRssResultメソッドが呼ばれ、本アプリでは、リスト更新がされます。

9. androidTest/javaにテストクラスを作る

androidTest/javaクラスに次のクラス SampleTest を作ります。

import android.os.IBinder;
import android.support.test.InstrumentationRegistry;
import android.support.test.espresso.Espresso;
import android.support.test.espresso.Root;
import android.support.test.rule.ActivityTestRule;
import android.support.test.runner.AndroidJUnit4;
import android.support.v4.app.Fragment;
import android.support.v4.app.FragmentManager;
import android.view.WindowManager;
import android.widget.ListView;
import com.novoda.rxmocks.RxMocks;
import com.novoda.rxmocks.SimpleEvents;
import com.novoda.rxpresso.RxPresso;
import com.sarltokyo.sladmobilerssreader.app.MainActivity;
import com.sarltokyo.sladmobilerssreader.app.R;
import com.sarltokyo.sladmobilerssreader.app.RssListFragment;
import com.sarltokyo.sladmobilerssreader.data.DataRepository;
import com.sarltokyo.sladmobilerssreader.data.Item;
import com.sarltokyo.sladmobilerssreader.data.RssData;
import org.hamcrest.Description;
import org.hamcrest.Matcher;
import org.hamcrest.TypeSafeMatcher;
import org.junit.Rule;
import org.junit.Test;
import org.junit.runner.RunWith;

import java.io.IOException;
import java.util.ArrayList;
import java.util.List;

import static android.support.test.espresso.Espresso.onData;
import static android.support.test.espresso.Espresso.onView;
import static android.support.test.espresso.action.ViewActions.click;
import static android.support.test.espresso.assertion.ViewAssertions.matches;
import static android.support.test.espresso.matcher.ViewMatchers.*;
import static com.novoda.rxmocks.RxExpect.any;
import static com.novoda.rxmocks.RxExpect.anyError;
import static org.hamcrest.Matchers.anything;
import static org.hamcrest.Matchers.is;
import static org.junit.Assert.assertThat;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.when;

@RunWith(AndroidJUnit4.class)
public class SampleTest {

    private RxPresso rxPresso;
    private DataRepository mockedRepo;

    private final static int DUMMY_DATA_NUM = 10;
    private final static int DUMMY_REFRESH_DATA_NUM = 5;

    @Rule
    public ActivityTestRule<MainActivity> rule
            = new ActivityTestRule<MainActivity>(MainActivity.class) {
        @Override
        protected void beforeActivityLaunched() {
            App application
                    = (App) InstrumentationRegistry.getTargetContext().getApplicationContext();

            mockedRepo = RxMocks.mock(DataRepository.class);
            application.setRepository(mockedRepo);

            rxPresso = new RxPresso(mockedRepo);
            Espresso.registerIdlingResources(rxPresso);
        }

        @Override
        protected void afterActivityFinished() {
            super.afterActivityFinished();
            Espresso.unregisterIdlingResources(rxPresso);
            rxPresso.resetMocks();
        }
    };

    @Test
    public void testSucces() throws Exception {
        // ダミーデータ作成
        List<Item> items = createDummyItems(DUMMY_DATA_NUM);

        RssData mockRssData = mock(RssData.class);
        when(mockRssData.getItems()).thenReturn(items);

        MainActivity activity = rule.getActivity();

        // 何を返すか
        rxPresso.given(mockedRepo.getRssData(RssListFragment.RSS_FEED_URL))
                .withEventsFrom(SimpleEvents.onNext(mockRssData))
                // テストを実行する前に、何のイベントを待っているか
                .expect(any(RssData.class))
                .thenOnView(withId(android.R.id.list))
                .check(matches(isDisplayed()));

        // リストの行数のテスト
        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)));
        }
    }

    @Test
    public void testToDetail() throws Exception {
        // ダミーデータ作成
        List<Item> items = createDummyItems(DUMMY_DATA_NUM);

        RssData mockRssData = mock(RssData.class);
        when(mockRssData.getItems()).thenReturn(items);

        rule.getActivity();

        // 何を返すか
        rxPresso.given(mockedRepo.getRssData(RssListFragment.RSS_FEED_URL))
                .withEventsFrom(SimpleEvents.onNext(mockRssData))
                // テストを実行する前に、何のイベントを待っているか
                .expect(any(RssData.class))
                .thenOnView(withId(android.R.id.list))
                .check(matches(isDisplayed()));

        // 遷移先の画面の内容をテストする
        // RxPressoの使い方がわからず、Espresoでやる
        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")));
    }

    @Test
    public void testRefresh() throws Exception {
        // ダミーデータ作成
        List<Item> items = createDummyItems(DUMMY_DATA_NUM);

        RssData mockRssData = mock(RssData.class);
        when(mockRssData.getItems()).thenReturn(items);

        MainActivity activity = rule.getActivity();

        // 何を返すか
        rxPresso.given(mockedRepo.getRssData(RssListFragment.RSS_FEED_URL))
                .withEventsFrom(SimpleEvents.onNext(mockRssData))
                        // テストを実行する前に、何のイベントを待っているか
                .expect(any(RssData.class))
                .thenOnView(withId(android.R.id.list))
                .check(matches(isDisplayed()));

        // 更新用データ作成
        List<Item> refreshItems = createDummyRefreshItems(DUMMY_REFRESH_DATA_NUM);
        RssData refreshMockRssData = mock(RssData.class);
        when(refreshMockRssData.getItems()).thenReturn(refreshItems);

        // 何を返すか
        rxPresso.given(mockedRepo.getRssData(RssListFragment.RSS_FEED_URL))
                .withEventsFrom(SimpleEvents.onNext(refreshMockRssData))
                // テストを実行する前に、何のイベントを待っているか
                .expect(any(RssData.class))
                .thenOnView(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))));
        }
    }

    /**
     * ダミーデータ作成
     * @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;
    }


    @Test
    public void testIOException() throws Exception {

        rule.getActivity();

        // エラーでToastが表示されるかのテスト
        // 何を返すか
        rxPresso.given(mockedRepo.getRssData(RssListFragment.RSS_FEED_URL))
                .withEventsFrom(SimpleEvents.<RssData>onError(new IOException("error")))
                // テストを実行する前に、何のイベントを待っているか
                .expect(anyError(RssData.class, IOException.class))
                .thenOnView(withText("error")).inRoot(isToast())
                .check(matches(isDisplayed()));
    }

    /**
     * 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;
            }
        };
    }
}

通常、Espressoを利用して、UIのテストを行う場合、テストクラスは、ActivityInstrumentationTestCase2を継承しますが、Rx Andoriidを使う非同期処理を持つアプリのUIのテストで、RxPressoを使う場合、テストクラスに、@RunWith(AndroidJUnit4.class) アノーテーションを付け、テストクラス内で、@Ruleで、

    @Rule
    public ActivityTestRule<MainActivity> rule
            = new ActivityTestRule<MainActivity>(MainActivity.class) {
        @Override
        protected void beforeActivityLaunched() {
            App application
                    = (App) InstrumentationRegistry.getTargetContext().getApplicationContext();

            mockedRepo = RxMocks.mock(DataRepository.class);
            application.setRepository(mockedRepo);

            rxPresso = new RxPresso(mockedRepo);
            Espresso.registerIdlingResources(rxPresso);
        }

        @Override
        protected void afterActivityFinished() {
            super.afterActivityFinished();
            Espresso.unregisterIdlingResources(rxPresso);
            rxPresso.resetMocks();
        }
    };

のように記述します。beforeActivityLaunchedでは、(App) InstrumentationRegistry.getTargetContext().getApplicationContext()で、Appインスタンスを取得し、RxMocksのmockメソッドで、DataRepositoryのモックを取得、App#setRepositoryメソッドを呼ぶことにより、モックを利用する設定を行います。そして、RxPressoのインスタンスを取得して、EspressoのregisterIdlingResourcesにより、IdlingResourceを登録します。

また、afterActivityFinishedで、IdlingResourceの登録解除を行います。

10. テストの内容の説明

テストでは、実際にインターネットにアクセスしてRSSデータを取得すのではなく、次のコードの通り、RssDataをmockに置き換え、それのgetIemsメソッドがダミーデータを返すようにして、ダミーのデータで、UIのテストを行います。

        // ダミーデータ作成
        List<Item> items = createDummyItems(DUMMY_DATA_NUM);

        RssData mockRssData = mock(RssData.class);
        when(mockRssData.getItems()).thenReturn(items);

テスト項目は次の通りです。
* リストの行数のテスト。リストの各行の内容表示のテスト
* 記事詳細画面の内容のテスト
* 更新ボタンを押したら、リストが正しく更新されるかのテスト

リストの行数のテスト。リストの各行の内容表示のテストのコードは次の通りです。

@Test
public void testSucces() throws Exception {
// ダミーデータ作成
List items = createDummyItems(DUMMY_DATA_NUM);

RssData mockRssData = mock(RssData.class);
when(mockRssData.getItems()).thenReturn(items);

MainActivity activity = rule.getActivity();

// 何を返すか
rxPresso.given(mockedRepo.getRssData(RssListFragment.RSS_FEED_URL))
.withEventsFrom(SimpleEvents.onNext(mockRssData))
// テストを実行する前に、何のイベントを待っているか
.expect(any(RssData.class))
.thenOnView(withId(android.R.id.list))
.check(matches(isDisplayed()));

// リストの行数のテスト
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))); } } [/java] RxPressoを使用する場合、何を返すか [java] // 何を返すか rxPresso.given(mockedRepo.getRssData(RssListFragment.RSS_FEED_URL)) .withEventsFrom(SimpleEvents.onNext(mockRssData)) [/java] と、テストをする前に、何のイベントを待っているか [java] // テストを実行する前に、何のイベントを待っているか .expect(any(RssData.class)) .thenOnView(withId(android.R.id.list)) .check(matches(isDisplayed())); [/java] のように記述します。ここでは、 * mockedRepo.getRssData()で、データを取得したら、mocksRssDataを返す * RssDataのデータが戻り、android.R.id.listのidのViewが表示されるイベント待つ としています。 RxPressoで、リストの行数や、各行の内容のテストを行いたかったのですが、チェインさせる方法がわからなかったので、Espressoを使ったテストコードを書きました。Espresso UI testing with RxJavaによると、RxPressoはEspressoと併用できるそうなので、問題ありません。

// リストの行数のテスト
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))); } [/java] Fragmentを使ったアプリなので、コードの通り、FragmentManagersインスタンス、RssListFragmentインスタンスを取得後、ListFragment#getListFragmentメソッドで、listViewを取得して、リストの行数をテストしています。また、Espressoの流儀で、各行の内容のテストを行っています。 残りのテスト項目も同様にコーディングして、テストを行います。 11. サンプルプロジェクト公開 今回作成したサンプルプロジェクトをGitHubに公開します。 andropenguin/SladMobileRssReader

12. 参考サイト

Espresso UI testing with RxJava
vladlichonos/Android-Examples
第4回 簡単なRSSリーダーを作ってみる
ReactiveX/RxAndroid
android-test-kit
novoda/rxpresso

コメントを残す

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