Edited at
AndroidDay 2

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


何故、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 を導入すべきかは、ご自身の状況に留意し、自己責任でご検討くださいね。

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