14
9

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 3 years have passed since last update.

AndroidAdvent Calendar 2018

Day 2

Dagger2を使ったらUnitTestが楽になるか考えてみた

Last updated at Posted at 2018-12-01

※テストコードに関して、新しい記事を投稿しました。

 → 「Androidでテスト駆動開発に挑戦してみた

何故、Dagger2なのか?

入り口は直感でした。

「Unit Test をもっと簡単にできないだろうか。DIする設計なら、テストの時にモック注入とかできて楽なんじゃないか。Dagger2って有名だし使ってみるか。」

というざっくりとした直感です。

この記事では、上記を深掘りし、どうやったら Unit Test が楽になるか考えていきます。
※Dagger2の他にも、素敵なフレームワークは沢山あると思います。あくまでご参考まで。

Q. Unit Test が辛い理由

A. 単純に作業量が増える。

私たちは、アプリの仕様やビジネスに応じて、日々、最適な設計を模索しています。そこにテストの要素も追加するのは辛いですよね。

Q. どうすれば設計に集中できる?

A. テストの事なんか考えなくて良いようにする。

最適な設計の検討にパワー全振りしても、テストし易いプログラム構造になってくれる様な仕組みを作る。

「テストが書き辛い」とは、例えば、「テスト対象のロジックに到達する前に、周辺モジュールがヌルポで落ちまくって挫折」という状況。

もし、簡単・柔軟にモック・スタブ等に出来るならば、設計に集中できます!(仮説)

Q. 簡単・柔軟なモックとは?

A. 任意の変数やメソッドをモックにできること。

DaggerはDIする時に@Injectを変数に付けるので、テストコードは@Injectの付いた変数をMockitoでモック化します。

ソースコード

本題のソースコードです。ここでは以下の環境を用います。

使用するライブラリ

・dagger2 : 2.17
・mockito-core:2.23.0
・mockito-android:2.22.0
・junit : 4.12
・robolectric : 3.8
・espresso : 3.0.2

Dagger関連の実装

なるべくシンプルで分かりやすくなるよう、SubComponentは使いません。

AppComponent
@Component(modules = {AppModule.class})
public interface AppComponent extends IComponent {
    @Component.Builder
    interface AppBuilder {
        @BindsInstance
        AppBuilder application(Application application);
        AppComponent build();
    }
}
AppModule
@Module
public class AppModule {

    @Provides
    Context provideContext(Application application) {
        return application;
    }

    @Provides
    public SampleEntity provideSampleEntity() {
        return XXXXX;
    }
}

以下のIComponentが、実は肥大化する恐れがあります。
が、プログラムの階層をなるべく平坦にするため、諦めました。

IComponent
/**
 * DIを使う対象のクラスの分だけinjectメソッドを定義してください。
 * abstract classや、genericsクラスは、Dagger2はDIできないので、
 * インナークラスを定義し、そちらにDIしてください。
 */
public interface IComponent {
    void inject(MainActivity activity);
    ...
    void inject(SampleFragment fragment);
    ...
    void inject(AbstractPresenter.DIWrapper wrapper);
    void inject(GenericsPresenter.DIWrapper wrapper);
    ...
}

DI class は、DIを簡単に使えるようにしたUtilityです。

DI
public class DI {

    private static AppComponent sAppComponent;

    public static void setAppComponent(AppComponent component) {
        sAppComponent = component;
    }

    public static AppComponent injector() {
        return sAppComponent;
    }
}

DIを使う側の実装

MyApplication
public class MyApplication extends Application {
    @Override
    public void onCreate() {
        super.onCreate();
        DI.setAppComponent(
                DaggerAppComponent
                        .builder()
                        .application(this)
                        .build());
    }
}
MainPresenter
public class MainPresenter {
    @Inject MainPresenter(){}
}
MainActivity
public class MainActivity extends AppCompatActivity {

    @Inject Context mContext;
    @Inject MainPresenter mPresenter;
    @Inject SampleEntity mEntity;

    @Override
    protected void onCreate(Bundle savedInstanceState) {
        DI.injector().inject(this);
        super.onCreate(savedInstanceState);
    }
}

Unit Test の実装

DIをhookする

DIMock class は、EspressoでもRobolectricでも利用できます。
AppComponentをMockitoでモック化し、injectをhookします。
hookした際、本物のinjectを実行した後、テストロジックにコールバックします。
あとは、使いやすいように幾つかUtility的なメソッドを用意してみました。

DIMock
public class DIMock {

    public interface DICallback<T> {
        void onInject(T listener);
    }

    // original component
    private static AppComponent sOriginal = DI.injector();

    /**
     * injectをhook{@link Mockito#doAnswer(Answer)}するモック
     */
    private static AppComponent sMock;

    private static HashMap<Class<?>, Object> sCustomMocks = new HashMap<>();

    private static HashMap<Class<?>, ArrayList<DICallback>> sCallbacks = new HashMap<>();

