OpenCVのサンプル face-detection をAndroid Studioでビルド、実行する

OpenCVのサンプル face-detection をAndroid Studioでビルド、実行する

1. はじめに

Android Studio 1.4では、NDKをサポートしているようなので、練習として、OpenCV for Android OpenCV > PLATFORMS > ANDROID のサンプル face-detection をAndroid Studioでビルド、実行します。

2. OpenCV for Android SDKのインストール

OpenCV > DOWNLOADS より、OpenCV for Android をダウンロードします。記事執筆時の最新バージョンは、3.1です。zipファイルを適当なディレクトリに展開します。

$ cd ~/src
$ unzip OpenCV-3.1.0-android-sdk.zip

3. face-detectionのAndroid Studioへのインポート

OpenCV-android-sdk/samplesにあるface-detectionをAndroid Studioにインポートします。Android StudioのQuick StartのImport project (Eclipse ADT, Gradle, etc.)を使って、Select Eclipse or Gradle Project to importで、face-detectionを選択して、OKをします。

3. build.gradleの修正

サンプル face-detectionをAndroid Studioにインポートすると、次のようなエラーが出ます。

Failed to sync Gradle project 'face-detection'
Error:Cause: failed to find target with hash string 'android-14' in: /Users/osabe/src/android-sdk-macosx openAndroidSdkManager"

このエラーは、build.gradleで、compileSdkVersionが14に指定されているが、Android SDKのパッケージリポジトリにandroid-14がないのが原因です。また、本サンプルでは、android.hardware.camera2 の機能を使用していますが、これは、API level 21から利用可能です( android.hardware.camera2 )。そこで、build.gradleで、compileSdkVersion、minSdkVersionの値を修正します。

修正箇所
- openCVLibrary310の下のbuild.gradleファイル

    compileSdkVersion 14
    buildToolsVersion "23.0.2"

    defaultConfig {
        minSdkVersion 8
        targetSdkVersion 21
    }

    compileSdkVersion 21
    buildToolsVersion "23.0.2"

    defaultConfig {
        minSdkVersion 21
        targetSdkVersion 21
    }

に修正。
– openCVSamplefacedetectionの下のbuild.gradleファイル

    compileSdkVersion 14
    buildToolsVersion "23.0.2"

    defaultConfig {
        applicationId "org.opencv.samples.facedetect"
        minSdkVersion 8
        targetSdkVersion 8

        ndk {
            moduleName "detection_based_tracker"
        }
    }

    compileSdkVersion 21
    buildToolsVersion "23.0.2"

    defaultConfig {
        applicationId "org.opencv.samples.facedetect"
        minSdkVersion 21
        targetSdkVersion 21

        ndk {
            moduleName "detection_based_tracker"
        }
    }

に修正。

4. gradle.propetiesファイルの作成

Gradle projectをsyncすると、次のようなエラーが発生する。

Error:(12, 0) Error: NDK integration is deprecated in the current plugin.  Consider trying the new experimental plugin.  For details, see http://tools.android.com/tech-docs/new-build-system/gradle-experimental.  Set "android.useDeprecatedNdk=true" in gradle.properties to continue using the current NDK integration.

Top / OS関係 / Android / AndroidStudio /AndroidStudio によると、トップディレクトリに、次の内容のファイル gradle.propertiesを作成するといい。

android.useDeprecatedNdk=true

ファイルを作成して、プロジェクトをsyncする。

5. local.propertiesファイルの修正

4で、gradle.propertiesファイルを作成し、プロジェクトをsyncすると、次のエラーが発生する。

Error:Execution failed for task ':openCVSamplefacedetection:compileDebugNdk'.
> NDK not configured.
  Download the NDK from http://developer.android.com/tools/sdk/ndk/.Then add ndk.dir=path/to/ndk in local.properties.
  (On Windows, make sure you escape backslashes, e.g. C:\\ndk rather than C:\ndk)

トップディレクトリのlocal.propertiesファイルに、

ndk.dir=<android ndkの絶対パス>

を記述する。その後、プロジェクトをClean Project、Rebuild Projectする。

6. ビルド失敗に対する対策(openCVSamplefacedetectionのbuild.gradleを編集)

プロジェクトをRebuildすると、次のエラーが発生する。

Error:(2, 33) opencv2/core/core.hpp: No such file or directory

AndroidStudioでOpenCVのサンプルを動かす によると、このエラーは、Android Studioでのビルドで、Android.mkの設定が反映されないのが原因。ビルド時に、ndk-buildを実行するタスクを追加する。次のように、openCVSamplefacedetectionのbuild.gradleのandroidブロック内で、

    sourceSets.main.jni.srcDirs = []

    task buildNative(type: Exec, description: 'Compile JNI source via NDK') {
        def ndkDir = "/Users/foo/src/android-ndk-r10e"
        commandLine "$ndkDir/ndk-build",
                '-C', file('src/main/jni').absolutePath,
                '-j', Runtime.runtime.availableProcessors(),
                'all'
    }

    tasks.withType(JavaCompile) {
        compileTask -> compileTask.dependsOn buildNative
    }

を追加する。ファイル修正後、再度、Rebuild。

7. Rebuild失敗に対する対策

6で、openCVSamplefacedetectionのbukld.gradleの修正後、プロジェクトをRebuildすると、次のエラーが発生する。

Error:(15) ../../sdk/native/jni/OpenCV.mk: No such file or directory

AndroidStudioでOpenCVのサンプルを動かす によると、OpenCVのsdkを、プロジェクトから認識できないのが原因で、openCVSamplefacedetection/src/main/jniにあるAndroid.mkファイルを次のように修正する。

  include ../../sdk/native/jni/OpenCV.mk

   include <OpenCV sdkの絶対パス>/sdk/native/jni/OpenCV.mk

に修正する。修正後、Rebuild。face-detection/openCVSamplefacedetection/src/main/libs/armeabi-v7aディレクトリに、libdetection_based_tracker.so ファイルが生成されれば、ビルドが成功する。

8. アプリ実行

アプリをRunすると、次のようなエラーが発生する。

03-29 11:12:54.312 21512-21512/org.opencv.samples.facedetect D/AndroidRuntime: Shutting down VM
03-29 11:12:54.312 21512-21512/org.opencv.samples.facedetect E/AndroidRuntime: FATAL EXCEPTION: main
03-29 11:12:54.312 21512-21512/org.opencv.samples.facedetect E/AndroidRuntime: Process: org.opencv.samples.facedetect, PID: 21512
03-29 11:12:54.312 21512-21512/org.opencv.samples.facedetect E/AndroidRuntime: java.lang.UnsatisfiedLinkError: dalvik.system.PathClassLoader[DexPathList[[zip file "/data/app/org.opencv.samples.facedetect-2/base.apk"],nativeLibraryDirectories=[/data/app/org.opencv.samples.facedetect-2/lib/arm64, /vendor/lib64, /system/lib64]]] couldn't find "libdetection_based_tracker.so"
03-29 11:12:54.312 21512-21512/org.opencv.samples.facedetect E/AndroidRuntime:     at java.lang.Runtime.loadLibrary(Runtime.java:367)
03-29 11:12:54.312 21512-21512/org.opencv.samples.facedetect E/AndroidRuntime:     at java.lang.System.loadLibrary(System.java:1076)
03-29 11:12:54.312 21512-21512/org.opencv.samples.facedetect E/AndroidRuntime:     at org.opencv.samples.facedetect.FdActivity$1.onManagerConnected(FdActivity.java:67)
03-29 11:12:54.312 21512-21512/org.opencv.samples.facedetect E/AndroidRuntime:     at org.opencv.android.AsyncServiceHelper$3.onServiceConnected(AsyncServiceHelper.java:319)
03-29 11:12:54.312 21512-21512/org.opencv.samples.facedetect E/AndroidRuntime:     at android.app.LoadedApk$ServiceDispatcher.doConnected(LoadedApk.java:1223)
03-29 11:12:54.312 21512-21512/org.opencv.samples.facedetect E/AndroidRuntime:     at android.app.LoadedApk$ServiceDispatcher$RunConnection.run(LoadedApk.java:1240)
03-29 11:12:54.312 21512-21512/org.opencv.samples.facedetect E/AndroidRuntime:     at android.os.Handler.handleCallback(Handler.java:739)
03-29 11:12:54.312 21512-21512/org.opencv.samples.facedetect E/AndroidRuntime:     at android.os.Handler.dispatchMessage(Handler.java:95)
03-29 11:12:54.312 21512-21512/org.opencv.samples.facedetect E/AndroidRuntime:     at android.os.Looper.loop(Looper.java:148)
03-29 11:12:54.312 21512-21512/org.opencv.samples.facedetect E/AndroidRuntime:     at android.app.ActivityThread.main(ActivityThread.java:5417)
03-29 11:12:54.312 21512-21512/org.opencv.samples.facedetect E/AndroidRuntime:     at java.lang.reflect.Method.invoke(Native Method)
03-29 11:12:54.312 21512-21512/org.opencv.samples.facedetect E/AndroidRuntime:     at com.android.internal.os.ZygoteInit$MethodAndArgsCaller.run(ZygoteInit.java:726)
03-29 11:12:54.312 21512-21512/org.opencv.samples.facedetect E/AndroidRuntime:     at com.android.internal.os.ZygoteInit.main(ZygoteInit.java:616)
03-29 11:12:54.319 21512-21512/org.opencv.samples.facedetect I/Process: Sending signal. PID: 21512 SIG: 9

face-detection/openCVSamplefacedetection/build/outputs/apkディレクトリに生成したapkファイルをunzipで展開すると、ダイナミックファイルが含まれていないのが、エラーの原因( AndroidStudioでOpenCVのサンプルを動かす ) 。そこで、シンボリックをはる。

$ cd face-detection/openCVSamplefacedetection/src/main
$ ln -s libs jniLibs

9. OpenCV Managerのインストール

アプリを再度、Runする。今度は、OpenCV Managerがインストールされていないので、インストールしろというメッセージが出る。そこで、OpenCV Mangerをインストールする。

10. アプリを再度、実行

アプリが動作するようになる。ただし、Nexus 6Pでは、logcatに次のエラーを出して、アプリが実行できない。

