AsyncTaskLoaderを使ったアドレス帳アプリで、Google Dagger 2を使って、テスト時にDBファイルをテスト用ファイルに置き換える

1. はじめに

以前、記事、ORMLiteを使ったアドレス帳アプリをテストしやすいように書き換え、Espressoでテストしたで、アドレス帳アプリの各種テストのやり方を書きました。その記事では、住友孝郎@cattaka_net氏のスライド開発を効率的に進めるられるまでの道程で紹介されているように、テスト時、データベースファイルを、RenamingDelegatingContextを使って、テスト用ファイルに置き換えて、各種テストを行いました。その方法は有効ではあるのですが、データベースからデータを読み込み、リスト画面にデータを表示するのに、Rx Androidを使うアプリの場合、Espressoを使ったUIのテスト時、ListViewの表示終了を待ってくれず、テストがうまくできませんでした。そこで、非同期処理には、Rx Androidの使用を断念して、従来のAsyncTaskLoaderを使用する方針に転換しました。Google Dagger 2を使ったUIテストについては、Google Dagger 2を使ってSharedPreferencesのテストを行ってみたや、AsyncTaskLoaderを使ったアプリで、Google Dagger 2とEspressoを使ってUIテストをするの記事で、SharedPreferencesやAsyncTaskLoaderが絡むUIのテストはやったのですが、Google Dagger 2をデータベースに適用して、データベースの絡むUIテストはまだやっていませんでした。RenamingDelegatingContextを使えば、依存性注入をやることもないのですが、SharedPreferencesやネットワークアクセスAPIに依存性注入をやるようなアプリでは、データベースにも依存性注入をやって統一するのもいいのではないかと思います。

2. テスト対象アプリ

テスト対象アプリは、andropenguin/AddressBookORMLite7ですが、画面遷移に、startActivityを使うのをやめ、Fragmentを使用することにしました。また、以前のアプリでは、データ操作に必要なSQLiteOpenHelperを継承したクラスOpenHelperのインスタンスを得るために、住友孝郎@cattaka_net氏の、FastCheckList/app/src/main/java/net/cattaka/android/fastchecklist/core/ にあるContextLogicとContextLogicFactoryを使用していましたが、RenamingDelegatingContextを使用せず、Google Dagger 2による依存性注入を行う方針なので、次のクラスOpenHelperで定義されるコンストラクタ OpenHelper(Context, String)で取得することにしました。

OpenHelper

public class OpenHelper extends OrmLiteSqliteOpenHelper {
    private final static String TAG = OpenHelper.class.getSimpleName();

    private final static int DATABASE_VERSION = 1;

    private Dao<Person, Integer> mPersonDao;
    private Dao<Address, Integer> mAddressDao;

    public OpenHelper(Context context, String name) {
        super(context, name, null, DATABASE_VERSION);
    }

   ...
}

ActivityとFragmentクラスは次のとおりです。コンストラクタOpenHelperの第2引数には、mDatabaseNameを渡し、MainActivityでは、それは、DATABASE_NAMEであり、PersonListFragmentや、RegisterFragmentでは、MainActivity.DATABASE_NAMEです。

MainActivity

public class MainActivity extends AppCompatActivity
        implements PersonListFragment.OnListItemClickListener {

    private final static String TAG = MainActivity.class.getSimpleName();

    private OpenHelper mOpenHelper;
    
    ...	

    public final static String DATABASE_NAME = "addressbook.db";

    public String mDatabaseName;

    @Override
    protected void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
        setContentView(R.layout.activity_main);

        mDatabaseName = DATABASE_NAME;

        mOpenHelper = new OpenHelper(this, mDatabaseName);

        ...
        }
    }
    ...
}

PersonListFragment

public class PersonListFragment extends ListFragment
        implements LoaderManager.LoaderCallbacks<List<Person>> {

    ...

    public String mDatabaseName;

    ...

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

        mDatabaseName = MainActivity.DATABASE_NAME;

        mOpenHelper = new OpenHelper(getActivity(), mDatabaseName);
    }

    ...

    @Override
    public Loader<List<Person>> onCreateLoader(int id, Bundle args) {
        return new PersonLoader(getActivity(), mDatabaseName);
    }

    ...
}

RegisterFragment

public class RegisterFragment extends Fragment implements View.OnClickListener {

    ...

    public String mDatabaseName;

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

        mDatabaseName = MainActivity.DATABASE_NAME;

        mOpenHelper = new OpenHelper(getActivity(), mDatabaseName);
    }
    
    ...
}

AsyncTaskLoaderを継承したクラス PersonLoaderのコンストラクタは、以前のアプリでは、

    public PersonLoader(Context context) {
        super(context);
        mContextLogic = ContextLogicFactory.createContextLogic(context);
        mOpenHelper = mContextLogic.createOpenHelper();
    }

になっていましたが、今回は、ContextLogicFactoryやContextLogicを使わないので、

    public PersonLoader(Context context, String databaseName) {
        super(context);

        mOpenHelper = new OpenHelper(context, databaseName);
    }

に書き換えました。

3. 依存性を見つける

今回は、依存性注入を行い、テスト時に、データベースファイルをテスト用に置き換えたいので、MainActivity、PersonListFragment、RegisterFragmentのmDatabaseNameがStringに依存していることから、mDatabaseNameに依存性注入を行うことにしました。

4. モジュールクラス

プロダクションコードのモジュールクラスは次のとおりです。ここでは、provideDatabaseNameメソッドは、実際のデータベースファイルのファイル名を返します。

DatabaseNameModule

@Module
public class DatabaseNameModule {

    DatabaseNameModule() {
    }

    @Provides
    @Singleton
    String provideDatabaseName() {
        return MainActivity.DATABASE_NAME;
    }
}

テスト用のモジュールクラスは次のとおりです。provideDatabaseNameメソッドは、mockModeがtrueの時は、プロダクション用のデータベースファイル名に、プレフィックス test_ を付けたファイル名を返すようにして、mockModeがfalseの時は、実際のデータベースファイル名を返すようにしました。

DebugDatabaseNameModule

@Module
public class DebugDatabaseNameModule {
    private final boolean mockMode;

    public DebugDatabaseNameModule(boolean provideMocks) {
        mockMode = provideMocks;
    }

    @Provides
    @Singleton
    String provideDatabaseName() {
        if (mockMode) {
            return "test_" + MainActivity.DATABASE_NAME;
        } else {
            return MainActivity.DATABASE_NAME;
        }
    }
}

5. @Injectを付け、injectを呼ぶ

次のように、MainActivity、PersonListFragment、RegisterFragmentクラスで、mDatabaseNameに、@Injectアノーテーションを付け、また、mDatabaseNameへの代入文をコメントアウトして、injectを呼びます。

