Androidアプリのオレ的テストコードの書き方
はじめに
Androidのテストコードを書いてきた経験をもとに、自分なりにこういうテストコードの書き方をすれば書きやすさやメンテナンス性を考慮したテストコードが書けるかなという視点で、自分のテストコードの書き方を振り返ってみたいと思います。
テスト用ライブラリの追加
Kotlin coroutineとFlowを使用したViewModelとRepositoryをテストしていくことを想定しています。
下記のテスト用ライブラリを追加します。
Junit4とmockK,Coroutinesテスト用のライブラリを導入します。
ViewModelはHiltを使用するなどしてコンストラクタで必要な引数を与えられるようになっていることを想定しています。
// build.gradle(app)
dependencies {
// Junit4
testImplementation("junit:junit:4.13.2")
// mockk
testImplementation("io.mockk:mockk:1.12.5")
// Coroutines Test
testImplementation("org.jetbrains.kotlinx:kotlinx-coroutines-test:1.6.4")
}
各ライブラリのバージョンは最新のもので問題ないはずです。
Repositoryのテスト
Repositoryのテストは、特に難しい箇所はないと思います。
APIのクラスをモックしてエラーハンドリングなどが動作するか確認していきます。
class LoadListTest : EnclosedMainRepositoryTestBase() {
@Test
fun `正常に値が返ってくる場合は取得した値を返す`() = runTest {
// Given
val listItems = (1..10).map { "test$it" }
coEvery { api.loadList() } returns listItems
val repository = MainRepository(api)
// When
val actual = repository.loadList().first()
// Then
assertEquals(listItems, actual)
}
@Test
fun `APIエラーになった場合はエラーを返す`() = runTest {
// Given
val exception = Exception("error")
coEvery { api.loadList() } throws exception
val repository = MainRepository(api)
// When
var caughtException: Throwable? = null
repository.loadList()
.catch { error ->
caughtException = error
}
.singleOrNull()
// Then
assertEquals(exception.message, caughtException?.message)
}
}
ViewModelのテスト
ViewModelのようにLiveDataやCoroutineを使用したメソッドのテストでは、実行順序が想定したように動かないといったことで詰まることが多いです。
そこで、conference-app-2020で採用されていた ViewModelTestRule のように
記述した順にメソッドが実行されるようにテストルールを作成して、テストコードで使用するようにしておくと
ViewModelのテストコードが書きやすくなります。
使用するには下記のようにして、テストコードでViewModelTestRuleをテストルールとして設定します。
class HogeHogeViewModelTest {
@get:Rule
val rule = ViewModelTestRule()
}
その他はRepositoryのときと同様にRepositoryのクラスをモックしてエラーハンドリングなどが動作するか確認していきます。
テストコードの保守性
テストメソッドの名前は自然言語でつけてしまう
テストコードが増えてくるとどのテスト条件のテストがどこにあるか探すのが難しくなってきます。
そのため通常のコードとは違いテストコードではメソッド名に自然言語でどのようなテストなのかを記述するようにします。
@Test
fun `正常に値が返ってくる場合は取得した値でUiStateに更新する`() = runTest {
}
Given-When-Then構文
テストコードが肥大化してくると、しばらくしてからテスト対象を修正した際にテストコードも修正が必要になってきます。
その際にGiven-When-Then構文を使用してテストコードを記述しておくことで、テストのインプットアウトプットがわかりやすくなり修正しやすくなります。
@Test
fun `正常に値が返ってくる場合は取得した値でUiStateに更新する`() = runTest {
val viewModel = MainViewModel(repository)
val results: MutableList<MainViewModel.UiState> = mutableListOf()
val job = launch(viewModelTestRule.coroutineRule.dispatcher) {
viewModel.uiState.toList(results)
}
// Given
val listItems = (1..10).map { "test$it" }
coEvery {
repository.loadList()
} returns flowOf(listItems)
// When
viewModel.loadList()
job.cancel()
// Then
assertEquals(
MainViewModel.UiState(
isLoading = false,
listItems = listItems,
isShowError = false
),
results.last()
)
}
テストコードを階層化する
同じメソッドに対して様々な条件でのテストを行うので、テストコードが肥大化してくるとどれがどのテストかわかりにくくなります。
そこでテストが大きくなってきたらテスト対象のメソッドごとに分離して、テストコードのメンテナンスを行いやすくしておきます。
Junit5だと標準でテストコードの階層化をサポートしているのですが、
Junit4はサポートしていないので、テストコードを階層化するにあたり下記のようにEnclosedを使用します。
また、Kotlinの場合にEnclosedを使用してテストコードを階層化するには、
抽象クラスを定義してそれを各メソッドをテストするクラスに継承させる必要があります。
こうすることでKotlinでもサブクラスにあるテストメソッドを認識させることができます。
package com.example.testcodesampleapp
import com.example.testcodesampleapp.testrule.MockkRule
import com.example.testcodesampleapp.testrule.ViewModelTestRule
import io.mockk.coEvery
import io.mockk.impl.annotations.MockK
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.flow
import kotlinx.coroutines.flow.flowOf
import kotlinx.coroutines.flow.toList
import kotlinx.coroutines.launch
import kotlinx.coroutines.test.advanceUntilIdle
import kotlinx.coroutines.test.runTest
import org.junit.Assert.assertEquals
import org.junit.Rule
import org.junit.Test
import org.junit.experimental.runners.Enclosed
import org.junit.runner.RunWith
import java.lang.Exception
@ExperimentalCoroutinesApi
@RunWith(Enclosed::class)
class MainViewModelTest {
abstract class EnclosedMainViewModelTestBase {
@get:Rule
val mockkRule = MockkRule(this)
@get:Rule
val viewModelTestRule = ViewModelTestRule()
@MockK
lateinit var repository: MainRepository
}
class LoadListTest : EnclosedMainViewModelTestBase() {
@Test
fun `正常に値が返ってくる場合は取得した値でUiStateに更新する`() = runTest {
val viewModel = MainViewModel(repository)
val results: MutableList<MainViewModel.UiState> = mutableListOf()
val job = launch(viewModelTestRule.coroutineRule.dispatcher) {
viewModel.uiState.toList(results)
}
// Given
val listItems = (1..10).map { "test$it" }
coEvery {
repository.loadList()
} returns flowOf(listItems)
// When
viewModel.loadList()
job.cancel()
// Then
assertEquals(
MainViewModel.UiState(
isLoading = false,
listItems = listItems,
isShowError = false
),
results.last()
)
}
@Test
fun `APIエラーになった場合はUiStateのisShowErrorをtrueに更新する`() = runTest {
val viewModel = MainViewModel(repository)
val results: MutableList<MainViewModel.UiState> = mutableListOf()
val job = launch(viewModelTestRule.coroutineRule.dispatcher) {
viewModel.uiState.toList(results)
}
// Given
coEvery {
repository.loadList()
} returns flow {
throw Exception("error")
}
// When
viewModel.loadList()
advanceUntilIdle()
job.cancel()
// Then
assertEquals(
MainViewModel.UiState(
isLoading = false,
listItems = listOf(),
isShowError = true
),
results.last()
)
}
}
class Method2Test : EnclosedMainViewModelTestBase() {
@Test
fun method2_givenAllNormalValues_returnSuccess() {
assertEquals(6, 3 + 3)
}
@Test
fun method2_givenAllNormalValues_returnError() {
assertEquals(4, 2 + 2)
}
}
}
まとめ
現状実際にテストを書いてみて自分がしっくり来たテストの書き方を書いてみました。
まだテストコードを書いていないという場合は、
まずはテストコードを動作確認のPlayGroundのようにして気軽に書いてみて慣れてみてることから始めてみましょう。
だんだんとテストコードの書き方がわかってくるとすらすら書けるようになるので、
その後はテストコードの品質というところでメンテナンスのしやすいテストという観点にも目を向けていけばいいと思います。
Discussion