※テストコードに関して、新しい記事を投稿しました。
何故、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は使いません。
@Component(modules = {AppModule.class})
public interface AppComponent extends IComponent {
@Component.Builder
interface AppBuilder {
@BindsInstance
AppBuilder application(Application application);
AppComponent build();
}
}
@Module
public class AppModule {
@Provides
Context provideContext(Application application) {
return application;
}
@Provides
public SampleEntity provideSampleEntity() {
return XXXXX;
}
}
以下の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です。
public class DI {
private static AppComponent sAppComponent;
public static void setAppComponent(AppComponent component) {
sAppComponent = component;
}
public static AppComponent injector() {
return sAppComponent;
}
}
DIを使う側の実装
public class MyApplication extends Application {
@Override
public void onCreate() {
super.onCreate();
DI.setAppComponent(
DaggerAppComponent
.builder()
.application(this)
.build());
}
}
public class MainPresenter {
@Inject MainPresenter(){}
}
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的なメソッドを用意してみました。
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を使っているものとします。
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の場合と全く同じ流れで同じ事ができます。
@Test
void myTest() {
DIMock.registDICallback(SampleFragment.class, fragment -> {
// (Espressoと同じなので略)
});
MainActivity activity = Robolectric
.buildActivity(MainActivity.class)
.create().get();
activity.myLogic();
}
}
いかがでしたでしょうか
楽に柔軟にモック化できるようになりました。
これならガンガンソースを書いていっても、テストし易いのではないでしょうか。(勿論、例外は無限にあると思います。。)
Dagger2 を導入すべきかは、ご自身の状況に留意し、自己責任でご検討くださいね。
ご参考になれば幸いです。