MainActivity

public class MainActivity extends AppCompatActivity
        implements PersonListFragment.OnListItemClickListener {

    ...

    private OpenHelper mOpenHelper;

    ...

    public final static String DATABASE_NAME = "addressbook.db";

    @Inject
    public String mDatabaseName;

    @Override
    protected void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
        setContentView(R.layout.activity_main);

//        mDatabaseName = DATABASE_NAME;
        App.getInstance().component().inject(this);

        mOpenHelper = new OpenHelper(this, mDatabaseName);
        
        ...
     }
     
     ...
}

PersonListFragment

public class PersonListFragment extends ListFragment
        implements LoaderManager.LoaderCallbacks<List<Person>> {

    ...

    private OpenHelper mOpenHelper;

    @Inject
    public String mDatabaseName;

    ...

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

//        mDatabaseName = MainActivity.DATABASE_NAME;
        App.getInstance().component().inject(this);

        mOpenHelper = new OpenHelper(getActivity(), mDatabaseName);
    }

    ...
}

RegisterFragment


public class RegisterFragment extends Fragment implements View.OnClickListener {
    private final static String TAG = RegisterFragment.class.getSimpleName();

    private OpenHelper mOpenHelper;
  
    ...

    @Inject
    public String mDatabaseName;

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

//        mDatabaseName = MainActivity.DATABASE_NAME;
        App.getInstance().component().inject(this);
        
        mOpenHelper = new OpenHelper(getActivity(), mDatabaseName);
    }
    
    ...
}

6. テスト用のベースinjectedクラスを作る

InstrumentationTestCaseを使うクラスのために、debug/java配下に、次のクラスを作ります。

InstrumentationTestCase

public class InjectedInstrumentationTest extends InstrumentationTestCase {
    private final static String TAG = InjectedInstrumentationTest.class.getSimpleName();

    @Inject
    public String mockDatabaseName;

    public OpenHelper mOpenHelper;

    @Override
    protected void setUp() throws Exception {
        super.setUp();

        App app =
                (App)getInstrumentation().getTargetContext().getApplicationContext();
        app.setMockMode(true);
        app.component().inject(this);

        Context context = getInstrumentation().getTargetContext();
        mOpenHelper = new OpenHelper(context, mockDatabaseName);

        cleanData();
    }

    @Override
    protected void tearDown() throws Exception {
        App.getInstance().setMockMode(false);
        mOpenHelper.close();
        mOpenHelper = null;
    }

    public void cleanData() {
        Log.d(TAG, "cleanData");
        List<Person> persons = null;
        try {
            persons = mOpenHelper.findPerson();
        } catch (SQLException e) {
            Log.e(TAG, e.getMessage());
        }

        for (Person person : persons) {
            try {
                mOpenHelper.deletePerson(person.getName());
            } catch (SQLException e) {
                Log.e(TAG, e.getMessage());
            }
        }
    }

    public List<Person> createTestData(int personsNum) {
        Log.d(TAG, "createTestData");
        List<Person> persons = new ArrayList<Person>();
        // Creating dummy data.
        for (int i = 0; i < personsNum; i++) {
            Person person = new Person();
            Address address = new Address();
            address.setZipcode("123-456" + i);
            address.setPrefecture("Tokyo");
            address.setCity("Shinjyuku-ku");
            address.setOther("Higashi-shinjyuku 1-2-" + i);
            person.setName("Hoge" + i);
            person.setAddress(address);
            try {
                mOpenHelper.registerPerson(person);
                persons.add(person);
            } catch (SQLException e) {
                Log.e(TAG, e.getMessage());
            }
        }
        return persons;
    }
}
&#91;/java&#93;

mockDatabaseNameに、アノーテーション @Injectを付け、

&#91;java&#93;
        App app =
                (App)getInstrumentation().getTargetContext().getApplicationContext();
        app.setMockMode(true);
        app.component().inject(this);

        Context context = getInstrumentation().getTargetContext();
        mOpenHelper = new OpenHelper(context, mockDatabaseName);

        cleanData();
&#91;/java&#93;

のように、後述で定義するAppクラスのインスタンスを取得して、setMockModeで、モックモードにして、injectで依存性注入を行います。そして、OpenHelperコンストラクタの第二引数に、依存性注入されたmockDatabaseNameを渡します。また、上記コードのように、データベースをクリアするcleanDataメソッドと、ダミーデータをデータベースに作るcreateTestDataメソッドを定義しています。

ActivityInstrumentationTestCase2を使うテストのために、debug/java配下に、次のクラスを作ります。

InjectedBaseActivityTest
&#91;java&#93;
public class InjectedBaseActivityTest extends ActivityInstrumentationTestCase2<MainActivity> {
    private final static String TAG = InjectedInstrumentationTest.class.getSimpleName();

    @Inject
    String mockDatabaseName;

    public OpenHelper mOpenHelper;

    public InjectedBaseActivityTest() {
        super(MainActivity.class);
    }

    @Override
    protected void setUp() throws Exception {
        super.setUp();

        App app =
                (App)getInstrumentation().getTargetContext().getApplicationContext();
        app.setMockMode(true);
        app.component().inject(this);

        Context context = getInstrumentation().getTargetContext();
        mOpenHelper = new OpenHelper(context, mockDatabaseName);

        cleanData();
    }

    @Override
    protected void tearDown() throws Exception {
        App.getInstance().setMockMode(false);
    }

    public void cleanData() {
        Log.d(TAG, "cleanData");
        List<Person> persons = null;
        try {
            persons = mOpenHelper.findPerson();
        } catch (SQLException e) {
            Log.e(TAG, e.getMessage());
        }

        for (Person person : persons) {
            try {
                mOpenHelper.deletePerson(person.getName());
            } catch (SQLException e) {
                Log.e(TAG, e.getMessage());
            }
        }
    }

    public List<Person> createTestData(int personsNum) {
        Log.d(TAG, "createTestData");
        List<Person> persons = new ArrayList<Person>();
        // Creating dummy data.
        for (int i = 0; i < personsNum; i++) {
            Person person = new Person();
            Address address = new Address();
            address.setZipcode("123-456" + i);
            address.setPrefecture("Tokyo");
            address.setCity("Shinjyuku-ku");
            address.setOther("Higashi-shinjyuku 1-2-" + i);
            person.setName("Hoge" + i);
            person.setAddress(address);
            try {
                mOpenHelper.registerPerson(person);
                persons.add(person);
            } catch (SQLException e) {
                Log.e(TAG, e.getMessage());
            }
        }
        return persons;
    }
}
&#91;/java&#93;

