AndroidのJUnitテストは超ムズいぞ・・・
Andorid開発者みなさん、JUnitテストコード書いてちゃんとテストしてますか?
Android開発歴1年程度ですが、JUnitテストが超ムズいので打ちひしがれています。他の分野の話題に比べ、話題が少ないので、世間一般でどうなのかな?と思いました。
ムズい、その訳は?
- いまだに、標準がJUnit4。(JUnit5にする方法もあるらしいが、公式ではいまだにJUnit4)
- GUIに密接に関連している。
- 非同期処理を多用している。
- 複数のテスト方法がある
1.に関しては、何でいまだにJUnit4なんだろう?と疑問を持つくらい。JUnit5が出てもうだいぶ経つのに(2017年)。assertAllとか使いたいんだけどな。
2.に関しては、一番困るのがContext類(Context、ApplicationContext)。これが至る所に出てくる。strings.xmlからgetStringするのにもContextが必要。
3.はkotlinだとcoroutineですね。Dispacherとかscopeとかを考えないといけません。
4.についてはこの後、詳しく説明します。
自分の思うところとしては・・・
最初はAndroid未経験だったので、とりあえずスケジュールもあるし、プロダクションコードの開発に専念しました。AndroidのJUnitテストコードを書くにはプロダクションコードを書くのとはまた、別なノウハウが必要になります。(後述)
結果的に、プロダクションコード一通り書き終えて、ある程度手動テストしてから(完成!)JUnitテストコードを書き始めたのですが、結果、プロダクションコードも一部書き直さないといけないところがいくつか出てきました。
やはり、テストコードはプロダクションコードを書きながら作るのが定石です。(痛感)
Androidのテスト方法
- Local Test
- マシンのローカル Java 仮想マシン(JVM)で実行される。
- $module-name/src/test/java/ に作る
- 軽い
- Instrument test
- 物理デバイスまたはエミュレータで実行される。
- $module-name/src/androidTest/java/ に作る
- 重い(エミュレータで実行場合は特に)
Androidのテストライブラリ
これに対して、Androidのテストの書き方が複数存在しています。公式でも複数組み合わせることを推奨しています。
何故か?
AndroidのアプリはAndroidの仕組みに依存しているものが多々あります。一つの書き方、テストライブラリでは全てをカバーできないので、それぞれの得意なところを組み合わせてテストします。
それぞれのテストライブラリでそれぞれのノウハウと書き方を要求されます。なので超ムズい。
テストライブラリとしても、これだけあります。
- 使わない(Context、GUIに依存しない、素のUtilクラスなどの場合)
- Espresso
- Robolectric
- UI Autometer
テストライブラリとテスト方法を対比してみると
テストライブラリ | テスト方法 |
---|---|
使わない | local test |
Espresso | Instrument test |
Robolectric | local test |
UI Autometer | Instrument test |
RobolectricはGUIのテストをしますが、何故かlocal testです。(後述)
mockライブラリ、assrtionライブラリ
これに対して、mockライブラリ、assrtionライブラリが複数存在します。
個人的にお気に入りは、mockライブラリはmockk、assertionライブラリはtruthを使っています。(AssertJに近いと言われている)
テスト対象のクラスの種類
テスト対象のクラスが
- Activity
- Fragment
- RecyclerView、Adapter等
- VeiwModel(coroutineの部分も含む)
- room(使っていれば)
- 等々・・・
によっても書き方が違います。
さあ、これらを組み合わせるだけで、もうお腹いっぱい・・・って感じじゃないでしょうか?
Robolectricは何故local testでAndroid APIが使えるのか?
- Robolectricは独自にshadowという仕組みを持っています。(mockとは違うと行っている)
- Androidのフレームワークに依存性があるコードをshadowに変換して処理します。
- Robolectricを利用したユニットテスト実行時、RobolectricTestRunnerがJVMをフッキングし、クラスローダーがAndroid コンポーネントをロードするのではなく、RobolectricのShadowオブジェクトをロードするようにします。
かと言って、どんなAndroid APIのテストもできるわけではありません。できないものもあります。
- WorkManagerを使った非同期
- NavigationView(ナビゲーションドロワー)の操作
- Pageingを使ったRecyclerViewの操作
- 等々・・・
具体例、roomのテストケース
ここからは具体的なテストケースの例、わかりやすいところから。まずroomのdaoのテスト。
roomはinMemoryDatabaseBuilderを使います。
@RunWith(AndroidJUnit4::class)
class MyDaoTest {
/** DB */
private lateinit var myDb: MyDb
/** DAO */
private lateinit var myDao: MyDao
@Before
fun createDb() {
val context = ApplicationProvider.getApplicationContext<Context>()
myDb = Room.inMemoryDatabaseBuilder(
context,
MyDb::class.java
).build()
println("setup!!")
// DAOのテスト用インスタンスを取得
myDao = myDb.myDao()
runBlocking {
// 初期データが必要な時はここで入れる
myDao.insert( ・・・・)
}
}
@After
@Throws(IOException::class)
fun cleanup() {
myDb.close()
}
上の例のMyDb、MyDaoはプロダクションコードのクラスです。
@BeforeでinMemoryDatabaseBuilderで初期化してやります。daoのインスタンスも取ってきてプロパティにセットします。初期データが必要であればここで入れます。Room.inMemoryDatabaseBuilderはテストメソッド毎に空になりますので注意が必要。
roomのdaoの関数はsupend関数である場合が多いです。supend関数はcoroutineのscopeの中にないと動かないので。各テストメソッドは
@Test
@Throws(Exception::class)
fun selectByXXXTest() = runTest {
val rslt = myDao.selectXXXX("XXX")
assertThat(rslt.size).isEqualTo(2)
}
のようにrunTestでテスト用のscopeで囲ってやる必要があります。
daoの関数がObserveパターンでFlowを返す場合、assertの部分はrslt.first()で取り出す必要があります
assertThat(rslt.first().size).isEqualTo(2)
roomのテストケースはInstrument testで作ります。
具体例、ViewModelのテストケース
ここから徐々に難しくなってきます。
ViewMocdelはAAC(Androidアーキテクチャコンポーネント)に準じていればRpositioryクラスに依存しています。そしてコンストラクタの引数にRpositioryクラスのインスタンスを持っています。そして、大抵の場合、中で非同期でDBのCRUD、HTTP通信を行います。Rpositioryクラスはmock化済のインスタンスをViewModelに渡してやる必要があります。
viewModelScope.launch(Dispatchers.IO) {
databaseRepository.insert(entity)
// あるいはHTTP通信など・・・
}
ViewModelのクラスの中で「Dispatchers.IO」が直書きされていると、テスト的には非常に具合が悪いです。(テストケースのTestDispacherとViewModelのDispacherが別なのですれ違う)
ここはテストケースからTestDispacherをViewModelのクラスに渡してやるように修正が必要になります。
class MyViewModel(
private val databaseRepository: DatabaseRepository,
private val dispatcher: CoroutineDispatcher = Dispatchers.IO): ViewModel() {
・・・
@WorkerThread
fun insertTest(entity: MyEntity) {
viewModelScope.launch(dispatcher) {
databaseRepository.insert(entity)
}
}
}
のようにViewModelのコンストラクタの引数にdispatcherを追加してデフォルトをDispatchers.IOを指定します。こうすることにより、JUnitテストの時はTestDispacherを渡し、プロダクションの場合はDispatchers.IOで動きます。
ViewModelのテストメソッドもViewModelのviewModelScopeをテストするのでrunTestで囲ってやる必要があります。
@MockK
private lateinit var databaseRepository: DatabaseRepository
@Test
fun deleteTest() = runTest(UnconfinedTestDispatcher()) {
// Mock
val dispacher = UnconfinedTestDispatcher(testScheduler)
val viewModel = MyViewModel(databaseRepository, dispacher)
// 実行
viewModel.deleteByXXXX(1)
advanceUntilIdle()
// assert
}
Repositryについてはmock済のインスタンスをViewModelのコンストラクタに渡してやります。
UnconfinedTestDispatcher()とadvanceUntilIdle()についてはいつもお世話になっているここを参考にしてください。
ViewModelでObserveパターンでroomからSELECTした結果を取ってくるようなテストはひと捻りが必要です。プロダクションコードでは
fun selectHogehoge(): LiveData<List<MyEntity>> = ・・・
のような場合、Activity、Fragment側で
viewModel.selectHogehoge().observe(viewLifecycleOwner) { entityList ->
・・・
}
になりますが、テストケースの中でviewLifecycleOwnerは使えません。
テストケースの中ではobserve → observeForever を使います。
@Test
fun selectTest() = runTest() {
// Mock
val vmdispacher = UnconfinedTestDispatcher(testScheduler)
val viewModel = MyViewModel(databaseRepository, vmdispacher)
val dispatcher = UnconfinedTestDispatcher()
Dispatchers.setMain(dispatcher)
val testflow = flowOf (listOf( ・・・ ))
every { databaseRepository.selectHogeHoge(any()) } returns testflow
// 実行
advanceUntilIdle()
// assert
viewModel.selectHogeHoge().observeForever {
assertThat(it).isEqualTo(expectedList)
}
Dispatchers.resetMain()
}
Observeパターンの実行部分を
Dispatchers.setMain(dispatcher)
Dispatchers.resetMain()
で囲ってやる必要があります。
具体例、RobolectricでActivityをテストする場合
今のところテストライブラリを全て使いこなしているわけではないので、Robolectricだけ説明します。build.gradleに依存ライブラリを追加します。
android {
・・・
testOptions {
unitTests {
includeAndroidResources = true // robolectric
}
}
}
dependencies {
・・・
testImplementation 'androidx.test.ext:junit-ktx:1.1.5'
testImplementation 'junit:junit:4.13.2'
testImplementation 'io.mockk:mockk-android:1.13.3'
testImplementation 'io.mockk:mockk-agent:1.13.3'
testImplementation 'com.google.truth:truth:1.1.4' // truth
testImplementation 'org.jetbrains.kotlinx:kotlinx-coroutines-test:1.7.1'
testImplementation 'androidx.arch.core:core-testing:2.2.0'
}
mockkとtruthも追加します。
testOptionsも必要、これがないとRobolectricは動かない。
テストケースの書き方ですが、これも書き方が色々あって、
@RunWith(AndroidJUnit4::class)
//@RunWith(RobolectricTestRunner::class)
class MainActivityTest {
@Test
fun buttonTest() {
val activity = Robolectric.setupActivity(MainActivity::class.java)
val button = activity.findViewById<Button>(R.id.button)
・・・
}
}
@RunWithは
@RunWith(AndroidJUnit4::class)
@RunWith(RobolectricTestRunner::class)
どちらでも動きます。AndroidJUnit4だとInstrument testからも呼び出すことができますので(一緒に実行する)、こちらの方がベターだと思います。
上の書き方は古いかきかたでdeprecatedになっています。ActivityのlifeCycleを制御できません。新しい書き方は、
@Test
fun buttonTest() {
Robolectric.buildActivity(MainActivity::class.java).use { controller ->
val activity: MainActivity = controller
.setup()
// .pause() // ActivityのLifeCycleを制御できる
.start()
.get()
val button = activity.findViewById<Button>(R.id.button)
・・・
}
}
pause()、start()等でActivityのlifeCycleを制御できます。
具体例、RobolectricでFragmentをテストする場合
Fragmentをテストする場合はActivityと違ってFragmentScenarioを使います。
build.gradleの依存ライブラリに以下を追加します。
dependencies {
debugImplementation 'androidx.fragment:fragment-testing:1.5.7'
}
テストコードは以下の様になります。
@Test
fun mainTest001() {
val scenario = launchFragmentInContainer<MyFragment>()
scenario.onFragment { fragment ->
val button = fragment.view?.findViewById<Button>(R.id.myButton)
// assert
assertThat(・・・)
}
}
launchFragmentInContainerでFragmentScenarioのインスタンスを取得して、scenario.onFragmentでFragmentのインスタンスを取得します。
具体例、Activity、FragmentでViewModelをmock化する
さて、以上これだけで全てのテストができるわけではありません。まだまだ、道半ばです。AAC(Androidアーキテクチャコンポーネント)に準じていればActivity、FragmentはViewModelに依存しています。Activity、Fragmentの処理だけに閉じてテストしたいのであれば、ViewModelをmock化する必要があります。
@RunWith(AndroidJUnit4::class)
class MyFragmentTest {
@get:Rule
val mockkRule = MockKRule(this)
@MockK
private lateinit var mockMyViewModel: MyViewModel
@Before
fun setUp() {
MockKAnnotations.init(this)
}
@Test
fun mainTest001() {
// Mock
every { mockMyViewModel.getHogeHoge() } returns Unit
mockkConstructor(ViewModelProvider::class)
every { anyConstructed<ViewModelProvider>().get(MyViewModel::class.java) } returns mockMyViewModel
// 実行
val scenario = launchFragmentInContainer <MyFragment>()
scenario.onFragment { fragment ->
val button = fragment.view?.findViewById<Button>(R.id.myButton)
// assert
assertThat(・・・)
}
}
}
ミソはMyViewModelをmock化。
ViewModelProviderのコンストラクタをmock化してgetメソッドの戻り値にMyViewModelをmock化したインスタンスを返すことです。
具体例、Activity、FragmentでActionbarのmenuを使っているとテストが落ちる
ここでひとつ困った問題が。Activity、FragmentでActionbarのmenuを使っています。
private fun setupMenuBar() {
addMenuProvider(object : MenuProvider {
override fun onCreateMenu(menu: Menu, menuInflater: MenuInflater) {
menuInflater.inflate(R.menu.actionbar_menu, menu)
}
override fun onMenuItemSelected(item: MenuItem): Boolean {
when (item.itemId) {
・・・
return true
}
})
}
fun setupMenuBar() {
val menuHost: MenuHost = requireActivity()
menuHost.addMenuProvider(object : MenuProvider {
override fun onCreateMenu(menu: Menu, menuInflater: MenuInflater) {
}
override fun onPrepareMenu(menu: Menu) {
super.onPrepareMenu(menu)
menu.findItem(R.id.myIcon).isVisible = true // ★
}
override fun onMenuItemSelected(menu: MenuItem): Boolean {
when (menu.itemId) {
・・・
}
return true
}
}, viewLifecycleOwner, Lifecycle.State.RESUMED)
}
のような場合で、FragmentScenarioを使ったテストはFragmentの// ★の部分でnullポで落ちます。恐らく、FragmentScenarioだとActivityでのmenuのinflateがされないでFragmentのインスタンスを取得するので、menu.findItem(R.id.myIcon)はnullが返ります。nullのisVisibleでnullポになります。
setupMenuBar()はonViewCreated()から必ず呼ばれるので全てのテストが失敗します。
FragmentScenarioのlaunchFragmentInContainerの引数にFragmentFactoryの引数があるのでFragmentFactoryでFragmentのsetupMenuBar()関数だけspykしたインスタンスを返せばいいんじゃないか?と思ってやってみましたがダメでした。
結局、クラスと関数をopenにしてoverride可能にし、
open class MyFragment : Fragment() {
・・・
open fun setupMenuBar() {
・・・
}
}
テストコード側は
@RunWith(AndroidJUnit4::class)
class MyFragmentTest {
@Test
fun mainTest001() {
val scenario = launchFragmentInContainer<MyFragment4Test>()
scenario.onFragment { fragment ->
val button = fragment.view?.findViewById<Button>(R.id.myButton)
// assert
assertThat(・・・)
}
}
}
class MyFragment4Test: MyFragment() {
override fun setupMenuBar() {
}
}
という苦肉の策を取りました。が、テストのためにプロダクションコードを書き換える(openにする)のは本末転倒なので、極力避けたいところですが他に方法がありませんでした。
最後に
一旦は、今回はここで閉じますが、今後も色々と変化球的な具体例をご披露したいと思います。
この辺のノウハウ等を共有している方がいれば、ご意見等を伺いたいですので、よろしくお願いします。
AndroidのJUnitテストは超ムズいぞ!