    /**
     * DaggerのDIの監視を開始する。
     * DIが実行された後、モックに置き換えたい場合は{@link DIMock#registDICallback(Class, DICallback)} でコールバックを受け取り
     * 個別にfieldに代入する、とか、{@link DIMock#replaceMockAll(Object)}を実行すること。
     */
    static {

        // injectをhook(Mockito.doAnswer)する為の設定をする
        // AppComponentの実態クラスは、Daggerによりfinal宣言されているのでspyできない。
        sMock = Mockito.mock(AppComponent.class);

        Answer answer = invocation -> {

            // injectが呼ばれた際に本コールバックが発生する

            Object arg = invocation.getArgument(0);

            // originalのinjectを実行する
            callInject(sOriginal, arg.getClass(), arg);

            // テスト対象のクラスにコールバックする
            ArrayList<DICallback> list = sCallbacks.get(arg.getClass());
            if (list != null) {
                for (DICallback callback : list) {
                    callback.onInject(arg);
                }
            }

            return null;
        };

        // 全injectをhookする
        for (Method method : IComponent.class.getMethods()) {
            // このタイミングでcallしても実際のメソッドが呼ばれる訳ではない。
            // Mockitoに登録している。
            AppComponent hook = Mockito.doAnswer(answer).when(sMock);
            Class<?> paramType = method.getParameterTypes()[0];
            callInject(hook, paramType, ArgumentMatchers.any(paramType));
        }

        DI.setAppComponent(sMock);
    }

    /**
     * injectを実行する
     */
    private static <T> void callInject(AppComponent component, Class<T> clazz, Object arg) {
        try {
            component.getClass().getMethod("inject", clazz).invoke(component, arg);
        } catch (Exception e) {
            e.printStackTrace();
        }
    }

    /**
     * テストしたいクラスのinjectをhookするコールバックを登録する
     * @param clazz テスト対象のクラス
     * @param callback コールバック
     */
    public static <T> void registDICallback(Class<T> clazz, DICallback<T> callback) {

        ArrayList<DICallback> list = sCallbacks.get(clazz);
        if (list == null) {
            list = new ArrayList<>();
            sCallbacks.put(clazz, list);
        }

        list.add(callback);
    }

    /**
     * Inject対象のインスタンスのインジェクト済みインスタンスを全て{@link Mockito#mock(Class)}で置き換える
     * {@link DIMock#registCustomMock(Class, Object)}で登録済みの場合は、そちらを優先する。
     * @param target
     */
    public static void replaceMockAll(Object target) {
        for(Field field : target.getClass().getDeclaredFields()) {
            if(field.getAnnotation(Inject.class) == null) {
                continue;
            }
            boolean accessible = field.isAccessible();
            try {
                field.setAccessible(true);
                Class<?> type = field.getType();
                Object mock = sCustomMocks.get(type);
                if (mock == null) {
                    mock = Mockito.mock(type);
                }
                field.set(target, mock);
            } catch (Exception e) {
                e.printStackTrace();
            } finally {
                field.setAccessible(accessible);
            }
        }
    }

    /**
     * モックをカスタムする場合は、事前に登録する
     * @param clazz mock化するクラス型
     * @param mock mockのインスタンス。{@link Mockito#mock(Class)}を使って作成しても良いし
     *             テスト用スタブ(自作)を指定してもよい。
     */
    public static void registCustomMock(Class clazz, Object mock) {
        sCustomMocks.put(clazz, mock);
    }
}

Unit Test は、楽になるか?

ようやく準備が整いました。実際にテストコードを書いてみます。

Espressoのテスト

DIMockを使って以下の事をやってみます。
(1) 特定のクラス型に対して、カスタムのモックオブジェクトを登録する。
(2) DIが実行されたタイミングで、DI対象のインスタンスを全てモック化する。
(3) DIが実行されたタイミングで、特定のフィールドのみモック化する。

FragmentだとかViewModelだとか関係なく、DIをhookできます。

以下の例では、MainActivityに対してのinjectをhookし
特定のモジュールを丸ごとモック化したり、一部のメソッドをモック化するサンプルを作成しました。
MainActivityは、SampleFragmentを内包する想定で読んでください。
SampleFragmentもDIを使っているものとします。

Espressoのテスト

    private ActivityTestRule<MainActivity> mRule = new ActivityTestRule<>(MainActivity.class, false, false);

    @Test
    public void myTest() {

        Intent intent = new Intent(); // intentの初期化は省略

        // 事前にカスタムモックを登録しておく ・・・ (1)
        DIMock.registCustomMock(SampleEntity.class, new SampleEntityStub());

        DIMock.registDICallback(MainActivity.class, activity -> {
            // DI対象を全てモックに置き換える ・・・ (2)
            // さっき、registCustomMockしたスタブもこのタイミングで置き換えられる ・・・ (1)
            DIMock.replaceMockAll(activity);
        });

        DIMock.registDICallback(SampleFragment.class, fragment -> {
            // 一部のフィールドだけ個別にモックに置き換えることが出来る ・・・ (3)
            fragment.xxxxx = mock(Xxxxx.class);

            // spyして一部のメソッドを改造する事ができる ・・・ (3)
            SampleFragment spyFragment = spy(fragment);
            Yyyyy spyY = spy(fragment.yyyyy);
        });

        mRule.launchActivity(intent);
        MainActivity activity = mRule.getActivity();
        activity.myLogic();
    }
}

Robolectricのテスト

Espressoの場合と全く同じ流れで同じ事ができます。

Robolectricのテスト

    @Test
    void myTest() {
        DIMock.registDICallback(SampleFragment.class, fragment -> {
            // (Espressoと同じなので略)
        });

        MainActivity activity = Robolectric
                .buildActivity(MainActivity.class)
                .create().get();
        activity.myLogic();
    }
}

いかがでしたでしょうか

楽に柔軟にモック化できるようになりました。
これならガンガンソースを書いていっても、テストし易いのではないでしょうか。(勿論、例外は無限にあると思います。。)

Dagger2 を導入すべきかは、ご自身の状況に留意し、自己責任でご検討くださいね。

ご参考になれば幸いです。

14
9
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
14
9

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?