依存性注入の仕方は、InjectedInstrumentationTestクラスと同様です。当初、InjectedBaseActivityTestクラスを、

&#91;java&#93;
public class InjectedBaseActivityTest<T extends Activity> extends ActivityInstrumentationTestCase2<T> {
    ...
}

のように、ジェネリックを使って定義したのですが、

Dagger not able to inject into InjectedBaseActivityTest #3

に、issueが上がっている通り、Google Dagger 2は、タイプパラメータを持ったInjectedBaseActivityTestに依存性注入ができないようです。そこで、androidTest/java配下に、同じ内容のクラスInjectedBaseActivityTestを作ってみたのですが、後述のコンポネントクラスから、そのクラスInjectedBaseActivityTestが見えないようで、その方法を断念しました。

また、InjectedBaseActivityTestクラスでも、データベースのデータをクリアするメソッドと、ダミーデーをデータベースに作るメソッドを定義しました。InjectedInstrumentationTestとコードのダブリがあるので、2つのメソッドをユーティリティクラスに抽出する試みをやったのですが、問題が生じてうまくいきませんでした。注意深くやればいいのかもしれませんが、それは宿題にします。

7. Applicationを継承したAppクラスを作る

Google Dagger 2を使ってSharedPreferencesのテストを行ってみたや、AsyncTaskLoaderを使ったアプリで、Google Dagger 2とEspressoを使ってUIテストをすると同様に、Applicationを継承したクラスAppを作り、マニフェストファイルに登録します。

App

public class App extends Application {

    private static App sInstance;
    private DatabaseNameComponent component;

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

        sInstance = this;
        component = DatabaseNameComponent.Initializer.init(false);
    }

    public static App getInstance() {
        return sInstance;
    }

    public DatabaseNameComponent component() {
        return component;
    }

    public void setMockMode(boolean useMock) {
        component = DatabaseNameComponent.Initializer.init(useMock);
    }
}

8. コンポネントクラス

コンポネントクラスは次のとおりです。

DatabaseNameComponent

@Singleton
@Component(modules = {DebugDatabaseNameModule.class})
public interface DatabaseNameComponent {

    void inject(MainActivity activity);
    void inject(PersonListFragment personListFragment);
    void inject(RegisterFragment registerFragment);
    void inject(InjectedBaseActivityTest test);
    void inject(InjectedInstrumentationTest instrumentationTest);

    public final static class Initializer {
        public static DatabaseNameComponent init(boolean mockMode) {
            return DaggerDatabaseNameComponent.builder()
                    .debugDatabaseNameModule(new DebugDatabaseNameModule(mockMode))
                    .build();
        }
    }
}

MainActivity、PersonListFragment、RegisterFragment、InjectedBaseActivityTest、InjectedInstrumentationTestの各クラスで、依存性注入を行うので、上記コードの通り、injectメソッドを定義します。

9. 各種テストクラス

9.1 データベースのテスト

上で定義したInjectedInstrumentationTestクラスを継承した、次の テストクラスを作ります。

OpenHelperTest

public class OpenHelperTest extends InjectedInstrumentationTest {

    public void testInsertSelect()throws Exception{
        assertEquals(0, mOpenHelper.findPerson().size());

        Person person = new Person();
        Address address = new Address();
        address.setZipcode("123-4567");
        address.setPrefecture("Tokyo");
        address.setCity("Shinjyuku-ku");
        address.setOther("Higashi-shinjyuku 1-2-3");
        person.setName("Foo");
        person.setAddress(address);
        // insert
        mOpenHelper.registerPerson(person);
        // select
        Person sut = mOpenHelper.findPerson("Foo");

        assertThat(sut.getAddress().getZipcode(), is("123-4567"));
        assertThat(sut.getAddress().getPrefecture(), is("Tokyo"));
        assertThat(sut.getAddress().getCity(), is("Shinjyuku-ku"));
        assertThat(sut.getAddress().getOther(), is("Higashi-shinjyuku 1-2-3"));
    }

    public void testDuplicate() throws  Exception {
        assertEquals(0, mOpenHelper.findPerson().size());

        Person person = new Person();
        Address address = new Address();
        address.setZipcode("123-4567");
        address.setPrefecture("Tokyo");
        address.setCity("Shinjyuku-ku");
        address.setOther("Higashi-shinjyuku 1-2-3");
        person.setName("Foo");
        person.setAddress(address);
        // insert
        mOpenHelper.registerPerson(person);
        assertEquals(1, mOpenHelper.findPerson().size());

        Person person2 = new Person();
        Address address2 = new Address();
        address.setZipcode("111-1111");
        address.setPrefecture("Kyoto");
        address.setCity("Kyoto");
        address.setOther("boo 1-2-3");
        person2.setName("Foo"); // duplicate
        person2.setAddress(address2);
        // insert
        // JUnit 3の書き方になってしまう
        try {
            mOpenHelper.registerPerson(person2);
            fail("SQLException is expected");
        } catch (SQLException e) {
        }

        assertEquals(1, mOpenHelper.findPerson().size());
    }

    public void testRegister() throws Exception {
        assertEquals(0, mOpenHelper.findPerson().size());

        // insert
        createTestData(200);

        // select
        List<Person> sut = mOpenHelper.findPerson();
        assertEquals(200, sut.size());
    }

    public void testFind() throws Exception {
        assertEquals(0, mOpenHelper.findPerson().size());

        Person person = new Person();
        Address address = new Address();
        address.setZipcode("123-4567");
        address.setPrefecture("Tokyo");
        address.setCity("Shinjyuku-ku");
        address.setOther("Higashi-shinjyuku 1-2-3");
        person.setName("Foo");
        person.setAddress(address);
        // insert
        mOpenHelper.registerPerson(person);
        // select
        List<Person> sut = mOpenHelper.findPerson();

        assertEquals(1, sut.size());

        assertThat(sut.get(0).getName(), is("Foo"));
        assertThat(sut.get(0).getAddress().getZipcode(), is("123-4567"));
        assertThat(sut.get(0).getAddress().getPrefecture(), is("Tokyo"));
        assertThat(sut.get(0).getAddress().getCity(), is("Shinjyuku-ku"));
        assertThat(sut.get(0).getAddress().getOther(), is("Higashi-shinjyuku 1-2-3"));
    }

