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.

コメントを残す

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