前書き
本記事では筆者が実際に行なったテスト方法とその際に直面した問題、その解決方法を記載しています。
問題の原因ははっきりと特定できておらず、その解決方法も正攻法である保証はありません。
もし本記事に記載している問題についてご存知の方がいらっしゃれば、大変恐縮ではございますがアドバイスいただきたいと思っております。
背景
データの状態によってViewのVisibilityを変更させるような画面を作りたいと思っていました。
設計を少し具体化すると、UseCaseの保持するLiveData<List<String>?>
をsourceとして、ViewModelにてTransformations.map()でLiveData<Int>
に変換し、そのViewModelをFragmentのxmlに対してDataBindingでバインドするような作りのアプリです。
このアプリのViewModelのテストを実装していた際に問題に直面しました。
簡易的なコードを下記に示します。
interface Repository {
fun load(): Single<List<String>?>
}
class UseCase(private val repository: Repository) {
private list: MutableLiveData<List<String>?> = MutableLiveData()
init {
list.value = null
}
fun getList(): LiveData<List<String>?> = list
fun load() {
repository
.load()
.subscribe { response ->
list.postValue(response)
}
}
}
class ViewModel(private val useCase: UseCase) : ViewModel() {
val visibility: LiveData<Int> = Transformations.map(useCase.getList()) { list ->
when {
list.isNullOrEmpty() -> View.GONE
else -> View.VISIBLE
}
}
fun load() = useCase.load()
}
直面した問題
Transformations.map()に変更が流れてこない
ViewModelのテストを下記のように書いた際、UseCaseの値の変更がViewModel側に流れていないような結果になりました。
クラスのモックにはmockkを使用しました。
class ViewModelTest {
@get:Rule
val rule: TestRule = InstantTaskExecutorRule()
fun `UseCase#listに要素ありのリストが入れられた際に、visibilityがVISIBLEになること`() {
val repository = mockk<Repository>()
every { repository.load() } returns Single.create { it.onSuccess(listOf("hoge", "fuga")) }
val useCase = UseCase(repository)
val viewModel = ViewModel(useCase)
viewModel.load()
assertEquals(true, useCase.getList().value?.isEmpty())
assertEquals(View.VISIBLE, viewModel.visibility.value)
}
}
このテストを実行した結果は expected: 0 actual: null
でした。
一つ目のアサーションは問題なく通過しているため、モックした通りに値がSingleに流れてUseCase#list
に値が反映されていることが分かります。
したがって、この変更をソースとしてTransformations.map()でViewModel#visibility
も変換されて欲しいのですが、そうはなっていないようです。
何故UseCaseのLiveDataには値が反映され、ViewModelのLiveDataには値が反映されていないのか、原因ははっきりしていませんが恐らくpostValue()による非同期な値の変更が影響しているのではないかと考え、別のテスト方法を考えました。
LiveDataの変更が複数回通知される
一つ前の問題の回避策として、今度はobserveForeverを用いてViewModel#visibility
を監視することで、値の変更をテストすることにしてみました。
class ViewModelTest {
@get:Rule
val rule: TestRule = InstantTaskExecutorRule()
fun `UseCase#listに要素ありのリストが入れられた際に、visibilityがVISIBLEになること`() {
val repository = mockk<Repository>()
every { repository.load() } returns Single.create { it.onSuccess(listOf("hoge", "fuga")) }
val useCase = UseCase(repository)
val viewModel = ViewModel(useCase)
viewModel.visibility.observeForever { visibility ->
assertEquals(View.VISIBLE, visibility)
}
viewModel.load()
assertEquals(true, useCase.getList().value?.isEmpty())
// assertEquals(View.VISIBLE, viewModel.visibility.value)
}
このテストの実行結果は expected 0 actual: 8
でした。
どうやら今回の方法ではきちんと値の変換が行われているようです。しかし予期した変換ではありません。
調査したところ、observeForeverのラムダが複数回実行されていることが分かりました。
恐らくUseCaseのinitで与えた初期値で1回、load()で取得した値によるpostValue()で2回です。
回避策として泥臭いやりかたですが、呼び出し回数をカウントするローカル変数を用意してその数によってアサーションを呼ぶかどうかを判定するようにしました。
class ViewModelTest {
@get:Rule
val rule: TestRule = InstantTaskExecutorRule()
fun `UseCase#listに要素ありのリストが入れられた際に、visibilityがVISIBLEになること`() {
val repository = mockk<Repository>()
every { repository.load() } returns Single.create { it.onSuccess(listOf("hoge", "fuga")) }
val useCase = UseCase(repository)
val viewModel = ViewModel(useCase)
var count = 0
viewModel.visibility.observeForever { visibility ->
if (count == 1) {
assertEquals(View.VISIBLE, visibility)
}
count++
}
viewModel.load()
assertEquals(true, useCase.getList().value?.isEmpty())
// assertEquals(View.VISIBLE, viewModel.visibility.value)
}
これで無事にテストが通りました。
まとめ
- LiveDataをTransformations.map()で変換するテストを実施。
- LiveData.valueでは値の反映・変換は行われなかった。
- observeForeverを用いて変更を監視するように修正。
- observeForeverは複数回呼び出されるため呼び出し回数をカウントして制御。
ご精読ありがとうございました。
アドバイス・ご指摘等お待ちしております。