    public void testUpdate() throws Exception {
        assertEquals(0, mOpenHelper.findPerson().size());

        Person person = new Person();
        Address address = new Address();
        address.setZipcode("123-4567");
        address.setPrefecture("Tokyo");
        address.setCity("Shinjyuku-ku");
        address.setOther("Higashi-shinjyuku 1-2-3");
        person.setName("Foo");
        person.setAddress(address);
        // insert
        mOpenHelper.registerPerson(person);
        // select
        List<Person> sut = mOpenHelper.findPerson();

        assertEquals(1, sut.size());

        Person person2 = mOpenHelper.findPerson(person.getName());
        Address address2 = person2.getAddress();
        address2.setZipcode("123-0000");
        address2.setPrefecture("Osaka");
        address2.setCity("Osaka");
        address2.setOther("hoge 1-2-3");
        person2.setName("Foo2");
        person2.setAddress(address2);
        // update
        mOpenHelper.updatePerson(person2);
        // select
        assertEquals(1, mOpenHelper.findPerson().size());
        Person sut2 = mOpenHelper.findPerson("Foo2");
        assertThat(sut2.getAddress().getZipcode(), is("123-0000"));
        assertThat(sut2.getAddress().getPrefecture(), is("Osaka"));
        assertThat(sut2.getAddress().getCity(), is("Osaka"));
        assertThat(sut2.getAddress().getOther(), is("hoge 1-2-3"));
        assertThat(sut2.getName(), is("Foo2"));
    }

    public void testDelete() throws SQLException {
        assertEquals(0, mOpenHelper.findPerson().size());

        Person person = new Person();
        Address address = new Address();
        address.setZipcode("123-4567");
        address.setPrefecture("Tokyo");
        address.setCity("Shinjyuku-ku");
        address.setOther("Higashi-shinjyuku 1-2-3");
        person.setName("Foo");
        person.setAddress(address);
        // insert
        mOpenHelper.registerPerson(person);
        // delete
        mOpenHelper.deletePerson(person.getName());
        assertEquals(0, mOpenHelper.findPerson().size());
    }
}

mOpenHelperは、継承元のInjectedInstrumentationTestにあるフィールドで、InjectedInstrumentationTestクラスでは、

        Context context = getInstrumentation().getTargetContext();
        mOpenHelper = new OpenHelper(context, mockDatabaseName);

であったので、テスト用データベースファイルを使ってテストを行っています。テスト項目は次のとおりです。

* データを1件データベースに挿入して、データをデータベースから取得したら、入力したものになっているか
* nameがダブっているデータをデータベースに挿入しようとすると、SQLExceptionが発生するか
* データベースに200件データを挿入して、全件取得したら、データ数は200件になっているか
* データベースにデータを1件挿入して、nameでデータを取得したら、データの各値は、挿入したデータのものと同じか
* データベースにデータを1件挿入して、nameでデータを取得して、各値を書き換えて、データ更新して、nameでデータ取得すると、データは更新されているか
* データベースにデータを1件挿入して、nameで削除操作をすると、データベースからデータが削除されるか

9.2 Adapterのテスト

Adapterのテストは、データベースを使わないので、ORMLiteを使ったアドレス帳アプリをテストしやすいように書き換え、Espressoでテストしたと同様にテストコードを書きます。

9.3 AsyncTaskLoaderのテスト

AsyncTaskLoaderのテストコードは次のとおりです。

PersonLoaderTest

public class PersonLoaderTest extends InjectedInstrumentationTest {

    private Context mContext;
    private PersonLoader mPersonLoader;


    @Override
    protected void setUp() throws Exception {
        super.setUp();
        mContext = getInstrumentation().getTargetContext();
        mPersonLoader = new PersonLoader(mContext, mockDatabaseName);
    }

    @Override
    protected void tearDown() throws Exception {
        super.tearDown();
        mPersonLoader = null;
    }

    public void testSuccess() throws Exception {
        createTestData(200);

        List<Person> actual = mPersonLoader.getPersons();

        for (int i =0; i < actual.size(); i++) {
            Person actualPerson = actual.get(i);
            String actuallName = actualPerson.getName();
            Address actualAddress = actualPerson.getAddress();
            String actualZipcode = actualAddress.getZipcode();
            String actualPrefecture = actualAddress.getPrefecture();
            String acutalCity = actualAddress.getCity();
            String actualOther = actualAddress.getOther();

            assertThat(actualZipcode, is("123-456" + i));
            assertThat(actualPrefecture, is(("Tokyo")));
            assertThat(acutalCity, is(("Shinjyuku-ku")));
            assertThat(actualOther, is("Higashi-shinjyuku 1-2-" + i));
            assertThat(actuallName, is("Hoge" + i));
        }
    }

    public void testEmptyData() throws Exception {
        cleanData();

        List<Person> actual = mPersonLoader.getPersons();
        assertTrue(actual.isEmpty());
    }
}

今回は、AsyncTaskLoaderは、データベースからデータを読み込む処理に使い、テスト用データベースファイルを使いますので、テストクラスは、InjectedInstrumentationTestクラスを継承します。データベースのテストの時と同様に、

        mContext = getInstrumentation().getTargetContext();
        mPersonLoader = new PersonLoader(mContext, mockDatabaseName);

とすることにより、PersonLoaderコンストラクタの第二引数には、テスト用データベースファイルのファイル名を渡しています。

9.4 UIのテスト

UIのテストコードは次のとおりです。

public class MainActivityTest extends InjectedBaseActivityTest {
    private final static String TAG = MainActivityTest.class.getSimpleName();

    public MainActivityTest() {
       super();
    }

    public void testTransitionRegisterActivity1() throws Throwable {
        // Creating dummy data
        createTestData(200);

        MainActivity activity = getActivity();

        assertFalse(activity.isFinishing());
        final ListView listView = (ListView) activity.findViewById(android.R.id.list);

        // The test works OK on API Level 21 emulator, but fails on API Level 19 emulator.
        assertEquals(200, listView.getCount());

        onData(anything()).inAdapterView(withId(android.R.id.list)).atPosition(0)
                .check(ViewAssertions.matches(ViewMatchers.isDisplayed()));

        onData(anything()).inAdapterView(withId(android.R.id.list)).atPosition(0)
                .perform(click());

        onView(withId(R.id.nameEt)).check(matches(withText("Hoge0")));
        onView(withId(R.id.zipcodeEt)).check(matches(withText("123-4560")));
        onView(withId(R.id.prefectureEt)).check(matches(withText("Tokyo")));
        onView(withId(R.id.cityEt)).check(matches(withText("Shinjyuku-ku")));
        onView(withId(R.id.otherEt)).check(matches(withText("Higashi-shinjyuku 1-2-0")));
        // todo : ボタンがUPDATEと表示されてることをテストしたい

        onView(withId(R.id.btn)).check(matches(isClickable()));
    }