03-29 11:23:24.338 23120-23120/? E/AndroidRuntime: java.lang.UnsatisfiedLinkError: dalvik.system.PathClassLoader[DexPathList[[zip file "/data/app/org.opencv.samples.facedetect-1/base.apk"],nativeLibraryDirectories=[/data/app/org.opencv.samples.facedetect-1/lib/arm64, /vendor/lib64, /system/lib64]]] couldn't find "libdetection_based_tracker.so"

NDK r10 b 32 bit or 64 bit or compile using both and how to achieve it によると、原因は、Android Studioが生成するjniのダイナミックライブラリが32bitの物に対して、Nexus 6PのCPUがarm 64bitであるため。そこで、face-detection/openCVSamplefacedetection/src/main/jni ディレクトリにある Application.mk ファイルを次のように修正する。

APP_ABI := armeabi-v7a arm64-v8a

修正後、プロジェクトをBuild Clean、Rebuild。再度、アプリ実行する。正常実行される。

11. 参考サイト
OpenCV > PLATFORMS > ANDROID
OpenCV > DOWNLOADS
android.hardware.camera2
NDK r10 b 32 bit or 64 bit or compile using both and how to achieve it
AndroidStudioでOpenCVのサンプルを動かす

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.

ORMLiteを使ったアドレス帳アプリをテストしやすいように書き換え、Espressoでテストした

1. はじめに

以前公開したORMLiteを使ったアドレス帳アプリandropenguin/AddressBookORMliteをテストしやすいように書き直し、Espressoでテストを行います。

住友孝郎@cattaka_net氏のスライド開発を効率的に進めるられるまでの道程を読んで、データベース、Adapterがあるアプリで、どうテストしやすく書き換えるか、どうテストするか学びました。

上記スライドによると、データベース、Adapter、プリファレンスの単体テストを行い、また、Activityのテスト、つまりUIのテストは、データベース、Adapter、プリファレンスをダミーに置き換えてテストを行うと、いいそうです。上記スライドは、テストのやり方の概要を紹介するもので、実際のテストの仕方は、cattaka/FastCheckListのサンプルを読んで学びました。

以前公開したアドレス帳アプリAddressBookORMliteをテストしやすいように、adapter、app、asynctask、core、db、entityのサブパッケージを作ります。以前のアプリでは、adapterやdbに関するコードは、アクティビティに書いていましたし、また、DBから全件取得するコードを書いていたにも関わらず、AsyncTaskLoaderを使用していなかったので、asynctaskのサブパッケージも導入しました。あと、coreサブパッケージにあるクラスは、住友孝郎@cattaka_net氏が導入しているものです。

2. プロダクションコード

プロダクションコードの各クラスを見ていきます。

2.1 entityサブパッケージ

PersonクラスとAddressクラスは以下の通りです。多分内容は以前と変わっていないと思います。

Person.java

package com.sarltokyo.addressbookormlite7.entity;

import com.j256.ormlite.field.DatabaseField;
import com.j256.ormlite.table.DatabaseTable;

@DatabaseTable(tableName = "person")
public class Person {

    @DatabaseField(generatedId = true)
    private Integer id;
    @DatabaseField(unique = true)
    private String name;
    @DatabaseField(foreign = true, foreignAutoCreate = true, foreignAutoRefresh = true)
    private Address address;

    public Person() {
    }

    public Integer getId() {
        return id;
    }

    public String getName() {
        return name;
    }

    public void setName(String name) {
        this.name = name;
    }

    public Address getAddress() {
        return address;
    }

    public void setAddress(Address address) {
        this.address = address;
    }
}

Address.java

package com.sarltokyo.addressbookormlite7.entity;

import com.j256.ormlite.field.DatabaseField;
import com.j256.ormlite.table.DatabaseTable;

@DatabaseTable(tableName = "address")
public class Address {

    @DatabaseField(generatedId = true)
    private Integer id;
    @DatabaseField
    private String zipcode;
    @DatabaseField
    private String prefecture;
    @DatabaseField
    private String city;
    @DatabaseField
    private String other;


    public Address() {
    }

    public Integer getId() {
        return id;
    }

    public String getZipcode() {
        return zipcode;
    }

    public void setZipcode(String zipcode) {
        this.zipcode = zipcode;
    }

    public String getPrefecture() {
        return prefecture;
    }

    public void setPrefecture(String prefecture) {
        this.prefecture = prefecture;
    }

    public String getCity() {
        return city;
    }

    public void setCity(String city) {
        this.city = city;
    }

    public String getOther() {
        return other;
    }

    public void setOther(String other) {
        this.other = other;
    }
}

2.2 dbサブパッケージ

データベースに関するコードは次の通りです。以前は、CRUDのコードを他のクラスに書いていましたが、テストしやすいように、OpenHelperクラスに書きました。

OpenHelper.java

package com.sarltokyo.addressbookormlite7.db;

import android.content.Context;
import android.database.sqlite.SQLiteDatabase;
import android.util.Log;
import com.j256.ormlite.android.apptools.OrmLiteSqliteOpenHelper;
import com.j256.ormlite.dao.Dao;
import com.j256.ormlite.support.ConnectionSource;
import com.j256.ormlite.table.TableUtils;
import com.sarltokyo.addressbookormlite7.entity.Address;
import com.sarltokyo.addressbookormlite7.entity.Person;

import java.sql.SQLException;
import java.util.List;

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

    private final static String DATABASE_NAME = "addressbook.db";
    private final static int DATABASE_VERSION = 1;

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

    public OpenHelper(Context context) {
        this(context, DATABASE_NAME);
    }


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

    @Override
    public void onCreate(SQLiteDatabase database, ConnectionSource connectionSource) {
        try {
            // エンティティを指定してcreate tableする
            TableUtils.createTable(connectionSource, Person.class);
            TableUtils.createTable(connectionSource, Address.class);
        } catch (SQLException e) {
            Log.e(TAG, "cannot create database");
            Log.e(TAG, e.getMessage());
        }
    }

    @Override
    public void onUpgrade(SQLiteDatabase database, ConnectionSource connectionSource, int oldVersion, int newVersion) {
        try {
            TableUtils.dropTable(connectionSource, Address.class, true);
            TableUtils.dropTable(connectionSource, Person.class, true);
            onCreate(database);
        } catch (SQLException e) {
            Log.e(TAG, "cannot upgrade database");
            Log.e(TAG, e.getMessage());
        }
    }

    public Dao<Person, Integer> getPersonDao() throws SQLException {
        if (mPersonDao == null) {
            mPersonDao = getDao(Person.class);
        }
        return mPersonDao;
    }

    public Dao<Address, Integer> getAddressDao() throws SQLException {
        if (mAddressDao == null) {
            mAddressDao = getDao(Address.class);
        }
        return mAddressDao;
    }

    public Person findPerson(String name) throws SQLException {
        List<Person> persons = getPersonDao().queryForEq("name", name);
        if (persons.isEmpty()) {
            Log.d(TAG, "persons isEmpty");
            return null;
        } else {
            Log.d(TAG, "person exists");
            return persons.get(0);
        }
    }

    public List<Person> findPerson() throws SQLException {
        return getPersonDao().queryForAll();
    }

    public void registerPerson(Person person) throws SQLException {
        getPersonDao().create(person);
    }

    public void updatePerson(Person person) throws SQLException {
        Address address = person.getAddress();
        getAddressDao().update(address); // todo
        getPersonDao().update(person);
    }

    public boolean deletePerson(String name) throws SQLException {
        Person person = findPerson(name);
        if (person == null) {
            return false;
        } else {
            Address address = person.getAddress(); // todo
            getAddressDao().delete(address);
            getPersonDao().delete(person);
            return true;
        }
    }
}

2.3 coreサブパッケージ

住友孝郎@cattaka_net氏のコードを使用しています。

2.4 asynctaskサブパッケージ

AsyncTaskLoaderはほぼ定番コードを使用するので、Android の非同期処理を行う Loader の起動方法で入手した、absractなAsyncTaskLoaderを使用します。下記コードで、使用ケースに応じて、Tにクラス名を書いて使用します。

AbstractAsyncTaskLoader.java

package com.sarltokyo.addressbookormlite7.asynctask;

import android.content.Context;
import android.support.v4.content.AsyncTaskLoader;

/**
 * Android の非同期処理を行う Loader の起動方法
 * http://tomoyamkung.net/2014/02/24/android-loader-execute/
 */
public abstract class AbstractAsyncTaskLoader<T> extends AsyncTaskLoader<T> {

    protected T mResult;

    public AbstractAsyncTaskLoader(Context context) {
        super(context);
    }


    abstract public T loadInBackground();

    @Override
    public void deliverResult(T data) {
        if (isReset()) {
            return;
        }

        mResult = data;
        if (isStarted()) {
            super.deliverResult(data);
        }
    }

    @Override
    protected void onStartLoading() {
        if (mResult != null) {
            deliverResult(mResult);
        }

        if (takeContentChanged() || mResult == null) {
            forceLoad(); // 非同期処理を開始
        }
    }

    @Override
    protected void onStopLoading() {
        cancelLoad(); // 非同期処理のキャンセル
    }

    @Override
    public void onCanceled(T data) {
        // nop
    }

    @Override
    protected void onReset() {
        super.onReset();

        onStopLoading();
        mResult = null;
    }
}

実際に使用するPersonLoader.javaは以下の通りです。上記AbstractAsyncTaskLoaderクラスで、TをListにしたクラスを継承します。AsyncTaskLoaderクラスについては、十分テストが行われていると思われるので、loadInBackground内の処理のみをテストするため、loadInBackground内の処理をメソッド抽出しています。

PersonLoader.java

import java.util.List;

public class PersonLoader extends AbstractAsyncTaskLoader<List<Person>> {

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

    private ContextLogic mContextLogic;
    private OpenHelper mOpenHelper;

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

    @Override
    public List<Person> loadInBackground() {
        return getPersons();
    }

