tomoima525's blog

Androidとか技術とかその他気になったことを書いているブログ。世界の秘密はカレーの中にある!サンフランシスコから発信中。

Dagger2 + KotlinでEspressoのInstrumentation test を書く

Instrumentation Testを書く時、実際にAPIリクエストは行わせず、APIレスポンスをmockしたいですよね。方法としては2通りはあるかと思います。

  • Gradleで Test flavorを作り、APIを差し替える
  • Dagger2を使い、mockをinjectする

mockの値を変更するのが容易なので、2番目の方法がおすすめです。しかしながらDagger2でmockを依存性注入するのは結構面倒で、実際テストを書き始めるまでにいくつかステップが必要です。この記事ではDagger2とKotlinを使ってEspresso instumentation testをを書く方法を説明します。

サンプルプロジェクト - 会員登録

サンプルプロジェクトとして簡単な会員登録の画面を作りました。入力した値が正しければ、名前と年齢を表示し、そうでなければWarningを出します。
f:id:tomoima525:20180107151141p:plain:w400

このプロジェクトはMVVMアーキテクチャを用いており、RxJava2でオブザーバーパターンを実装しています。このプロジェクトを使って、以下のようなテストを書きます。

  • デフォルトで"No Info" と表示する
  • 入力が正しい場合は名前と年齢を表示する
  • 名前が入力されてない場合はエラーメッセージ("Name invalid")を表示する

こちらがプロジェクトのリンクです。

github.com

Step1: Android Test用の依存性注入を行う

まずはTestCaseに対してテストオブジェクトの依存性注入を行う必要があります。このブログで紹介されている方法に従ってみましょう。

artemzin.com

なお、NativeサポートのためにMockito 2.6+を使う必要があります。

jeroenmols.com

この例では、API レスポンスとSharedPreferenceの値をmockしたいです。なので、TestAppComponentは以下のようになります

@Singleton
@Component(modules = arrayOf(
        AppModule::class,
        PrefModule::class,
        ApiModule::class,
        RepositoryModule::class)
)
interface TestAppComponent : AppComponent {
    fun inject(test: SignupActivityTest)
}

依存性注入はSignupActivityTest.ktsetup()で行います。

@Before
fun setup() {
    MockitoAnnotations.initMocks(this)
    val app = InstrumentationRegistry.getTargetContext().applicationContext as MyApplication
    testAppComponent = DaggerTestAppComponent.builder()
            .appModule(AppModule(app))
            .apiModule(/** mock Api Module**/)
            .prefModule(/** mock preference Module **/)
            .build()
    app.appComponent = testAppComponent
    testAppComponent.inject(this)
}

参考までに付け加えておくと、このへんのTestComponentを作るのを助けてくれるDaggerMockというライブラリもあります。このライブラリはDagger2のオブジェクトをオーバーライド出来るのでテストオブジェクトの作成が容易になります。 とは言え自分がおすすめしたいのは、まずは自分でコードを書いてみて、どのような仕組みで動いているのか理解しておくことです。デバッグなどをする時に役に立ちます。

Step2: Android TestでKotlinクラスをmockする

依存性注入をするためにApiModulePrefModuleをオーバーライドしてTestApiModuleTestPrefModuleを作る必要があります。しかしながら、Kotlinではクラスが標準でfinalのため、mockが出来ません。Mockitoはfinal classもmockできるようになりましたが、残念ながらJUnitテストしかサポートしていません。

個人的な意見としては、現時点でもっともおすすめな方法はDebug flavorだけをall-open compiler pluginを使うことでしょうか。Debug flavorのみでOpenにする手順は以下のリンクにあります。この方法に従ってみましょう。

github.com

PrefModuleTestPrefModuleは以下のようになります。

@DebugOpenClass
@Module
class PrefModule {
    @Provides
    @Singleton
    fun provideUserPref(application: MyApplication): UserPrefs = UserPrefs(application)
}
class TestPrefModule: PrefModule() {
    override fun provideUserPref(application: MyApplication): UserPrefs {
        return Mockito.mock(UserPrefs::class.java)
    }
}

Step3. テストの実行

テストコードはこんな感じです。

@RunWith(AndroidJUnit4::class)
class SignupActivityTest {

    @get:Rule
    val testRule: ActivityTestRule<SignupActivity>
            = ActivityTestRule(SignupActivity::class.java)

    @Inject
    lateinit var registerApi: RegisterApi

    @Inject
    lateinit var userPref: UserPrefs

    private lateinit var testAppComponent: TestAppComponent

    @Before
    fun setup() {
        MockitoAnnotations.initMocks(this)
        val app = InstrumentationRegistry.getTargetContext().applicationContext as MyApplication
        testAppComponent = DaggerTestAppComponent.builder()
                .appModule(AppModule(app))
                .apiModule(TestApiModule())
                .prefModule(TestPrefModule())
                .build()
        app.appComponent = testAppComponent
        testAppComponent.inject(this)
    }
    @Test
    fun userInfo_returns_no_info_by_default() {
        // given
        // nothing is stored
        whenever(userPref.hasAge()).thenReturn(false)
        whenever(userPref.hasName()).thenReturn(false)

        // then
        onView(withId(R.id.user_info)).check(matches(withText("No info")))
    }
    ...
}

最初のテストuserInfo_returns_no_info_by_defaultuserInfoTextViewがデフォルトの値("No info")を表示することをチェックしています。しかしこれらのテストを実行すると、Textが設定されていないといわれ失敗します。

android.support.test.espresso.base.DefaultFailureHandler$AssertionFailedWithCauseError: 'with text: is "No Info"' doesn't match the selected view.
Expected: with text: is "No Info"
Got: "AppCompatTextView{id=2131165314, res-name=user_info, visibility=VISIBLE, width=697, height=66, has-focus=false, has-focusable=false, has-window-focus=true, is-clickable=false, is-enabled=true, is-focused=false, is-focusable=false, is-layout-requested=false, is-selected=false, layout-params=android.support.constraint.ConstraintLayout$LayoutParams@fd2f3f8, tag=null, root-is-layout-requested=false, has-input-connection=false, x=56.0, y=795.0, text=Current info: Name Mike Age 12, input-type=0, ime-target=false, has-links=false}”

これはなぜかというと、ActivityTestRuleTestAppComponentに正しく依存性注入がされていないからです。 依存性注入の部分のログをとると、SignupActivityの依存性注入が先に始まり、TestAppComponentの依存性注入が後に始めることがわかります。

f:id:tomoima525:20180107153958p:plain

これはActivityTestRulesetup()よりも先にActivityを起動してしまうために起きています。なので、ここでは手動でテスト毎に依存性注入を行うことが必要です。コードは以下のように書き直します。

@RunWith(AndroidJUnit4::class)
class SignupActivityTest {
    @get:Rule
    val testRule: ActivityTestRule<SignupActivity>
            = ActivityTestRule(SignupActivity::class.java, false, false) // do not launch the app 
    ...
    @Test
    fun userInfo_returns_no_info_by_default() {
        // given
        ...
        // when
        testRule.launchActivity(null) // Launch manually
        // then
        ...
    }
}

テストを実行してみます。

f:id:tomoima525:20180107154248p:plain:w400
無事通りました!

f:id:tomoima525:20180107154316p:plain

依存性注入を正しい順序で行われ、想定通りにテストがパスしました。

まとめ

自分の経験では、Android instrumentation testはいつも始めるのが難しいです。それは恐らく実際のユースケースに沿ったドキュメントが十分に存在しないからではないでしょうか。このチュートリアルが instrumentation testを書くために役に立てば幸いです。