    public void testTransitionRegisterActivity2() throws Throwable {
        // Creating dummy data
        createTestData(200);

        MainActivity activity = getActivity();

        assertFalse(activity.isFinishing());

        onView(withId(R.id.input_add)).perform(click());

        onView(withId(R.id.nameEt)).check(matches(withText("")));
        onView(withId(R.id.zipcodeEt)).check(matches(withText("")));
        onView(withId(R.id.prefectureEt)).check(matches(withText("")));
        onView(withId(R.id.cityEt)).check(matches(withText("")));
        onView(withId(R.id.otherEt)).check(matches(withText("")));
        // todo : ボタンがREGISTERと表示されてることをテストしたい

        onView(withId(R.id.btn)).check(matches(isClickable()));
    }

    public void testAddPerson() throws Throwable {
        // Creating dummy data
        createTestData(200);

        MainActivity activity = getActivity();

        onView(withId(R.id.input_add)).perform(click());

        assertFalse(activity.isFinishing());
        onView(withId(R.id.nameEt)).perform(typeText("Bar"));
        onView(withId(R.id.zipcodeEt)).perform(typeText("123-4567"));
        onView(withId(R.id.prefectureEt)).perform(typeText("Tokyo"));
        onView(withId(R.id.cityEt)).perform(typeText("Nerima-ku"));
        onView(withId(R.id.otherEt)).perform(typeText("Nerima 1-2-3"));

        onView(withId(R.id.btn)).perform(click());
    }

    public void testUpdateAddress() throws Throwable {
        // Creating dummy data
        createTestData(200);

        MainActivity activity = getActivity();

        assertFalse(activity.isFinishing());

        onData(anything()).inAdapterView(withId(android.R.id.list)).atPosition(0)
                .perform(click());

        onView(withId(R.id.zipcodeEt)).perform(clearText()).perform(typeText("345-6789"));
        onView(withId(R.id.prefectureEt)).perform(clearText()).perform(typeText("Kyoto"));
        onView(withId(R.id.cityEt)).perform(clearText()).perform(typeText("Shimogyo-ku"));
        onView(withId(R.id.otherEt)).perform(clearText()).perform(typeText("Higashi 1-2-3"));
        onView(withId(R.id.btn)).perform(click());
        // todo: MainActivityに戻ったことをテストしたい
    }

    public void testNameEmptyInfo() throws Throwable {
        // Creating dummy data
        createTestData(200);

        MainActivity activity = getActivity();

        assertFalse(activity.isFinishing());

        onView(withId(R.id.input_add)).perform(click());
        onView(withId(R.id.nameEt)).perform(typeText(""));

        // todo: うまくいかない
//        onView(withText("name is empty.")).inRoot(withDecorView(not(is(getActivity().getWindow().getDecorView()))))
//                .check(matches(isDisplayed()));

        onView(withText("name is empty.")).inRoot(isToast());
    }

    public void testZipcodeEmptyInfo() throws Throwable {
        // Creating dummy data
        createTestData(200);

        MainActivity activity = getActivity();

        assertFalse(activity.isFinishing());

        onView(withId(R.id.input_add)).perform(click());
        onView(withId(R.id.zipcodeEt)).perform(typeText(""));

        onView(withText("zipcode is empty.")).inRoot(isToast());
    }

    public void testPrefectureEmptyInfo() throws Throwable {
        // Creating dummy data
        createTestData(200);

        MainActivity activity = getActivity();

        assertFalse(activity.isFinishing());

        onView(withId(R.id.input_add)).perform(click());
        onView(withId(R.id.prefectureEt)).perform(typeText(""));

        onView(withText("prefecture is empty.")).inRoot(isToast());
    }

    public void testCityEmptyInfo() throws Throwable {
        // Creating dummy data
        createTestData(200);

        MainActivity activity = getActivity();

        assertFalse(activity.isFinishing());

        onView(withId(R.id.input_add)).perform(click());
        onView(withId(R.id.cityEt)).perform(typeText(""));

        onView(withText("city is empty.")).inRoot(isToast());
    }

    public void testOtherEmptyInfo() throws Throwable {
        // Creating dummy data
        createTestData(200);

        MainActivity activity = getActivity();

        assertFalse(activity.isFinishing());

        onView(withId(R.id.input_add)).perform(click());
        onView(withId(R.id.otherEt)).perform(typeText(""));

        onView(withText("other is empty.")).inRoot(isToast());
    }

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

このクラスは、上で定義したInjectedBaseActivityTestクラスを継承していて、InjectedBaseActivityTestでは、mockDatabaseNameに依存性注入を行っているので、このテストクラスは、テスト用データベースファイルを使用しています。

テスト項目は次のとおりです。

* Personリストが正常に表示されるか
* Person追加ボタンを押すと、登録画面に遷移するか
* Person追加ボタンを押して遷移した先の登録画面で、入力してボタンを正常に押せるか
* Personリストで、行をタップして、遷移した先の更新画面で、入力欄を書きかえて、更新ボタンを押せるか
* Person追加ボタンを押して遷移した先の登録画面で、入力欄に空があると、エラーのToastが表示されるか
* Personリストで、行をロングタップしたら、その行が削除されるか

10. ソースファイル公開

今回作成したアプリのソースファイルを公開します。

andropenguin/AddressBookORMLite12

11. 参考サイト

開発を効率的に進めるられるまでの道程
cattaka/FastCheckList
Android の非同期処理を行う Loader の起動方法
EspressoでToast表示のチェックをする
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

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

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

Google Dagger 2を使ってSharedPreferencesのテストを行ってみた

1. はじめに

SharedOreferencesを使ったAndroidアプリのテストには、 開発を効率的に進めるられるまでの道程 によると、SharedPreferencesを差し替える方法があるようです。本記事では、Google Dagger 2を使って、依存性注入を行って、テストを行いたいと思います。

2. Google Dagger 2のワークフロー

Dependency Injection With Dagger 2 on Androidによると、Google Dagger 2を利用するためのワークフローは

(1) 依存オブジェクトとその依存性を見つける
(2) @Moduleアノーテーションを付けたクラスを作り、依存性を返す各メソッドに@Provideアノーテーションを用いる
(3) @Injectアノーテーションを用いて依存するオブジェクトに依存性を要求する
(4) @Componentアノーテーションを用いてinterfaceを作り、第2ステップで作られた@Moduleアノーテーションを持つクラスを加える。
(5) その依存性で依存オブジェクトを初期化するために@Component interfaceのオブジェクトを作る

です。

3. テスト対象のAndroidアプリ

画面に、EditTextとButtonがあって、EditTextにテキストを入力、Buttonを押下すると、プリファレンスにテキストが書き込まれ、アプリを終了、再起動すると、EditTextにプリファレンスの値が表示される簡単なアプリです。メイン画面のFragmentのコードは、次のとおりです。

public class MyFragment extends Fragment implements View.OnClickListener {
    private final static String TAG = MyFragment.class.getSimpleName();