    public List<Person> getPersons() {
        List<Person> persons = null;
        try {
            persons = mOpenHelper.findPerson();
        } catch (SQLException e) {
            Log.e(TAG, e.getMessage());
        }
        if (persons == null) {
            persons = new ArrayList<Person>();
        }

        if (persons.size() == 0) {
            Log.d(TAG, "person is empty");
        }
        for (Person person: persons) {
            Log.d(TAG, "person = " + person.getName());
        }
        return persons;
    }
}

2.5 appサブパッケージ

アクティビティを置いたサブパッケージです。

MainActivity.java

package com.sarltokyo.addressbookormlite7.app;

import android.app.ProgressDialog;
import android.content.Intent;
import android.support.v4.app.LoaderManager;
import android.support.v4.content.Loader;
import android.os.Bundle;
import android.support.v7.app.AppCompatActivity;
import android.util.Log;
import android.view.Menu;
import android.view.MenuItem;
import android.view.View;
import android.widget.AdapterView;
import android.widget.ListView;
import android.widget.Toast;
import com.sarltokyo.addressbookormlite7.adapter.AdapterEx;
import com.sarltokyo.addressbookormlite7.asynctask.PersonLoader;
import com.sarltokyo.addressbookormlite7.core.ContextLogic;
import com.sarltokyo.addressbookormlite7.core.ContextLogicFactory;
import com.sarltokyo.addressbookormlite7.db.OpenHelper;
import com.sarltokyo.addressbookormlite7.entity.Person;

import java.sql.SQLException;
import java.util.List;


public class MainActivity extends AppCompatActivity
        implements LoaderManager.LoaderCallbacks<List<Person>> {

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

    private ListView mListView;
    private OpenHelper mOpenHelper;

    public final static String TYPE = "type";
    public final static String CREATE_DATA_TYPE = "create";
    public final static String UPDATE_DATA_TYPE = "update";

    private AdapterEx mPersonsAdapter;
    private ContextLogic mContextLogic;
    private ProgressDialog mProgressDialog;


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

        // todo: ここ重要。DBをproduction用、test用に切り替えられるようにする。
        mContextLogic = ContextLogicFactory.createContextLogic(this);
        mOpenHelper = mContextLogic.createOpenHelper();
    }

    @Override
    protected void onResume() {
        super.onResume();
        Log.d(TAG, "onResume called");
        showProgress();
        LoaderManager manager = getSupportLoaderManager();
        if (manager.getLoader(0) != null) {
            manager.destroyLoader(0);
        }
        manager.initLoader(0, null, this);
    }

    @Override
    public boolean onCreateOptionsMenu(Menu menu) {
        // Inflate the menu; this adds items to the action bar if it is present.
        getMenuInflater().inflate(R.menu.menu_main, menu);
        return true;
    }

    @Override
    public boolean onOptionsItemSelected(MenuItem item) {
        // Handle action bar item clicks here. The action bar will
        // automatically handle clicks on the Home/Up button, so long
        // as you specify a parent activity in AndroidManifest.xml.
        int id = item.getItemId();

        //noinspection SimplifiableIfStatement
        if (id == R.id.action_settings) {
            return true;
        } else if (id == R.id.input_add) {
            Intent intent = new Intent(MainActivity.this, RegisterActivity.class);
            intent.putExtra(TYPE, CREATE_DATA_TYPE);
            startActivity(intent);
            return true;
        }

        return super.onOptionsItemSelected(item);
    }

    private void showProgress() {
        Log.d(TAG, "showProcess()");
        mProgressDialog = new ProgressDialog(this);
        mProgressDialog.setMessage("running");
        mProgressDialog.show();
    }

    private void dismissProgress() {
        Log.d(TAG, "dismissProgress()");
        mProgressDialog.dismiss();
    }

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

    @Override
    public void onLoadFinished(Loader<List<Person>> loader, List<Person> data) {
        Log.d(TAG, "PersonLoader finished");
        dismissProgress();

        if (data != null) {
            mPersonsAdapter = new AdapterEx(this, data);
            mListView = (ListView)findViewById(android.R.id.list);
            mListView.setAdapter(mPersonsAdapter);

            mListView.setOnItemClickListener(new AdapterView.OnItemClickListener() {
                @Override
                public void onItemClick(AdapterView<?> parent, View view, int position, long id) {
                    updateAddress(view);
                }
            });

            mListView.setOnItemLongClickListener(new AdapterView.OnItemLongClickListener() {
                @Override
                public boolean onItemLongClick(AdapterView<?> parent, View view, int position, long id) {
                    return deletePerson(view);
                }
            });
        } else {
            // todo
            Toast.makeText(this, "Some error occured.", Toast.LENGTH_LONG).show();;
        }

        LoaderManager manager = getSupportLoaderManager();
        // 既にローダーがある場合は破棄
        if (manager.getLoader(0) != null) {
            manager.destroyLoader(0);
        }
    }

    @Override
    public void onLoaderReset(Loader<List<Person>> loader) {
        // nop
    }

    private void updateAddress(View view) {
        Log.d(TAG, "updateAddress is called");

        int positon;
        String name = null;

        if (!(view.getTag() instanceof Integer)) {
            return;
        }
        positon = (Integer)view.getTag();
        if (positon < 0 | mPersonsAdapter.getCount() <= positon) {
            return;
        }

        Log.d(TAG, "position in updateAddress = " + positon);
        Person person = mPersonsAdapter.getItem(positon);
        Log.d(TAG, "person in updateAddress = " + person);
        try {
            name = mOpenHelper.findPerson(person.getName()).getName();
            Log.d(TAG, "name in updateAddress = " + name);
        } catch (SQLException e) {
            Log.e(TAG, e.getMessage());
        }
        if (name == null) return; // todo: 多分、ありえない
        Intent intent = new Intent(MainActivity.this, RegisterActivity.class);
        intent.putExtra(TYPE, UPDATE_DATA_TYPE);
        intent.putExtra("name", name);
        startActivity(intent);
    }

    private boolean deletePerson(View view) {
        int positon;

        if (!(view.getTag() instanceof Integer)) {
            return true;
        }
        positon = (Integer)view.getTag();
        if (positon < 0 | mPersonsAdapter.getCount() <= positon) {
            return true;
        }

        Person person = mPersonsAdapter.getItem(positon);
        try {
            boolean isDeleted = mOpenHelper.deletePerson(person.getName());
            if (isDeleted) {
                Toast.makeText(MainActivity.this, person.getName() + " was deleted.", Toast.LENGTH_LONG).show();

                List<Person> list = mOpenHelper.findPerson();
                mPersonsAdapter = new AdapterEx(this, list);
                mListView.setAdapter(mPersonsAdapter);
                mPersonsAdapter.notifyDataSetChanged();
            } else {
                Toast.makeText(MainActivity.this, person.getName() + " cannot be deleted.", Toast.LENGTH_LONG).show();
            }
            return true;
        } catch (SQLException e) {
            Toast.makeText(MainActivity.this, person.getName() + " cannot be deleted.", Toast.LENGTH_LONG).show();
            Log.e(TAG, e.getMessage());
            return true;
        }
    }

}

person追加かアドレス更新の時に遷移する先のアクティビティ
RegisterActivity.java

package com.sarltokyo.addressbookormlite7.app;

import android.content.res.Resources;
import android.os.Bundle;
import android.support.v7.app.AppCompatActivity;
import android.text.TextUtils;
import android.util.Log;
import android.view.View;
import android.widget.Button;
import android.widget.EditText;
import android.widget.Toast;
import com.sarltokyo.addressbookormlite7.core.ContextLogic;
import com.sarltokyo.addressbookormlite7.core.ContextLogicFactory;
import com.sarltokyo.addressbookormlite7.db.OpenHelper;
import com.sarltokyo.addressbookormlite7.entity.Address;
import com.sarltokyo.addressbookormlite7.entity.Person;

import java.sql.SQLException;

/**
 * Created by osabe on 15/07/17.
 */
