Instrumentation Testを書く時、実際にAPIリクエストは行わせず、APIレスポンスをmockしたいですよね。方法としては2通りはあるかと思います。
- Gradleで Test flavorを作り、APIを差し替える
- Dagger2を使い、mockをinjectする
mockの値を変更するのが容易なので、2番目の方法がおすすめです。しかしながらDagger2でmockを依存性注入するのは結構面倒で、実際テストを書き始めるまでにいくつかステップが必要です。この記事ではDagger2とKotlinを使ってEspresso instumentation testをを書く方法を説明します。
サンプルプロジェクト - 会員登録
サンプルプロジェクトとして簡単な会員登録の画面を作りました。入力した値が正しければ、名前と年齢を表示し、そうでなければWarningを出します。
このプロジェクトはMVVMアーキテクチャを用いており、RxJava2でオブザーバーパターンを実装しています。このプロジェクトを使って、以下のようなテストを書きます。
- デフォルトで"No Info" と表示する
- 入力が正しい場合は名前と年齢を表示する
- 名前が入力されてない場合はエラーメッセージ("Name invalid")を表示する
こちらがプロジェクトのリンクです。
Step1: Android Test用の依存性注入を行う
まずはTestCaseに対してテストオブジェクトの依存性注入を行う必要があります。このブログで紹介されている方法に従ってみましょう。
なお、NativeサポートのためにMockito 2.6+を使う必要があります。
この例では、API レスポンスとSharedPreferenceの値をmockしたいです。なので、TestAppComponentは以下のようになります
@Singleton @Component(modules = arrayOf( AppModule::class, PrefModule::class, ApiModule::class, RepositoryModule::class) ) interface TestAppComponent : AppComponent { fun inject(test: SignupActivityTest) }
依存性注入はSignupActivityTest.kt
のsetup()
で行います。
@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する
依存性注入をするためにApiModule
とPrefModule
をオーバーライドしてTestApiModule
とTestPrefModule
を作る必要があります。しかしながら、Kotlinではクラスが標準でfinalのため、mockが出来ません。Mockitoはfinal classもmockできるようになりましたが、残念ながらJUnitテストしかサポートしていません。
個人的な意見としては、現時点でもっともおすすめな方法はDebug flavorだけをall-open compiler pluginを使うことでしょうか。Debug flavorのみでOpenにする手順は以下のリンクにあります。この方法に従ってみましょう。
PrefModule
とTestPrefModule
は以下のようになります。
@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_default
はuserInfo
TextViewがデフォルトの値("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}”
これはなぜかというと、ActivityTestRule
にTestAppComponent
に正しく依存性注入がされていないからです。
依存性注入の部分のログをとると、SignupActivity
の依存性注入が先に始まり、TestAppComponent
の依存性注入が後に始めることがわかります。
これはActivityTestRule
がsetup()
よりも先に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 ... } }
テストを実行してみます。
無事通りました!
依存性注入を正しい順序で行われ、想定通りにテストがパスしました。
まとめ
自分の経験では、Android instrumentation testはいつも始めるのが難しいです。それは恐らく実際のユースケースに沿ったドキュメントが十分に存在しないからではないでしょうか。このチュートリアルが instrumentation testを書くために役に立てば幸いです。