    private EditText mPrefEt;
    private Button mBtn;

    public final static String CONFIG_MAME = "appconfig";
    public final static String KEY_TEXT = "text";

    SharedPreferences mSharedPrefs;

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

        mSharedPrefs = getActivity().getSharedPreferences(CONFIG_MAME, Context.MODE_PRIVATE);
    }

    @Nullable
    @Override
    public View onCreateView(LayoutInflater inflater, ViewGroup container, Bundle savedInstanceState) {
        View view = inflater.inflate(R.layout.fragment_main, container, false);

        mPrefEt = (EditText)view.findViewById(R.id.prefEt);
        mBtn = (Button)view.findViewById(R.id.btn);
        mBtn.setOnClickListener(this);

        loadConfig();

        return view;
    }

    protected SharedPreferences getSharedPrefs() {
        return mSharedPrefs;
    }

    @Override
    public void onClick(View v) {
        int id = v.getId();
        if (id == R.id.btn) {
            String text = mPrefEt.getText().toString();
            if (TextUtils.isEmpty(text)) return;;
            saveText(text);
        }
    }

    private void loadConfig() {
        mPrefEt.setText(getSharedPrefs().getString(KEY_TEXT, ""));
    }

    private void saveText(String text) {
        SharedPreferences.Editor editor = getSharedPrefs().edit();
        editor.putString(KEY_TEXT, text);
        editor.commit();
    }
}

4. build.gradle、app/build.gradleの編集

build.gradleとapp/build.gradleを編集します。

build.gradle

// Top-level build file where you can add configuration options common to all sub-projects/modules.

buildscript {
    repositories {
        jcenter()
    }
    dependencies {
        classpath 'com.android.tools.build:gradle:1.1.1'
        classpath 'com.neenbedankt.gradle.plugins:android-apt:1.7'

        // NOTE: Do not place your application dependencies here; they belong
        // in the individual module build.gradle files
    }
}

allprojects {
    repositories {
        jcenter()
    }
}

apt(Annotation Processing Tool)を利用するために、

      classpath 'com.neenbedankt.gradle.plugins:android-apt:1.7'

の行を、build.gradleに追加しています。

apt/build.gradle

buildscript {
    repositories {
        jcenter()
    }
    dependencies {
        classpath 'com.android.tools.build:gradle:1.1.1'
    }
}
apply plugin: 'com.android.application'
apply plugin: 'com.neenbedankt.android-apt'

repositories {
    jcenter()
}

android {
    compileSdkVersion 22
    buildToolsVersion "22.0.1"

    defaultConfig {
        applicationId "com.sarltokyo.dagger2sharedpreferences6.app"
        minSdkVersion 9
        targetSdkVersion 22
        versionCode 1
        versionName "1.0"

        testInstrumentationRunner "android.support.test.runner.AndroidJUnitRunner"
    }

    compileOptions {
        sourceCompatibility JavaVersion.VERSION_1_6
        targetCompatibility JavaVersion.VERSION_1_6
    }
    buildTypes {
        release {
            minifyEnabled false
            proguardFiles getDefaultProguardFile('proguard-android.txt'), 'proguard-rules.pro'
        }
    }
    packagingOptions {
        exclude 'LICENSE.txt'
    }
}

dependencies {
    compile fileTree(dir: 'libs', include: ['*.jar'])
    compile 'com.android.support:appcompat-v7:22.2.1'

    // Dagger 2 dependencies
    compile 'com.google.dagger:dagger:2.0.1'
    apt "com.google.dagger:dagger-compiler:2.0.1"
//    apt 'com.google.dagger:dagger-compiler:2.1-SNAPSHOT'
//    compile 'com.google.dagger:dagger:2.1-SNAPSHOT'
    provided 'org.glassfish:javax.annotation:10.0-b28' // adds the @Generated annoation that Android lacks

    // 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'
    }
}

aptプラグインを利用するため、

apply plugin: 'com.android.application'

の次の行に

apply plugin: 'com.neenbedankt.android-apt'

の行を追加しています。次に、defaultConfigブロック内で、

testInstrumentationRunner "android.support.test.runner.AndroidJUnitRunner"

によって、テストランナーを指定しています。そして、dependenciesブロック内の、

    compile fileTree(dir: 'libs', include: ['*.jar'])
    compile 'com.android.support:appcompat-v7:22.2.1'

の次に、

    // Dagger 2 dependencies
    compile 'com.google.dagger:dagger:2.0.1'
    apt "com.google.dagger:dagger-compiler:2.0.1"
//    apt 'com.google.dagger:dagger-compiler:2.1-SNAPSHOT'
//    compile 'com.google.dagger:dagger:2.1-SNAPSHOT'
    provided 'org.glassfish:javax.annotation:10.0-b28' // adds the @Generated annoation that Android lacks

    // 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'
    }

を追加して、各種ライブラリのリポジトリを登録しています。dagger-compilerについては、aptを指定します。なお、ビルド時エラーが出るので、回避するため、androidブロック内に

    packagingOptions {
        exclude 'LICENSE.txt'
    }

を記述しています。

build.gradleとapp/build/gradleを編集したら、IDEに認識させるため、Sync Nowを押します。Project Strructureを開いて、Ploblemsに何も表示されなければ、ライブラリが正常に導入されています。

5. 依存オブジェクトとその依存性を見つける

メイン画面のMyFragmentのソースコードを見ると、依存オブジェクトは、mSharedPrefsで、SharedPreferencesに依存しています。

6. @Moduleアノーテーションを付けたクラスを作り、依存性を返す各メソッドに@Provideアノーテーションを用いる

プロダクションコードで、本記事の場合、com.sarltokyo.dagger2sharedpreferences6.moduleパッケージを作り、次のクラス SharedPrefsModuleをを作ります。

import dagger.Module;
import dagger.Provides;

import javax.inject.Singleton;

@Module
public class SharedPrefsModule {

    @Provides
    @Singleton
    SharedPreferences provideSharedPrefs() {
        // todo: 仮実装
        return null;
    }
}

クラスに、@Moudleアノーテーションを付け、依存性 mSharedPrefsを返すメソッド provideSharedPrefsに、@Provideアノーテーションを付けます。メソッド名は、プレフィックスに、provideを付けるのが慣例になっています。

src配下に、debug/javaディレクトリを作り、その下に、パッケージ com.sarltokyo.dagger2sharedpreferences6.moduleパッケージを作り、次のクラス

import dagger.Module;
import dagger.Provides;

import javax.inject.Singleton;

@Module
public class DebugSharedPrefsModule {
    private final boolean mockMode;