public class RegisterActivity extends AppCompatActivity
        implements View.OnClickListener {
    private final static String TAG = RegisterActivity.class.getSimpleName();

    private ContextLogic mContextLogic;
    private OpenHelper mOpenHelper;
    private Button mBtn;

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

        // todo: ここ重要。DBをproduction用、test用に切り替えられるようにする。
        mContextLogic = ContextLogicFactory.createContextLogic(this);
        mOpenHelper = mContextLogic.createOpenHelper();

        mBtn = (Button)findViewById(R.id.btn);
        mBtn.setOnClickListener(this);

        String type = getIntent().getStringExtra(MainActivity.TYPE);
        String name = getIntent().getStringExtra("name");

        Resources resources = getResources();
        if (type.equals(MainActivity.CREATE_DATA_TYPE)) {
            mBtn.setText(resources.getString(R.string.register));
        } else if (type.equals(MainActivity.UPDATE_DATA_TYPE)) {
            mBtn.setText(resources.getString(R.string.update));
            showAddress(name);
        }
    }

    @Override
    public void onClick(View view) {
        int id = view.getId();
        if (id == R.id.btn) {
            String type = getIntent().getStringExtra(MainActivity.TYPE);
            if (type.equals(MainActivity.CREATE_DATA_TYPE)) {
                registerAddressBook();
            } else if (type.equals(MainActivity.UPDATE_DATA_TYPE)) {
                String name = getIntent().getStringExtra("name");
                updateAddressBook(name);
            }
        }
    }

    public void registerAddressBook() {
        String name = ((EditText)findViewById(R.id.nameEt)).getText().toString();
        String zipcode = ((EditText)findViewById(R.id.zipcodeEt)).getText().toString();
        String prefecture = ((EditText)findViewById(R.id.prefectureEt)).getText().toString();
        String city = ((EditText)findViewById(R.id.cityEt)).getText().toString();
        String other = ((EditText)findViewById(R.id.otherEt)).getText().toString();

        if (!checkData(name, zipcode, prefecture, city, other)) {
            return;
        }

        Address address = new Address();
        address.setZipcode(zipcode);
        address.setPrefecture(prefecture);
        address.setCity(city);
        address.setOther(other);
        Person person = new Person();
        person.setName(name);
        person.setAddress(address);
        try {
            mOpenHelper.registerPerson(person);
            finish();
        } catch (SQLException e) {
            Toast.makeText(this, "cannot register address.", Toast.LENGTH_LONG).show();
            Log.e(TAG, e.getMessage());
        }
    }

    public void updateAddressBook(String name) {
        String newName = ((EditText)findViewById(R.id.nameEt)).getText().toString();
        String zipcode = ((EditText)findViewById(R.id.zipcodeEt)).getText().toString();
        String prefecture = ((EditText)findViewById(R.id.prefectureEt)).getText().toString();
        String city = ((EditText)findViewById(R.id.cityEt)).getText().toString();
        String other = ((EditText)findViewById(R.id.otherEt)).getText().toString();

        if (!checkData(name, zipcode, prefecture, city, other)) {
            return;
        }

        try {
            Person person = mOpenHelper.findPerson(name);
            Address address = person.getAddress();
            address.setZipcode(zipcode);
            address.setPrefecture(prefecture);
            address.setCity(city);
            address.setOther(other);
            person.setName(newName);
            person.setAddress(address);
            mOpenHelper.updatePerson(person);
            finish();
        } catch (SQLException e) {
            Toast.makeText(this, "cannot update address.", Toast.LENGTH_LONG).show();
            Log.e(TAG, e.getMessage());
        }
    }

    public void showAddress(String name) {
        String type = getIntent().getStringExtra(MainActivity.TYPE);
        if (!type.equals(MainActivity.UPDATE_DATA_TYPE)) return;

        Person person;
        Address address;

        try {
            person = mOpenHelper.findPerson(name);

        } catch (SQLException e) {
            // ありえない
            Log.e(TAG, e.getMessage());
            return;
        }

        address = person.getAddress();

        String zipcode = address.getZipcode();
        String prefecture = address.getPrefecture();
        String city = address.getCity();
        String other = address.getOther();

        ((EditText)findViewById(R.id.nameEt)).setText(name);
        ((EditText)findViewById(R.id.zipcodeEt)).setText(zipcode);
        ((EditText)findViewById(R.id.prefectureEt)).setText(prefecture);
        ((EditText)findViewById(R.id.cityEt)).setText(city);
        ((EditText)findViewById(R.id.otherEt)).setText(other);
    }

    public boolean checkData(String name, String zipcode, String prefecture,
                             String city, String other) {
        if (TextUtils.isEmpty(name)) {
            Toast.makeText(this, "name is empty.", Toast.LENGTH_LONG).show();
            return false;
        }
        if (TextUtils.isEmpty(zipcode)) {
            Toast.makeText(this, "zipcode is empty.", Toast.LENGTH_LONG).show();
            return false;
        }
        if (TextUtils.isEmpty(prefecture)) {
            Toast.makeText(this, "prefecture is empty.", Toast.LENGTH_LONG).show();
            return false;
        }
        if (TextUtils.isEmpty(city)) {
            Toast.makeText(this, "city is empty.", Toast.LENGTH_LONG).show();
            return false;
        }
        if (TextUtils.isEmpty(other)) {
            Toast.makeText(this, "other is empty.", Toast.LENGTH_LONG).show();
            return false;
        }
        return true;
    }
}

ここで、coreサプパッケージのContexLogic、ContexLogicFactoryクラスを使って、アクティビティクラスのonCreateメソッド内で、

 // todo: ここ重要。DBをproduction用、test用に切り替えられるようにする。
        mContextLogic = ContextLogicFactory.createContextLogic(this);
        mOpenHelper = mContextLogic.createOpenHelper();

のようにコーディングすると、アプリ使用時には実際のデータベースファイルaddressbook.db、テスト時には、test_addressbook.db ファイルを使用できるようになります。ContexLogic、ContexLogicFactoryクラスクラスについては、cattaka/FastCheckListのサンプルにファイルがあります。

2.6 adapterサブパッケージ

アダプタのコードは、以前はMainActivity.javaに書いていましたが、テストしやすいように、独立なクラスにします。

AdapterEx.java

package com.sarltokyo.addressbookormlite7.adapter;

import android.content.Context;
import android.view.LayoutInflater;
import android.view.View;
import android.view.ViewGroup;
import android.widget.ArrayAdapter;
import android.widget.TextView;
import com.sarltokyo.addressbookormlite7.app.R;
import com.sarltokyo.addressbookormlite7.entity.Person;

import java.util.List;

public class AdapterEx extends ArrayAdapter<Person> {
    public AdapterEx(Context context, List<Person> persons) {
        super(context, R.layout.layout_main_item, persons);
    }

    @Override
    public View getView(int position, View convertView, ViewGroup parent) {
        Person person = getItem(position);
        if (convertView == null) {
            LayoutInflater inflater = LayoutInflater.from(getContext());
            convertView = inflater.inflate(R.layout.layout_main_item, null);
        }
        convertView.setTag(position);
        convertView.findViewById(R.id.nameTv).setTag(position);
        TextView nameTv = (TextView)convertView.findViewById(R.id.nameTv);
        nameTv.setText(person.getName());
        return convertView;
    }
}

3. Espressoを使ったテストの準備

Espress 2.2を使うと、テストランナー android.support.test.runner.AndroidJUnitRunner で、データベース、アダプター、プリファレンス、UIのテストをすべて行えます。

app/build.gradleを以下のように編集します。applicationIdやtestApplicationIdは適宜修正します。なお、不要なライブラリを参照しているかもしれません。

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

repositories {
    jcenter()
}

android {
    compileSdkVersion 22
    buildToolsVersion "22.0.1"

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

        testApplicationId "com.sarltokyo.addressbookormlite7.app.test"
        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'
        }
        debug {
            testCoverageEnabled true
        }
    }
    packagingOptions {
        exclude 'LICENSE.txt'
    }
}

dependencies {
    compile fileTree(dir: 'libs', include: ['*.jar'])
    compile 'com.android.support:appcompat-v7:22.2.1'
    compile 'com.j256.ormlite:ormlite-android:4.48'
    androidTestCompile ('com.android.support.test:runner:0.3') {
        exclude module: 'support-annotations'
    }
    androidTestCompile ('com.android.support.test:rules:0.3') {
        exclude module: 'support-annotations'
    }
    androidTestCompile ('com.android.support.test.espresso:espresso-core:2.2') {
        exclude module: 'support-annotations'
    }
    androidTestCompile ('com.android.support.test.espresso:espresso-idling-resource:2.2') {
        exclude module: 'support-annotations'
    }
    androidTestCompile 'org.mockito:mockito-core:1.10.19'
    androidTestCompile 'com.google.dexmaker:dexmaker-mockito:1.2'
}

4. Espressoを使ったテスト

4.1 テスト用ツール類 app.testサブパッケージ

住友孝郎@cattaka_net氏のBaseTestCase、TestContextLogic、TestUtil、UnlockKeyguardActivityクラスを使用します。https://github.com/cattaka/FastCheckList/tree/master/app/src/androidTest/java/net/cattaka/android/fastchecklist/test からファイルは入手可能です。本記事では、データベースのクリア、ダミーデータの作成のために、BaseTestCaseを改変しました。

BaseTestCase.java

package com.sarltokyo.addressbookormlite7.app.test;

import android.app.Activity;
import android.app.KeyguardManager;
import android.content.Context;
import android.content.Intent;
import android.os.PowerManager;
import android.os.SystemClock;
import android.test.ActivityInstrumentationTestCase2;
import android.util.Log;
import com.sarltokyo.addressbookormlite7.core.ContextLogic;
import com.sarltokyo.addressbookormlite7.core.ContextLogicFactory;
import com.sarltokyo.addressbookormlite7.db.OpenHelper;
import com.sarltokyo.addressbookormlite7.entity.Address;
import com.sarltokyo.addressbookormlite7.entity.Person;

import java.sql.SQLException;
import java.util.ArrayList;
import java.util.List;

/**
 * Created by osabe on 15/07/14.
 *
 * This file is a modified version of BaseTestCase.java
 * https://github.com/cattaka/FastCheckList/blob/master/app/src/androidTest/java/net/cattaka/android/fastchecklist/test/BaseTestCase.java
 */
public class BaseTestCase<T extends Activity> extends ActivityInstrumentationTestCase2<T> {
    protected ContextLogic mContextLogic;
    private final static String TAG = BaseTestCase.class.getSimpleName();

    public BaseTestCase(Class<T> tClass) {
        super(tClass);
    }

    protected void setUp() throws Exception {
        super.setUp();
        Context context = getInstrumentation().getTargetContext();
        mContextLogic = new TestContextLogic(context);
        {   // Replace ContextLogicFactory to use RenamingDelegatingContext.
            ContextLogicFactory.replaceInstance(new ContextLogicFactory() {
                @Override
                public ContextLogic newInstance(Context context) {
                    return mContextLogic;
                }
            });
        }
        {   // Unlock keyguard and screen on
            KeyguardManager km = (KeyguardManager) getInstrumentation()
                    .getTargetContext().getSystemService(Context.KEYGUARD_SERVICE);
            PowerManager pm = (PowerManager) getInstrumentation()
                    .getTargetContext().getSystemService(Context.POWER_SERVICE);
            if (km.inKeyguardRestrictedInputMode() || !pm.isScreenOn()) {
                Intent intent = new Intent(getInstrumentation().getContext(), UnlockKeyguardActivity.class);
                intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK);
                getInstrumentation().getTargetContext().startActivity(intent);
                while (km.inKeyguardRestrictedInputMode()) {
                    SystemClock.sleep(100);
                }
            }
        }

        cleanData();
    }

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

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

        for (Person person : persons) {
            try {
                openHelper.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.
        OpenHelper openHelper = mContextLogic.createOpenHelper();
        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("Higash-shinjyuku 1-2-" + i);
            person.setName("Hoge" + i);
            person.setAddress(address);
            try {
                openHelper.registerPerson(person);
                persons.add(person);
            } catch (SQLException e) {
                Log.e(TAG, e.getMessage());
            }
        }
        return persons;
    }
}
&#91;/java&#93;

4.2 アダプターのテスト

アダプターのテストは、AdapterExのコンストラクタがAdapterEx(Context context, List<Person> persons)のようにList<Person>型の引数を持ちますので、そこにダミーのデータを入れて、Viewが期待した通りになるかテストします。


adapterサブパッケージ
AdapterExTest.java

package com.sarltokyo.addressbookormlite7.adapter;

