Dagger2とMockitoを使ってUIテストをはじめる

  • 18
    いいね
  • 0
    コメント

この記事は Android Advent Calendar 2016 12日目の記事です。

こんにちは。昨年からAndroidエンジニアにクラスチェンジして仕事でAndroidアプリを作ってます。今回はDagger2を使ったAndroidのテストの話でも書いてみます。

Dagger2を使ってDIする

なぜテストにDIが必要か

  • シンプルな例で、何かのAPIの結果を表示する画面を考えてみます。APIを叩くServiceClassを作って、次にActivityの中でそのServiceClassのインスタンスを作ってコールして結果を表示するというような形にしたとします。
  • この場合、ActivityはServiceClassに依存しているという状態になります。APIのレスポンスに応じたテストをしたい場合、ActivityとServiceClassは密結合してるので、APIを返すサーバー側でレスポンスを変えないと、このActivityの表示パターンをテストできません。
  • そこで登場するのがDIという考え方です。この場合は画面内ではServiceClassのインスタンスを作らないようにして、実装クラスのインスタンスを画面外から注入することでレスポンスの差し替えを実現します。Activityはインターフェースだけ意識するようになるので、その実装がどうなっているかは意識する必要がなくなります。
  • Dagger2はこの注入するための仕組みを実現するDIコンテナ用のライブラリです。

DIできるようにした画面のテストをする

  • AndroidのViewのコンテキストが絡むテストとして以下のテストがあります。
    • Robolectricを使ったJVM上でのテスト
    • Espressoを使った実機上でのテスト
  • 今回は両方のパターンでDIを使って画面が依存するモジュールを差し替えるテストを紹介します。差し変える方法はテスト用のDaggerComponentを作る方法もありますが、今回はMocitoを使った方法でモックをDIしてみます。
  • サンプルはこんな感じでListDataDaoが返すListを一覧表示するだけの画面になります。この画面で表示するデータがあるときと無いときのケースをテストします。

Robolectric

  • Activityは単なるガワになってるのでFragmentだけテストを書きます
ListFragmentTest
@RunWith(RobolectricTestRunner.class)
public class ListFragmentTest {

    @Mock
    ListDataDao mockDao;

    private ListFragment fragment;

    @Before
    public void setUp() throws Exception {
        MockitoAnnotations.initMocks(this);
        fragment = ListFragment.newInstance();
        fragment.dao = mockDao;
    }

    @Test
    public void データがある時はリストが表示される() {

        List<String> mockData = new ArrayList<>();
        mockData.add("test1");
        mockData.add("test2");
        mockData.add("test3");

        when(mockDao.getData()).thenReturn(mockData);

        SupportFragmentTestUtil.startFragment(fragment, DriverActivity.class);

        RecyclerView recyclerView = (RecyclerView) fragment.getView().findViewById(R.id.recycler_view);
        recyclerView.measure(0, 0);
        recyclerView.layout(0, 0, 100, 1000);

        TextView textEmptyView = (TextView) fragment.getView().findViewById(R.id.empty);
        TextView itemView1 = (TextView) recyclerView.findViewHolderForAdapterPosition(0).itemView.findViewById(R.id.list_item_text);
        TextView itemView3 = (TextView) recyclerView.findViewHolderForAdapterPosition(2).itemView.findViewById(R.id.list_item_text);

        assertThat(recyclerView.getVisibility(), is(View.VISIBLE));
        assertThat(recyclerView.getAdapter().getItemCount(), is(3));
        assertThat(itemView1.getText(), is("test1"));
        assertThat(itemView3.getText(), is("test3"));

        assertThat(textEmptyView.getVisibility(), is(View.GONE));
    }

    @Test
    public void データが空の時はリストが表示されない() {

        List<String> mockData = new ArrayList<>();

        when(mockDao.getData()).thenReturn(mockData);

        SupportFragmentTestUtil.startFragment(fragment, DriverActivity.class);

        RecyclerView recyclerView = (RecyclerView) fragment.getView().findViewById(R.id.recycler_view);
        TextView textEmptyView = (TextView) fragment.getView().findViewById(R.id.empty);

        assertThat(recyclerView.getVisibility(), is(View.GONE));
        assertThat(textEmptyView.getVisibility(), is(View.VISIBLE));
    }