    // テスト用
    public final static String CONFIG_MAME = "test_" + MyFragment.CONFIG_MAME;

    public DebugSharedPrefsModule(boolean provideMocks) {
        mockMode = provideMocks;
    }

    @Provides
    @Singleton
    SharedPreferences provideSharedPrefs() {
        if (mockMode) {
           // todo: 仮実装
	   return null;
        } else {
           // todo: 仮実装
	   return null;
	}
    }
}

を作ります。provideSharedPrefsは、仮実装で、nullを返すように記述していますが、後で修正します。アノーテーションはプロダクションコードと同様に付けます。また、テスト用プリファレンスファイルを指定するため、

    // テスト用
    public final static String CONFIG_MAME = "test_" + MyFragment.CONFIG_MAME;

を記述しています。テスト時には、本番環境のプリファレンスに影響しないように、プリファレンスファイルはプレフィックス”test_”を付けたものを使用するようにします。これは、後で使用します。

7. @Injectアノーテーションを用いて依存するオブジェクトに依存性を要求する

MyFragmentクラスで、依存するオブジェクトは、mSharedPrefsなので、MyFragmentクラスで、

    @Inject
    SharedPreferences mSharedPrefs;

のように、@Injectアノーテーションを付けます。

8. @Componentアノーテーションを用いてinterfaceを作り、第2ステップで作られた@Moduleアノーテーションを持つクラスを加える

テストで、依存性注入によって、プリファレンスファイルを、テスト用プリファレンスファイルに置き換えるために、本記事では、componetのクラスを、debug/javaディレクトリに作ります。

その前に、ActivityInstrumentationTestCase2クラスを使ってテストを行うため、依存性注入の設定を行う、ActivityInstrumentationTestCase2を継承したクラス InjectedBaseActivityTestを com.sarltokyo.dagger2sharedpreferences6 パッケージに作ります。

import javax.inject.Inject;

public class InjectedBaseActivityTest extends ActivityInstrumentationTestCase2<MainActivity> {

    @Inject
    SharedPreferences mockSharedPrefs;

    public InjectedBaseActivityTest(Class activityClass) {
        super(activityClass);
    }

    @Override
    protected void setUp() throws Exception {
        super.setUp();

        // todo: 仮実装
    }

    @Override
    protected void tearDown() throws Exception {
         // todo: 仮実装
    }

    protected SharedPreferences getSharedPrefs() {
        return mockSharedPrefs;
    }
}

ここで、依存オブジェクトmockSharedPrefsに@Injectアノーテーションを付けています。また、setUp、tearDownでは、依存性注入の設定のオン、オフを行う設定を後で行います。

次に、debug/javaディレクトリのcom.sarltokyo.dagger2sharedpreferences6.componentパッケージに、次の内容のinterface SharedPrefsComponentを作ります。

import dagger.Component;

import javax.inject.Singleton;

@Singleton
@Component(modules = {DebugSharedPrefsModule.class})
public interface SharedPrefsComponent {
    void inject(MyFragment fragment);
    void inject(InjectedBaseActivityTest test);

    public final static class Initializer {
        public static SharedPrefsComponent init(boolean mockMode) {
	    // todo: 仮実装
            return null;
        }
    }
}

iterfaceには、@Componentアノーテーションを付け、modilesで、モジュールクラスを列挙します。複数ある場合は、{}の中に、,区切りでクラスを記述します。また、依存性を注入するクラスに対して、injectメソッドを定義します。本ケースでは、MyFragmentとInjdectedBaseActivityで依存性注入を行うので、

    void inject(MyFragment fragment);
    void inject(InjectedBaseActivityTest test);

と定義しています。また、staticな内部クラス Initializerを定義し、SharedPrefsComponentを返す、initメソッドを定義します。メソッドの中身は後で本実装するので、今は、仮実装で、nullを返すようにしています。

9. Applicationを継承したクラス Appを作る

MyFragmentとInjectedBaseActivityTestの両方から依存性注入を行えるように、次の内容のApplicationを継承したAppクラスを作ります。

public class App extends Application {

    private static App sInstance;
    private SharedPrefsComponent component;

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

        sInstance = this;
       	component = SharedPrefsComponent.Initializer.init(false);
    }

    public static App getInstance() {
        return sInstance;
    }

    public SharedPrefsComponent component() {
        return component;
    }

    public void setMockMode(boolean useMock) {
        component = SharedPrefsComponent.Initializer.init(useMock);
    }
}

そして、Manifestファイルに、このAppを登録します。これを忘れると、アプリが落ちるし、テストもできません。

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

    <application
        android:name="com.sarltokyo.dagger2sharedpreferences6.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>

AppがManifestファイルに登録されているので、アプリ起動時に、AppのonCreateメソッドが呼ばれ、

        component = SharedPrefsComponent.Initializer.init(false);

の行が実行され、本番環境では、debugモードがfalseになります。

10. その依存性で依存オブジェクトを初期化するために@Component interfaceのオブジェクトを作る

この時点で、プロジェクトをビルドして、todoコメントをした所を本実装していきます。

まず、SharedPrefsComponentで、returnの後に、 Daggerと打つと、DaggerSharedPrefsComponentと補完され、builder()、debugSharedPrefsModule()メソッドを順に呼ぶことができるようになります。debugSharedPrefsModule()の引数には、new DebugSharedPrefsModule(mockMode)を記述します。最後に、build()メソッドを呼ぶと、ビルドエラーの印が消えます。

import dagger.Component;

import javax.inject.Singleton;

@Singleton
@Component(modules = {DebugSharedPrefsModule.class})
public interface SharedPrefsComponent {
    void inject(MyFragment fragment);
    void inject(InjectedBaseActivityTest test);

    public final static class Initializer {
        public static SharedPrefsComponent init(boolean mockMode) {
            return DaggerSharedPrefsComponent.builder()
                    .debugSharedPrefsModule(new DebugSharedPrefsModule(mockMode))
                    .build();
        }
    }
}

あとは、Android StudioかIntelliJの下のバーにあるTODOをクリックして、todoコメントがある所を見つけ、修正していきます。

DebugSharedPrefsModule

    @Provides
    @Singleton
    SharedPreferences provideSharedPrefs() {
        if (mockMode) {
//            return mock(SharedPreferences.class);
            // mockではなく、テスト用プリファレンスファイルを使用
            return App.getInstance()
                    .getSharedPreferences(CONFIG_MAME, Context.MODE_PRIVATE);
        } else {
            return App.getInstance()
                    .getSharedPreferences(MyFragment.CONFIG_MAME, Context.MODE_PRIVATE);
        }
    }