import android.content.Context;
import android.test.InstrumentationTestCase;
import android.view.View;
import android.widget.LinearLayout;
import android.widget.TextView;
import com.sarltokyo.addressbookormlite7.app.R;
import com.sarltokyo.addressbookormlite7.entity.Address;
import com.sarltokyo.addressbookormlite7.entity.Person;
import org.hamcrest.Matchers;

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

import static org.hamcrest.Matchers.is;
import static org.junit.Assert.assertThat;

public class AdapterExTest extends InstrumentationTestCase {
    public void testGetView() {
        List<Person> dummys = new ArrayList<Person>();
        Person person1 = new Person();
        Address address1 = new Address();
        address1.setZipcode("123-4567");
        address1.setPrefecture("Tokyo");
        address1.setCity("Shinjuku");
        address1.setOther("hoge 1-2-3");
        person1.setName("Foo");
        person1.setAddress(address1);

        Person person2 = new Person();
        Address address2 = new Address();
        address2.setZipcode("111-2222");
        address2.setPrefecture("Kyoto");
        address2.setCity("Kyoto");
        address2.setOther("boo 4-5-6");
        person2.setName("Bar");
        person2.setAddress(address2);

        dummys.add(person1);
        dummys.add(person2);

        Context context = getInstrumentation().getTargetContext();
        AdapterEx sut = new AdapterEx(context, dummys);

        View view1 = sut.getView(0, null, null);
        assertThat(view1, is(Matchers.instanceOf(LinearLayout.class)));
        assertThat(view1.findViewById(R.id.nameTv), is(Matchers.instanceOf(TextView.class)));
        assertThat(((TextView)view1.findViewById(R.id.nameTv)).getText().toString(), is("Foo"));

        View view2 = sut.getView(1, null, null);
        assertThat(view2, is(Matchers.instanceOf(LinearLayout.class)));
        assertThat(view2.findViewById(R.id.nameTv), is(Matchers.instanceOf(TextView.class)));
        assertThat(((TextView)view2.findViewById(R.id.nameTv)).getText().toString(), is("Bar"));
    }
}

4.3 AsyncTaskLoaderのテスト

PersonLoaderのテストは、メソッド抽出したgetPersonsメソッドが、ダミーなデータ入力に対して、期待した値を返すかでテストします。

asynctaskサブパッケージ

PersonLoaderTest.java

package com.sarltokyo.addressbookormlite7.asynctask;

import android.content.Context;
import android.test.InstrumentationTestCase;
import android.test.RenamingDelegatingContext;
import com.sarltokyo.addressbookormlite7.db.OpenHelper;
import com.sarltokyo.addressbookormlite7.entity.Address;
import com.sarltokyo.addressbookormlite7.entity.Person;

import java.util.List;

import static org.hamcrest.Matchers.is;
import static org.junit.Assert.assertThat;

public class PersonLoaderTest extends InstrumentationTestCase {

    private OpenHelper mOpenHelper;
    private Context mContext;
    private PersonLoader mPersonLoader;

    @Override
    protected void setUp() throws Exception {
        super.setUp();
        mContext = new RenamingDelegatingContext(getInstrumentation().getTargetContext(), "test_" );
        mOpenHelper = new OpenHelper(mContext);
        mPersonLoader = new PersonLoader(mContext);

    }

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

    private void createRegularData() throws Exception {
        // insert
        for (int i = 0; i < 10; i++) {
            Person person = new Person();
            Address address = new Address();
            address.setZipcode(String.valueOf(i));
            address.setPrefecture("Tokyo");
            address.setCity("Shinjyuku-ku");
            address.setOther("Higashi-shinjyuku " + (i+1));
            person.setName("Abe" + i);
            person.setAddress(address);
            mOpenHelper.registerPerson(person);
        }
    }

    private void clearData() throws Exception {
        List<Person> persons = mOpenHelper.findPerson();
        for (Person person: persons) {
            mOpenHelper.deletePerson(person.getName());
        }
    }


    public void testSucces() throws Exception {
        createRegularData();

        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(String.valueOf(i)));
            assertThat(actualPrefecture, is(("Tokyo")));
            assertThat(acutalCity, is(("Shinjyuku-ku")));
            assertThat(actualOther, is("Higashi-shinjyuku " + (i+1)));
            assertThat(actuallName, is("Abe" + i));
        }
    }

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

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


    }
}

データベースをダミーのものに置き換えてテストを行うには、

    @Override
    protected void setUp() throws Exception {
        super.setUp();
        Context context = new RenamingDelegatingContext(getInstrumentation().getTargetContext(), "test_" );
        mOpenHelper = new OpenHelper(context);
    }

のように、contextを、 new RenamingDelegatingContext(getInstrumentation().getTargetContext(), “test_” );で置き換えることが重要です。これを行うと、データベースファイルは、実際のデータベースファイル名の頭に”test_”がついて、ダミーのデータベースファイルでテストを行うことができます。

4.4 coreサブパッケージのクラスのテスト

FastCheckList/app/src/androidTest/java/net/cattaka/android/fastchecklist/core/で、パッケージを修正して、テストします。

4.5 データベースのテスト

データベースのテストはダミーのデータファイルを使用してテストを行います。

dbサブパッケージ
OpenHelperTest.java

package com.sarltokyo.addressbookormlite7.db;

import android.content.Context;
import android.test.InstrumentationTestCase;
import android.test.RenamingDelegatingContext;
import com.sarltokyo.addressbookormlite7.entity.Address;
import com.sarltokyo.addressbookormlite7.entity.Person;

import java.sql.SQLException;
import java.util.List;

import static org.hamcrest.Matchers.is;
import static org.junit.Assert.assertThat;

public class OpenHelperTest extends InstrumentationTestCase {
    private OpenHelper mOpenHelper;

    @Override
    protected void setUp() throws Exception {
        super.setUp();
        Context context = new RenamingDelegatingContext(getInstrumentation().getTargetContext(), "test_" );
        mOpenHelper = new OpenHelper(context);
    }

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

    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
        for (int i = 0; i < 10; i++) {
            Person person = new Person();
            Address address = new Address();
            address.setZipcode(String.valueOf(i));
            address.setPrefecture("Tokyo");
            address.setCity("Shinjyuku-ku");
            address.setOther("Higashi-shinjyuku " + (i+1));
            person.setName("Abe" + i);
            person.setAddress(address);
            mOpenHelper.registerPerson(person);
        }
        // select
        List<Person> sut = mOpenHelper.findPerson();
        assertEquals(10, 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, mOpenHelper.findPerson().size());
    }

    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, mOpenHelper.findPerson().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());
    }
}

データベースのテストをダミーデータで行うためには、

    @Override
    protected void setUp() throws Exception {
        super.setUp();
        Context context = new RenamingDelegatingContext(getInstrumentation().getTargetContext(), "test_" );
        mOpenHelper = new OpenHelper(context);
    }

のように、contextを、 new RenamingDelegatingContext(getInstrumentation().getTargetContext(), “test_” );で置き換えることが重要です。これを行うと、データベースファイルは、実際のデータベースファイル名の頭に”test_”がついて、ダミーのデータベースファイルでテストを行うことができます。

4.6 UIのテスト

アクティビティ、つまりUIのテストは、ダミーなデータベースファイルを使用してテストを行います。

MainActivity3Test.java

package com.sarltokyo.addressbookormlite7.app;

import android.os.IBinder;
import android.support.test.espresso.*;
import android.support.test.espresso.assertion.ViewAssertions;
import android.support.test.espresso.matcher.ViewMatchers;
import android.view.WindowManager;
import android.widget.ListView;
import com.sarltokyo.addressbookormlite7.app.test.BaseTestCase;
import org.hamcrest.Description;
import org.hamcrest.Matcher;
import org.hamcrest.TypeSafeMatcher;


import static android.support.test.espresso.Espresso.onData;
import static android.support.test.espresso.Espresso.onView;
import static android.support.test.espresso.action.ViewActions.*;
import static android.support.test.espresso.assertion.ViewAssertions.matches;
import static android.support.test.espresso.matcher.ViewMatchers.*;
import static org.hamcrest.Matchers.anything;

public class MainActivity3Test extends BaseTestCase<MainActivity> {
    private final static String TAG = MainActivity3Test.class.getSimpleName();


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



    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()));


        // todo: これでOK
        onData(anything()).inAdapterView(withId(android.R.id.list)).atPosition(0)
                .perform(click());
        // todo: これでOK
        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("Higash-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());
    }


//    private void setSelection(final ListView listView, final int position) throws Throwable {
//        runTestOnUiThread(new Runnable() {
//            @Override
//            public void run() {
//                listView.setSelection(position);
//            }
//        });
//    }
////    private void select(final int position) throws Throwable {
////        runTestOnUiThread(new Runnable() {
////            @Override
////            public void run() {
////                onData(allOf(hasToString(startsWith("Hoge")))).inAdapterView(withId(android.R.id.list)).atPosition(position)
////                        .perform(click());
////            }
////        });
////    }

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

このテストでは、テストクラスが、BaseTestCaseを継承しており、BaseTestTestCaseクラスのsetUpメソッド内で、

    protected void setUp() throws Exception {
        super.setUp();
        Context context = getInstrumentation().getTargetContext();
        mContextLogic = new TestContextLogic(context);
        {   // Replace ContextLogicFactory to use RenamingDelegatingContext.
            ContextLogicFactory.replaceInstance(new ContextLogicFactory() {
                @Override
                public ContextLogic newInstance(Context context) {
                    return mContextLogic;
                }
            });
        }
...
    }

と書かれています。TestContextLogicクラスは、

public class TestContextLogic extends ContextLogic {
    private RenamingDelegatingContext mRdContext;
    public TestContextLogic(Context context) {
        super(context);
        mRdContext = new RenamingDelegatingContext(context, "test_");
    }

    @Override
    public OpenHelper createOpenHelper() {
        return new OpenHelper(mRdContext);
    }
}

になっており、ContextLogicFactoryクラスは、

public class ContextLogicFactory {
    static ContextLogicFactory INSTANCE = new ContextLogicFactory();

    public ContextLogic newInstance(Context context) {
        return new ContextLogic(context);
    }