    public static class DriverActivity extends AppCompatActivity implements HasComponent<ListActivityComponent> {

        @Override
        public ListActivityComponent getComponent() {

            ListActivityComponent activityComponent = mock(ListActivityComponent.class);

            ListFragmentComponent fragmentComponent = mock(ListFragmentComponent.class);

            when(activityComponent.plus(any(ListFragmentModule.class))).thenReturn(fragmentComponent);

            return activityComponent;
        }
    }
}
  • Driver用のActivityでFragmentが参照するComponentをMockitoで作ったモックに差し替えます。これでFragmentでモジュールを注入するinjectが呼ばれても何も起きなくなりますが、このままだとdaoはnullのままなので、setUp時にFragmentに直接セットしてあげます。
  • この時にMockitoで作ったモックのdaoをセットすることで各テストケース毎にdaoの振る舞いを変更することができるようになります。

Espresso

ListActivityUITest.java
@RunWith(AndroidJUnit4.class)
public class ListActivityUITest {

    @Mock
    ApplicationComponent applicationComponent;

    @Mock
    ListActivityComponent activityComponent;

    @Mock
    ListFragmentComponent fragmentComponent;

    @Mock
    ListDataDao mockDao;

    @Rule
    public ActivityTestRule<ListActivity> activityRule = new ActivityTestRule<>(ListActivity.class, true, false);

    @Before
    public void setUp() throws Exception {

        MockitoAnnotations.initMocks(this);

        SampleApplication app = (SampleApplication) InstrumentationRegistry
                .getTargetContext()
                .getApplicationContext();

        app.setComponent(applicationComponent);

        when(applicationComponent.plus(any(ListActivityModule.class)))
                .thenReturn(activityComponent);

        when(activityComponent.plus(any(ListFragmentModule.class)))
                .thenReturn(fragmentComponent);

        doAnswer(invocation -> {
            ListFragment fragment = (ListFragment) invocation.getArguments()[0];
            fragment.dao = mockDao;
            return fragment;
        }).when(fragmentComponent).inject(any(ListFragment.class));
    }

    @Test
    public void データがある時はリストが表示される() {

        List<String> mockData = new ArrayList<>();
        mockData.add("test1");
        mockData.add("test2");
        mockData.add("test3");

        when(mockDao.getData()).thenReturn(mockData);

        activityRule.launchActivity(new Intent());

        onView(withId(R.id.recycler_view))
                .check(matches(hasDescendant(withText("test1"))));
        onView(withId(R.id.recycler_view))
                .check(matches(hasDescendant(withText("test2"))));
        onView(withId(R.id.recycler_view))
                .check(matches(hasDescendant(withText("test3"))));

        onView(withId(R.id.empty))
                .check(matches(not(isDisplayed())));
    }

    @Test
    public void データが空の時はリストが表示されない() {

        List<String> mockData = new ArrayList<>();

        when(mockDao.getData()).thenReturn(mockData);

        activityRule.launchActivity(new Intent());

        onView(withId(R.id.empty))
                .check(matches(isDisplayed()));

        onView(withId(R.id.recycler_view))
                .check(matches(not(isDisplayed())));
    }
}
  • Espressoの場合も考え方は同じでテストが走る前にモックのComponentに差し替えてinjectを無効化した上でモックのdaoを直接Fragmentに差し込みます。
  • setupでこのように書くことで、FragmentComponentのinjectが呼ばれたタイミングでモックのdaoを差し込めます。
    doAnswer(invocation -> {
        ListFragment fragment = (ListFragment) invocation.getArguments()[0];
        fragment.dao = mockDao;
        return fragment;
    }).when(fragmentComponent).inject(any(ListFragment.class));

おわり

  • いかがだったでしょうか。個人的にはUIのテストは導入のコストが高いので、いきなりカバレッジ100%目指すぞ!というようなことはやらずに、ポイントを絞って重要な画面だけテストを書いてみるという形で始めてみるのがいいのかなと思います。
  • 今回のコードはこちらに上げておきました。
この投稿は Android Advent Calendar 201612日目の記事です。