のように修正します。mockModeがtrueの時は、テスト用SharedPreferencesを返すようにし、falseの時は、プロダクション用のSharedPreferencesを返すようにします。本記事では、mockModeがtrueの時、provideSharedPrefsメソッドが、テスト用プリファレンスファイルを使用したSharedPreferencesを返すようにしていますが、コメントにあるように、Mockitoのmockを使用することもできます。ただし、その場合、プリファレンスの読み込み、表示のテストはできますが、プリファレンスファイルへの書き込み、更新のテストはできません。

InjectedBaseActivityTest

    @Override
    protected void setUp() throws Exception {
        super.setUp();

        App app = (App)getInstrumentation().getTargetContext().getApplicationContext();
        app.setMockMode(true);
        app.component().inject(this);

        getSharedPrefs().edit().clear().commit();
    }

    @Override
    protected void tearDown() throws Exception {
//        getSharedPrefs().edit().clear().commit();

        App.getInstance().setMockMode(false);
    }

のように修正します。テスト用クラスでは、setUpメソッド内で、Appオブジェクトを取得して、setMockModeでモックモードにして、app.component()でコンポネントを呼んで、injectで依存性注入を行います。また、テストケースごとに、プリファレンスの内容を消すようにしています。tearDownメソッド内で、モックモードをオフにしています。

SharedPrefsModule

    @Provides
    @Singleton
    SharedPreferences provideSharedPrefs() {
        return App.getInstance()
                .getSharedPreferences(MyFragment.CONFIG_MAME, Context.MODE_PRIVATE);

    }

プロダクション用には、実際のプリファレンスファイルを使うので、App.getInatnce()からApplicationインスタンスを得、getSharedPreferencesメソッドで、プロダクション用プリファレンスファイルを使用するプリファレンスを返します。

11. MyFragmentでの修正

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

//        mSharedPrefs = getActivity().getSharedPreferences(CONFIG_MAME, Context.MODE_PRIVATE);
        App.getInstance().component().inject(this);
    }

のように依存性注入を行います。

なお、これは必要ありませんが、GitHubにアップしたコードは、

    protected SharedPreferences getSharedPrefs() {
        return mSharedPrefs;
    }

を定義し、

    private void loadConfig() {
//        mPrefEt.setText(mSharedPrefs.getString(KEY_TEXT, ""));
        mPrefEt.setText(getSharedPrefs().getString(KEY_TEXT, ""));
    }

    private void saveText(String text) {
//        SharedPreferences.Editor editor = mSharedPrefs.edit();
        SharedPreferences.Editor editor = getSharedPrefs().edit();
        editor.putString(KEY_TEXT, text);
        editor.commit();
    }

と修正しました。これはしなくても、アプリもテストも動きます。

12. UIおよびSharedPreferecnesテスト用のテストファイルを作る

androidTest/javaのcom.sarltokyo.dagger2sharedpreferences6パッケージに、次のようなInjectedBaseActivityTestクラスを継承したクラスMainActivityTest を作ります。

public class MainActivityTest extends InjectedBaseActivityTest {
    public MainActivityTest() {
        super(MainActivity.class);
    }

    @Override
    protected void setUp() throws Exception {
        super.setUp();
    }

    public void testPrefValueIsDisplayed() throws Exception {

        getSharedPrefs().edit().putString(MyFragment.KEY_TEXT, "foo").commit();

        getActivity();

        Instrumentation.ActivityMonitor monitor
                = getInstrumentation().addMonitor(MainActivity.class.getName(),
                null, true);

        onView(withId(R.id.prefEt)).check(matches(withText("foo")));

        getInstrumentation().removeMonitor(monitor);
    }

    /**
     * todo
     * 一度、ボタン押下でプリファレンスに書き込んで、Activity#finishで画面を閉じ、
     * 再度、getActivityしても、画面が開かず、Activityでプリファレンスの値の書き込み、
     * 更新がテストできない。
     */
    public void testWritePref() throws Exception {

        getActivity();

        Instrumentation.ActivityMonitor monitor
                = getInstrumentation().addMonitor(MainActivity.class.getName(),
                null, true);

        onView(withId(R.id.prefEt)).perform(typeText("hoge"));
        onView(withId(R.id.btn)).perform(click());

        String actual = getSharedPrefs().getString(MyFragment.KEY_TEXT, "");
        assertThat(actual, is("hoge"));

        getInstrumentation().removeMonitor(monitor);
    }

    public void testUpdatePref() throws Exception {

        getSharedPrefs().edit().putString(MyFragment.KEY_TEXT, "foo").commit();

        getActivity();

        Instrumentation.ActivityMonitor monitor
                = getInstrumentation().addMonitor(MainActivity.class.getName(),
                null, true);

        onView(withId(R.id.prefEt)).check(matches(withText("foo")));
        onView(withId(R.id.prefEt)).perform(clearText()).perform(typeText("boo"));
        onView(withId(R.id.btn)).perform(click());

        String actual = getSharedPrefs().getString(MyFragment.KEY_TEXT, "");
        assertThat(actual, is("boo"));

        getInstrumentation().removeMonitor(monitor);
    }
}

ここで、わかりにくいですが、テストクラスに出てくるgetSharedPrefs()に関してですが、テストクラスがInjectedBaseActivityTestを継承していて、InjectedBaseActivityTestに、モックのmockSharedPrefsを返すgetSharedPrefsメソッドが定義されているので、テストクラスのgetSharedPrefs()はモックのプリファレンスを返します。従って、テストでは、テスト用のプリファレンスファイルを使用してテストを行っています。また、ここでは、依存性注入を行って、プリファレンスファイルをテスト用に置き換えていますので、プリファレンスへの書き込み、読み込みができます。

テストは次の項目をテストしています。
* プリファレンスに書き込んでおいた値が、正しくEditTextに表示されるか
* EditTextにtextを入力して、registerボタンを押したら、プリファレンスの内容が正しく反映されているか
* プリファレンスに書き込んでおいた値を、EditTextに表示し、EditTextのテキストを書き換え、registerボタンを押したら、プリファレンスには変更後の値が書き込まれているか

13. テスト

テストランナーは、android.support.test.runner.AndroidJUnitRunner を使用します。

14. サンプルプロジェクト公開

本記事で扱ったサンプルプロジェクトを公開します。

andropenguin/Dagger2SharedPreferences6

また、SharedPreferecnesをユーティリティクラスに持たせ、Fragmentからは、ユーティリティクラスののメソッドを通じてプリファレンスに読み書きするアプリの場合で、Google Dagger 2で依存性注入を行いテストするサンプルも公開します。

andropenguin/Dagger2SharedPreferences7

15. 参考サイト

開発を効率的に進めるられるまでの道程
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
Android Testing with Espresso 2 and Dagger 2 – mocking, long running operations.