    public static ContextLogic createContextLogic(Context context) {
        return INSTANCE.newInstance(context);
    }

    public static void replaceInstance(ContextLogicFactory INSTANCE) {
        ContextLogicFactory.INSTANCE = INSTANCE;
    }
}

になっています。これらのコードで、テスト時に、実際のデータベースファイルのファイル名の頭に、”test_”をつけたファイル名のデータベースを使用するようになっています。

アクティビティのテストでは、ダミーデータを用意しておいた場合、

メイン画面で全件表示できるか
メイン画面のリストで行をタップすると、Address更新画面に遷移するか
遷移先でアドレスを更新できるか
+ボタンのタップで、名前、アドレス入力画面に遷移するか、名前、アドレスを入力できるか、personを追加できるか
アドレス入力画面で、入力欄が空欄の場合、Toast表示を確認できるか

などをテストしています。

5. ソースファイル公開

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

andropenguin/AddressBookORMLite7

6. 参考サイト

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

今更ながら、ORMLiteを使ってアドレス帳アプリを作ってみた

Androidで使えるORMライブラリについて調べて、ORMLiteを使ってアドレス帳アプリをサンプルとして作ってみました。ORMLiteを使うのは今更感はありますが。

ORMライブラリについては、@pside氏の 天下一「AndroidのORM」武道会 の記事が詳しいです。主要なライブラリは、次の通りで、私が調べたFull text search 3(FTS3)のサポート状況を付け加えます。

  • ORMLite: 直接なサポートはないが、raw queyインターフェエースを使うといいという話。 FTS3 searches in ORMLite?
  • ActiveAndroid: full text searchはサポートされているようだ。 Add FTS support #272
  • greenDAO: サポートされていない。
  • Ollie: 調査せず
  • SugarORM: サポートの有無不明。
  • DBFlow: サポートの有無不明。
  • Realm: サポートせず。中の人によると、Coreでタスクリストにあるとのこと。
  • couchbase: サポートせず。

上で引用した @pside 氏の記事によると、NoSQLのRealmは極端に処理が速いとのことですが、FTS3をサポートしていません。SQLiteを使っているGreenDaoは、SQLiteを使っているORMの中で一番処理が速いとのことですが、これもFTS3をサポートしていません。私が調べた限りでは、FTS3が使えるのは、ORMLiteとActiveRecordですが、@pside氏の記事では、ORMLiteの方が若干性能がいいようなので、今回は、ORMLiteを使いました。

今回は、ORMLiteを使ってアドレス帳のアプリをサンプルとして作りました。上で、FTS3について言及していますが、今回は、FTS3の機能は使わず、データベースのテーブルに関連がある場合のアプリの実装の練習をしました。題材は、アドレス帳で、テーブルは、2つあります。1つめのテーブルは、personというテーブルで、カラムにid、name、addressを持ちます。もう1つのテーブルは、addressというテーブルで、カラムに、id、zipcode、prefecture、city、otherを持ちます。2つのテーブルは、1対1の関係にあります。

まず、Intellijで、Gradle: Android Moduleで新規プロジェクトを作り、app/build.gradleのdependenciesに

compile 'com.j256.ormlite:ormlite-android:4.48'

を追加します。

エンティティクラスは、

Person.java

@DatabaseTable(tableName = "person")
public class Person {

    @DatabaseField(generatedId = true)
    private Integer id;
    @DatabaseField
    private String name;
    @DatabaseField(foreign = true, foreignAutoCreate = true, foreignAutoRefresh = true)
    private Address address;

    public Person() {
    }

    public Integer getId() {
        return id;
    }

    public String getName() {
        return name;
    }

    public void setName(String name) {
        this.name = name;
    }

    public Address getAddress() {
        return address;
    }

    public void setAddress(Address address) {
        this.address = address;
    }

Address.java

@DatabaseTable(tableName = "address")
public class Address {

    @DatabaseField(generatedId = true)
    private Integer id;
    @DatabaseField
    private String zipcode;
    @DatabaseField
    private String prefecture;
    @DatabaseField
    private String city;
    @DatabaseField
    private String other;


    public Address() {
    }

    public Integer getId() {
        return id;
    }

    public String getZipcode() {
        return zipcode;
    }

    public void setZipcode(String zipcode) {
        this.zipcode = zipcode;
    }

    public String getPrefecture() {
        return prefecture;
    }

    public void setPrefecture(String prefecture) {
        this.prefecture = prefecture;
    }

    public String getCity() {
        return city;
    }

    public void setCity(String city) {
        this.city = city;
    }

    public String getOther() {
        return other;
    }

    public void setOther(String other) {
        this.other = other;
    }
}

です。フィールドには、@DatabaseFieldアノーテーションを付け、自動生成する主キーには、generatedId = trueを付けます。また、Personは、Addressを持っているので、Personクラスで、Address型のフィールドが、関連する永続化フィールドであることをしめすために、foregin = trueを付けます。あと、foreignAutoCreate = trueは、関連に設定されたエンティティを自動で生成するために、foreignAutoRefresh = trueは、クエリが発行された際に自動で更新するために付けます。

次に、com.j256.ormlite.android.apptools.OrmLiteSqliteOpenHelper を継承したクラスを作ります。

DatabaseHelper.java

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

    private final static String DATABASE_NAME = "addressbook.db";
    private final static int DATABASE_VERSION = 1;

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

    public DatabaseHelper(Context context) {
        super(context, DATABASE_NAME, null, DATABASE_VERSION);
    }

    @Override
    public void onCreate(SQLiteDatabase database, ConnectionSource connectionSource) {
        try {
            // エンティティを指定してcreate tableする
            TableUtils.createTable(connectionSource, Person.class);
            TableUtils.createTable(connectionSource, Address.class);
        } catch (SQLException e) {
            Log.e(TAG, "データベースを作成できませんでした", e);
        }
    }

    @Override
    public void onUpgrade(SQLiteDatabase database, ConnectionSource connectionSource, int oldVersion, int newVersion) {
        // 省略
    }

    public Dao<Person, Integer> getPersonDao() throws SQLException {
        if (mPersonDao == null) {
            mPersonDao = getDao(Person.class);
        }
        return mPersonDao;
    }

    public Dao<Address, Integer> getAddressDao() throws SQLException {
        if (mAddressDao == null) {
            mAddressDao = getDao(Address.class);
        }
        return mAddressDao;
    }
}

onCreateメソッドとonUpgradeメソッドをオーバーライドするのは、SQLiteの場合と同じですが、onCreateメソッドでは、TableUtilsクラスを使ってテーブルを生成します。また、ORMLiteでは、Daoクラスの生成はコストがかかるということで、それぞれ、PersonのDaoとAddressのDaoオブジェクトがないときに限り、getPersonDaoとgetAddressDaoメソッドは、getDaoメソッドでDaoオブジェクトを作り値を返し、Daoオブジェクトがある場合は、それを返します。

そして、各アクティビティで、Daoメソッドを使うため、Applicationクラスを継承したクラスで、DatabaseHelperオブジェクトを返すメソッドを定義します。

AddressApplication.java

public class AddressApplication extends Application {
    private static AddressApplication instance;
    private DatabaseHelper mDatabaseHelper;

    @Override
    public void onCreate() {
        super.onCreate();
        instance = this;
    }

    public static AddressApplication getInstance() {
        return instance;
    }

    public DatabaseHelper getDatabaseHelper() {
        if (mDatabaseHelper == null) {
            mDatabaseHelper = new DatabaseHelper(this);
        }
        return mDatabaseHelper;
    }
}

最後に、アプリのメイン画面は、名前リストで、アクティビティは、

MainActivity.java

public class MainActivity extends AppCompatActivity {
    private final static String TAG = MainActivity.class.getSimpleName();

    private List<String > mList = new ArrayList<String>();
    private ListView mListView;
    private DatabaseHelper mHelper;

    public final static String TYPE = "type";
    public final static String CREATE_DATA_TYPE = "create";
    public final static String UPDATE_DATA_TYPE = "update";


    @Override
    protected void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
        setContentView(R.layout.activity_main);
        mHelper = AddressApplication.getInstance().getDatabaseHelper();

        mListView = (ListView)findViewById(android.R.id.list);

        mList = getAllList();

        ArrayAdapter<String> arrayAdapter = new ArrayAdapter<String>(this, R.layout.rowdata, mList);
        mListView.setAdapter(arrayAdapter);

        mListView.setOnItemClickListener(new AdapterView.OnItemClickListener() {
            @Override
            public void onItemClick(AdapterView<?> parent, View view, int position, long id) {
                ListView listView = (ListView)parent;
                String name = (String)listView.getItemAtPosition(position);
                Intent intent = new Intent(MainActivity.this, RegisterActivity.class);
                intent.putExtra(TYPE, UPDATE_DATA_TYPE);
                intent.putExtra("name", name);
                startActivity(intent);
            }
        });
    }

    @Override
    public boolean onCreateOptionsMenu(Menu menu) {
        // Inflate the menu; this adds items to the action bar if it is present.
        getMenuInflater().inflate(R.menu.menu_main, menu);

        MenuItem item = menu.add("NEW");
        item.setIcon(android.R.drawable.ic_input_add);
        item.setOnMenuItemClickListener(new MenuItem.OnMenuItemClickListener() {
            @Override
            public boolean onMenuItemClick(MenuItem menuItem) {
                Intent intent = new Intent(MainActivity.this, RegisterActivity.class);
                intent.putExtra(TYPE, CREATE_DATA_TYPE);
                startActivity(intent);

                return false;
            }
        });
        MenuItemCompat.setShowAsAction(item,
                MenuItemCompat.SHOW_AS_ACTION_IF_ROOM);

        return true;
    }

    @Override
    public boolean onOptionsItemSelected(MenuItem item) {
        // Handle action bar item clicks here. The action bar will
        // automatically handle clicks on the Home/Up button, so long
        // as you specify a parent activity in AndroidManifest.xml.
        int id = item.getItemId();

        //noinspection SimplifiableIfStatement
        if (id == R.id.action_settings) {
            return true;
        }

        return super.onOptionsItemSelected(item);
    }

    public List<String> getAllList() {
        List<String> list  = new ArrayList<String>();
        try {
            List<Person> persons =  mHelper.getPersonDao().queryForAll();
            for (Person person: persons) {
                list.add(person.getName());
            }
            return list;
        } catch (SQLException e) {
            Log.e(TAG, "例外が発生しました", e);
            return null;
        }
    }

    @Override
    protected void onRestart() {
        mListView = (ListView)findViewById(android.R.id.list);

        mList = getAllList();

        ArrayAdapter<String> arrayAdapter = new ArrayAdapter<String>(this, R.layout.rowdata, mList);
        mListView.setAdapter(arrayAdapter);

        super.onRestart();
    }
}

リストは、名前のリストなので、

public List<String> getAllList() {
        List<String> list  = new ArrayList<String>();
        try {
            List<Person> persons =  mHelper.getPersonDao().queryForAll();
            for (Person person: persons) {
                list.add(person.getName());
            }
            return list;
        } catch (SQLException e) {
            Log.e(TAG, "例外が発生しました", e);
            return null;
        }
    }

のように、mHelperからPersonのDaoを得て、全件取得 queryForAllを実行しています。

メイン画面のActionBarのメニューのプラスアイコンをタップすると、名前、アドレス入力画面に遷移し、リストに名前がある時、リストの行をタップすると、アドレス更新画面に遷移します。遷移先のアクティビティのクラスは、次のように定義されています。

RegisterActivity.java

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

    private DatabaseHelper mHelper;
    private Button mBtn;

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

        mHelper = AddressApplication.getInstance().getDatabaseHelper();

        mBtn = (Button)findViewById(R.id.btn);
        mBtn.setOnClickListener(this);

        String type = getIntent().getStringExtra(MainActivity.TYPE);

        Resources resources = getResources();
        if (type.equals(MainActivity.CREATE_DATA_TYPE)) {
            mBtn.setText(resources.getString(R.string.register));
        } else if (type.equals(MainActivity.UPDATE_DATA_TYPE)) {
            mBtn.setText(resources.getString(R.string.update));
            showAddress();
        }
    }

    @Override
    public void onClick(View view) {
        int id = view.getId();
        if (id == R.id.btn) {
            String type = getIntent().getStringExtra(MainActivity.TYPE);
            if (type.equals(MainActivity.CREATE_DATA_TYPE)) {
                registerAddressBook();
            } else if (type.equals(MainActivity.UPDATE_DATA_TYPE)) {
                updateAddressBook();
            }
        }
    }

    public void registerAddressBook() {
        String name = ((EditText)findViewById(R.id.nameEt)).getText().toString();
        String zipcode = ((EditText)findViewById(R.id.zipcodeEt)).getText().toString();
        String prefecture = ((EditText)findViewById(R.id.prefectureEt)).getText().toString();
        String city = ((EditText)findViewById(R.id.cityEt)).getText().toString();
        String other = ((EditText)findViewById(R.id.otherEt)).getText().toString();

        if (!checkDatum(name, zipcode, prefecture, city, other)) {
            return;
        }

        if (isExists(name)) {
            Toast.makeText(this, name + " has been registered already or some error occurs.", Toast.LENGTH_LONG).show();
            return;
        }

        Address address = new Address();
        address.setZipcode(zipcode);
        address.setPrefecture(prefecture);
        address.setCity(city);
        address.setOther(other);
        Person person = new Person();
        person.setName(name);
        person.setAddress(address);
        try {
            mHelper.getPersonDao().create(person);
            finish();
        } catch (SQLException e) {
            Toast.makeText(this, "cannot register address.", Toast.LENGTH_LONG).show();
            Log.e(TAG, e.getMessage());
        }
    }

    public void updateAddressBook() {
        String name = ((EditText)findViewById(R.id.nameEt)).getText().toString();
        String zipcode = ((EditText)findViewById(R.id.zipcodeEt)).getText().toString();
        String prefecture = ((EditText)findViewById(R.id.prefectureEt)).getText().toString();
        String city = ((EditText)findViewById(R.id.cityEt)).getText().toString();
        String other = ((EditText)findViewById(R.id.otherEt)).getText().toString();

        if (!checkDatum(name, zipcode, prefecture, city, other)) {
            return;
        }

        try {
            Person person = mHelper.getPersonDao().queryForEq("name", name).get(0);
            Address address = person.getAddress();
            address.setZipcode(zipcode);
            address.setPrefecture(prefecture);
            address.setCity(city);
            address.setOther(other);
            mHelper.getAddressDao().update(address);
            mHelper.getPersonDao().update(person);
            finish();
        } catch (SQLException e) {
            Log.e(TAG, e.getMessage());
        }
    }

    public void showAddress() {
        String type = getIntent().getStringExtra(MainActivity.TYPE);
        if (!type.equals(MainActivity.UPDATE_DATA_TYPE)) return;

        String name = getIntent().getStringExtra("name");

        List<Person> list = null;
        Person person;
        Address address;

        try {
            list = mHelper.getPersonDao().queryForEq("name", name);
            person = list.get(0);
        } catch (SQLException e) {
            // ありえない
            Log.e(TAG, e.getMessage());
            return;
        }

        address = person.getAddress();

        String zipcode = address.getZipcode();
        String prefecture = address.getPrefecture();
        String city = address.getCity();
        String other = address.getOther();

        ((EditText)findViewById(R.id.nameEt)).setText(name);
        ((EditText)findViewById(R.id.nameEt)).setEnabled(false);
        ((EditText)findViewById(R.id.zipcodeEt)).setText(zipcode);
        ((EditText)findViewById(R.id.prefectureEt)).setText(prefecture);
        ((EditText)findViewById(R.id.cityEt)).setText(city);
        ((EditText)findViewById(R.id.otherEt)).setText(other);
    }

    public boolean isExists(String name) {
        List<Person> list = null;
        try {
            list = mHelper.getPersonDao().queryForEq("name", name);
            if (list.isEmpty()) {
                return false;
            } else {
                return true;
            }
        } catch (SQLException e) {
            Log.e(TAG, e.getMessage());
            return false;
        }

    }

    public boolean checkDatum(String name, String zipcode, String prefecture,
                           String city, String other) {
        if (TextUtils.isEmpty(name)) {
            Toast.makeText(this, "name is empty.", Toast.LENGTH_LONG).show();
            return false;
        }
        if (TextUtils.isEmpty(zipcode)) {
            Toast.makeText(this, "zipcode is empty.", Toast.LENGTH_LONG).show();
            return false;
        }
        if (TextUtils.isEmpty(prefecture)) {
            Toast.makeText(this, "prefecture is empty.", Toast.LENGTH_LONG).show();
            return false;
        }
        if (TextUtils.isEmpty(city)) {
            Toast.makeText(this, "city is empty.", Toast.LENGTH_LONG).show();
            return false;
        }
        if (TextUtils.isEmpty(other)) {
            Toast.makeText(this, "other is empty.", Toast.LENGTH_LONG).show();
            return false;
        }
        return true;
    }
}

新規にアドレスを入力するケースでは、

        Address address = new Address();
        address.setZipcode(zipcode);
        address.setPrefecture(prefecture);
        address.setCity(city);
        address.setOther(other);
        Person person = new Person();
        person.setName(name);
        person.setAddress(address);
        try {
            mHelper.getPersonDao().create(person);
            finish();
        } catch (SQLException e) {
            Toast.makeText(this, "cannot register address.", Toast.LENGTH_LONG).show();
            Log.e(TAG, e.getMessage());
        }

のように、まず、Addressオブジェクトを作り、住所を入れ、次に、Personオブジェクトを作り、名前と住所を入れます。そして、mHeperのPersonに関するDaoオブジェクトを得て、createメソッドの引数にpersonを渡して、データ登録をします。

データ更新のケースでは、簡単のため、名前の変更はないものとして、次のようにデータ更新を行います。

        try {
            Person person = mHelper.getPersonDao().queryForEq("name", name).get(0);
            Address address = person.getAddress();
            address.setZipcode(zipcode);
            address.setPrefecture(prefecture);
            address.setCity(city);
            address.setOther(other);
            person.setAddress(address);
            mHelper.getAddressDao().update(address);
            mHelper.getPersonDao().update(person);
            finish();
        } catch (SQLException e) {
            Log.e(TAG, e.getMessage());
        }

名前の変更はないし、同じ名前を入力しようとすると、警告を表示してデータ登録をできない実装にしているため、メイン画面で名前をタップすると、当然データがあるので、mHelper.getPersonDao().queryForEq(“name”, name)は1個しか要素を含まないListを返すので、get(0)でPersonオブジェクトを得ます。次に、Personオブジェクトから、getAddressメソッドで住所を得ます。そして、更新のため入力された住所情報でAddressオブジェクトを更新シ、さらに、更新された住所でPersonオブジェクトを更新します。mHelper.getAddressDao().update(address)とmHelper.getPersonDao().update(person)でデータベースのデータを更新します。

作ったサンプルを

andropenguin/AddressBookORMlite

に公開します。

参考サイト
天下一「AndroidのORM」武道会
Ormlite for Android Using Ormlite for clean and efficient database managment
OrmLite調査メモ

リストを引っ張って更新するライブラリshontauro/android-pulltorefresh-and-loadmoreを使ってみた

shontauro/android-pulltorefresh-and-loadmoreにあるライブラリは、リストを引っ張ってリストを更新するもので、星が371、Forkが200で、人気のあるライブラリのようです。このライブラリを、AsyncTaskLoaderとListFragmentを組み合わせたサンプルにNavigationViewを導入してみたで紹介したサンプルアプリ andropenguin/CustomListFragmentSample7で使ってみました。CustomListFragmentSample7 では、ライブラリを使用せず、リストを上に引っ張って内容更新を行っていましたが、今回は、shontauro/android-pulltorefresh-and-loadmore のライブラリを使います。また、shontauro/android-pulltorefresh-and-loadmore のサンプルでは、FragmentやAndroid Design Support LibraryのNavigationViewを使っていないので、今回、それらを使うようにしています。

IntelliJで、gradleでビルドするため、Gradle Android Moduleで新規プロジェクトを作り、プロジェクト直下に、shontauro/android-pulltorefresh-and-loadmore の pulltorefresh-and-loadmore ディレクトリをコピーし、settings.gradleに

include ':pulltorefresh-and-loadmore'

の行を追加します。また、app/build.gradleに

dependencies {
    compile fileTree(dir: 'libs', include: ['*.jar'])
    compile 'com.android.support:appcompat-v7:22.2.0'
    compile project(':pulltorefresh-and-loadmore')
    compile 'com.android.support:support-annotations:22.2.0'
    compile 'com.android.support:appcompat-v7:22.2.0'
    compile 'com.android.support:support-v4:22.2.0'
    compile 'com.android.support:design:22.2.0'
    debugCompile 'com.squareup.leakcanary:leakcanary-android:1.3.1'
    releaseCompile 'com.squareup.leakcanary:leakcanary-android-no-op:1.3.1'
}

を記述します。

主要なレイアウトファイルは、次の通りです。

layout/activity_main.xml

<android.support.v4.widget.DrawerLayout
        android:id="@+id/main_drawer_layout"
        xmlns:android="http://schemas.android.com/apk/res/android"
        xmlns:app="http://schemas.android.com/apk/res-auto"
        android:layout_width="match_parent"
        android:layout_height="match_parent"
        android:fitsSystemWindows="true">

    <RelativeLayout
            android:layout_width="match_parent"
            android:layout_height="match_parent">

        <android.support.v7.widget.Toolbar
                android:id="@+id/main_tool_bar"
                android:layout_width="match_parent"
                android:layout_height="wrap_content"
                android:background="?attr/colorPrimary"
                android:minHeight="?attr/actionBarSize"
                app:theme="@style/ThemeOverlay.AppCompat.Dark.ActionBar"/>

        <FrameLayout
                android:id="@+id/main_frame"
                android:layout_width="match_parent"
                android:layout_height="match_parent"
                android:layout_below="@id/main_tool_bar"/>

    </RelativeLayout>

    <android.support.design.widget.NavigationView
            android:id="@+id/main_drawer_view"
            android:layout_width="wrap_content"
            android:layout_height="match_parent"
            android:layout_gravity="start"
            app:headerLayout="@layout/drawer_header"
            app:menu="@menu/main_drawer"/>

</android.support.v4.widget.DrawerLayout>

layout/drawer_header.xml

<?xml version="1.0" encoding="utf-8"?>
<RelativeLayout xmlns:android="http://schemas.android.com/apk/res/android"
                android:layout_width="match_parent"
                android:layout_height="178dp"
                android:background="@color/secondary_text">

</RelativeLayout>

layout/list_item.xml

<?xml version="1.0" encoding="utf-8"?>

<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
              android:orientation="vertical"
              android:layout_width="match_parent"
              android:layout_height="match_parent">

    <TextView
            android:id="@+id/text"
            android:layout_width="wrap_content"
            android:layout_height="wrap_content"
            android:text=""/>

</LinearLayout>

layout/loadmore.xml

<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
              android:layout_width="match_parent"
              android:layout_height="match_parent"
              android:orientation="vertical" >

    <!-- We have to indicate that the listview is now a LoadMoreListView -->

    <com.costum.android.widget.LoadMoreListView
            android:id="@+id/android:list"
            android:layout_width="match_parent"
            android:layout_height="match_parent" />

</LinearLayout>

loadmore.xmlは、Fragmentのレイアウトファイルになっていて、com.costum.android.widget.LoadMoreListViewタグを内包します。

次に、リスト更新に関係するJavaファイルは次の通りです。

CustomListFragment.java

import android.os.Bundle;
import android.support.v4.app.ListFragment;
import android.support.v4.app.LoaderManager;
import android.support.v4.content.Loader;
import android.util.Log;
import android.view.LayoutInflater;
import android.view.View;
import android.view.ViewGroup;
import com.costum.android.widget.LoadMoreListView;
import java.util.List;

public class CustomListFragment extends ListFragment
        implements LoadMoreListView.OnLoadMoreListener,
        LoaderManager.LoaderCallbacks<List<Entry>> {
    private CustomAdapter mCustomAdapter;
    private boolean mIsLoading = false;
    private int mCount = 0;

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

    public CustomListFragment() {
    }

    public static CustomListFragment newInstance() {
        CustomListFragment fragment = new CustomListFragment();
        return fragment;
    }

    @Override
    public View onCreateView(LayoutInflater inflater, ViewGroup container, Bundle savedInstanceState) {
        if (container == null) {
            return null;
        }
        return inflater.inflate(R.layout.loadmore, container, false);
    }

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

        mCustomAdapter = new CustomAdapter(getActivity());
        setListAdapter(mCustomAdapter);
        ((LoadMoreListView)getListView()).setOnLoadMoreListener(this);
        mIsLoading = true;
        getLoaderManager().initLoader(0, null, this);
    }

    @Override
    public void onLoadMore() {
        Log.d(TAG, "mIsLoading = " + mIsLoading);
        // 既に読み込み中ならスキップ
        if (mIsLoading) {
            return;
        }
        mIsLoading = true;
        getLoaderManager().initLoader(mCount, null, this);
    }

    @Override
    public Loader<List<Entry>> onCreateLoader(int id, Bundle args) {
        return new CustomLoader(getActivity(), mCount);
    }

    @Override
    public void onLoadFinished(Loader<List<Entry>> loader, List<Entry> data) {
        // Call onLoadMoreComplete when the LoadMore task, has finished
        ((LoadMoreListView) getListView()).onLoadMoreComplete();
        mCustomAdapter.setData(data);
        mIsLoading = false;
        mCount++;
    }

    @Override
    public void onLoaderReset(Loader<List<Entry>> loader) {
        mCustomAdapter.setData(null);
    }

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

        // java.lang.IllegalStateException: Content view not yet created となる。
//        ((LoadMoreListView)getListView()).setOnLoadMoreListener(null);
        setListAdapter(null);
        mCustomAdapter.setData(null);
        mCustomAdapter = null;
    }
}

pull-load-example3/app/src/main/java/com/sarltokyo/pull_load_example3/app/CustomListFragment.javaによると、リストを上に引っ張って内容を更新するには、com.costum.android.widget.LoadMoreListView クラスを使い、LoadMoreListView.OnLoadMoreListener インターフェースを実装すればいいようです。

onActivityCreatedメソッド内で、

((LoadMoreListView)getListView()).setOnLoadMoreListener(this);

によってリスナーを設定した後、AsyncTaskLoaderを初期化します。

onLoadMoreメソッドでは、リストを更新するので、mCountで、AsyncTaskLoaderを初期化し直します。

onLoadFinishedメソッドでは、リストの更新が終了したことを伝えるために、

((LoadMoreListView) getListView()).onLoadMoreComplete();

とします。また、mCountを1、増やします.

onDestroyメソッドでは、次のように、メモリーリーク対策を行っていますが、不十分なようです。

        setListAdapter(null);
        mCustomAdapter.setData(null);
        mCustomAdapter = null;

プロジェクトを、

andropenguin/pull-load-example3

に公開します。

参考サイト
[Android]OutOfMemoryError(メモリリーク)対策
Android Design Support Library を少しだけ触ってみました
shontauro/android-pulltorefresh-and-loadmore

AsyncTaskLoaderとListFragmentを組み合わせたサンプルにNavigationViewを導入してみた

以前作ったAsyncTaskLoaderとListFragmentを組み合わせたサンプルで、Android Desgin Support LibraryのNavigationViewを導入してみた。

改造前のサンプルは、AsyncTaskLoaderとListFragmentを組み合わせたサンプルで紹介したサンプル andropenguin/CustomListFragementSample4です。

NavigationViewを導入したサンプルを

andropenguin/CustomListFragmentSample7

に公開します。

動作時のスクリーンショット

List

NavigationView

参考サイト
Android Design Support Library を少しだけ触ってみました

他人のブログを読んだり、勉強会等のLTを聴いて感じること

こんにちは。

他人のブログを読んでいたり、勉強会や某ABCに参加してLTとか聴いていて感じたことを書きます。

1. お前のブログはオリジナリティがあるのか?
既知の内容なのに、そういった内容をブログに書いている人をよく見かけます。参考サイトのリンクを張っていればまだ許せますが、そういったものがないブログもあります。また、本に載っていることを書いているブログも見かけます。例えば、最近見かけたのでは、iOSでのUITableViewの使い方とか。これはひどいと思いました。お前が考案したAPIなのか?w

2. お前のブログは、どこかのサイトのページの翻訳ではないか?
Androidに関して、キーワードで日本語サイトをググると、Googleのドキュメントリファレンスのページをほぼ翻訳したページを見かけることがあります。ブログを書いている本人は、日本人のために翻訳してあげているんだとか言いそうですが、ブログ中に出てくるサンプルコードも、元のドキュメントリファレンスのページと同じことが多々あります。真っ先に日本語に訳して、日本で最先端を行っているとでも思っているのでしょうか。技術的にとんがりたいなら、自分で作ったサンプルアプリのコードでも載せて欲しいですね。また、最近見かけたブログでは、英語圏のブログのほぼ翻訳で、コード断片もコピペで、元のコードの、作者独自定義の定数を、定義もせずに(定義を引用もせずに)使用している例がありました。元の英語圏のブログでは、githubにサンプルコードがあったので、定数の定義を見ることができましたが、翻訳サイトでは、githubへのリンクは張られていませんでした。

3. 勉強会や某ABCのLTで笑いを取ることしかしない
勉強会や某ABCに参加してLTを聞いていると、次のようなことがあります。

a. アニメの画像をスライドに入れる。
面白くないですし、あなたは、そのアニメの作者に、画像の使用許諾を取ったのですか?

b. 奇声を上げて笑いをとろうとする
勉強会では見かけないですが、某ABCのLTではよく見かけます。ちっとも面白くないです。ビジネス、技術アピールで注目を浴びるようにして下さい。

c. その他
某ABCのLTで、当時流行っていた某ゲームアプリを、某テストツールで動かすことをやったLTがありました。はっきり言って、技術的に面白くないです。自分のプロダクトをテストツールで動かすのならいいですが、そうでないなら、単に笑いを取るだけの内容ですね。