Retrofit + LiveData のテストコードを実装してみた
以前、Android Jetpack をフル活用して、Qiita の LGTM 数ゼロの記事だけ検索できるアプリを作ったという記事を執筆しました。
その際、下記のようなArticleListViewModel
を実装しました。
/**
* 記事表示用ViewModel
*/
class ArticleListViewModel @ViewModelInject constructor(
private val searchRepository: SearchRepository
) : ViewModel() {
// 記事一覧(読み書き用)
// MutableLiveDataだと受け取った側でも値を操作できてしまうので、読み取り用のLiveDataも用意しておく
private val _articleList = MutableLiveData<Result<List<Article>>>()
val articleList: LiveData<Result<List<Article>>> = _articleList
/**
* 検索処理
* @param page ページ番号 (1から100まで)
* @param perPage 1ページあたりに含まれる要素数 (1から100まで)
* @param query 検索クエリ
*/
fun search(page: Int, perPage: Int, query: String) = viewModelScope.launch {
try {
Timber.d("search page=$page, perPage=$perPage, query=$query")
val response = searchRepository.search(page.toString(), perPage.toString(), query)
// Responseに失敗しても何かしら返す
val result = if (response.isSuccessful) {
response.body() ?: mutableListOf()
} else {
mutableListOf()
}
// LGTM数0の記事だけに絞る
val filteredResult = result.filter {
it.likes_count == 0
}
// viewModelScopeはメインスレッドなので、setValueで値をセットする
_articleList.value = Result.success(filteredResult)
} catch (e: CancellationException) {
// キャンセルの場合は何もしない
} catch (e: Throwable) {
_articleList.value = Result.failure(e)
}
}
}
ArticleListViewModel
では LiveData を使用しています。
また、SearchRepository
(実質SearchService
のラッパークラス)では Retrofit を使用しています。
そのため、本記事では Retrofit + LiveData のテストコードを実装してみます。
テスト用ライブラリの準備
app/build.gradle
に下記を追記してください。
dependencies {
testImplementation "com.google.truth:truth:1.1"
testImplementation "androidx.arch.core:core-testing:2.1.0"
...
// Retrofit
def retrofit_version = "2.9.0"
testImplementation "com.squareup.retrofit2:retrofit-mock:$retrofit_version"
...
// coroutines
def coroutines_version = "1.4.2"
testImplementation "org.jetbrains.kotlinx:kotlinx-coroutines-test:$coroutines_version"
}
Retrofit のモッククラスを実装する
SearchService
のモックとして下記のクラスを実装します。
/**
* SearchServiceのモック
*/
class MockSearchService(
private val delegate: BehaviorDelegate<SearchService>,
) : SearchService {
var response: List<Article>? = null
override suspend fun search(
page: String,
perPage: String,
query: String
): Response<List<Article>> {
return delegate.returningResponse(response).search(page, perPage, query)
}
}
BehaviorDelegate#returningResponse
で引数に設定されたresponse
を返却するようにしています。
テストコードを実装する
LiveData をテストするための準備を行う
LiveData をそのままテストしようとすると、下記のエラーが出力されます。
Exception in thread "pool-1-thread-1 @coroutine#1" java.lang.RuntimeException: Method getMainLooper in android.os.Looper not mocked. See http://g.co/androidstudio/not-mocked for details.
以下のルールを設定します。
// LiveDataをテストするために必要
@get:Rule
val instantTaskExecutorRule = InstantTaskExecutorRule()
ViewModel のインスタンスを生成するための準備を行う
下記のように ViewModel のインスタンスを生成するための準備をします。
private val retrofit = Retrofit.Builder()
.baseUrl("https://qiita.com")
.addConverterFactory(MoshiConverterFactory.create())
.build()
private val behavior = NetworkBehavior.create()
private val delegate = MockRetrofit.Builder(retrofit).networkBehavior(behavior).build()
.create(SearchService::class.java)
private val searchService = MockSearchService(delegate)
private val viewModel = ArticleListViewModel(SearchRepository(searchService))
NetworkBehavior.create()
でネットワークの振る舞いを擬似的に再現するための設定を行えるようにします。
MockRetrofit.Builder
でBehaviorDelegate
のインスタンスを生成します。
Dispatcher の置き換えを行う
viewModelScope
はメインスレッドですが、そのままテストを実行すると下記のエラーが出力されます。
java.lang.IllegalStateException: Module with the Main dispatcher had failed to initialize. For tests Dispatchers.setMain from kotlinx-coroutines-test module can be used
エラーの通り、Dispatchers.setMain
を使って別の Dispatcher に置き換えます。
@ExperimentalCoroutinesApi
class ArticleListViewModelTest {
...
@Before
fun setUp() {
Dispatchers.setMain(Dispatchers.Unconfined)
}
@After
fun tearDown() {
Dispatchers.resetMain()
}
...
}
LiveData の値を取得するための準備を行う
以下のようにテストコード上で直接 LiveData の値を取得しようとすると、値の取得が間に合わず null となり失敗します。
viewModel.search(1, 1, "")
assertThat(viewModel.articleList.value?.isSuccess).isTrue() // isSuccessがnullとなる
architecture-components-samplesにLiveDataTestUtil.java
という LiveData を取得するためのクラスがあるのでコピーします。
/**
* https://github.com/android/architecture-components-samples/blob/main/BasicSample/app/src/androidTest/java/com/example/android/persistence/LiveDataTestUtil.java
*/
object LiveDataTestUtil {
/**
* Get the value from a LiveData object. We're waiting for LiveData to emit, for 2 seconds.
* Once we got a notification via onChanged, we stop observing.
*/
@Throws(InterruptedException::class)
fun <T> getValue(liveData: LiveData<T>): T? {
val data = arrayOfNulls<Any>(1)
val latch = CountDownLatch(1)
val observer = object : Observer<T> {
override fun onChanged(@Nullable o: T) {
data[0] = o
latch.countDown()
liveData.removeObserver(this)
}
}
liveData.observeForever(observer)
latch.await(2, TimeUnit.SECONDS)
return data[0] as T?
}
}
これでテストコードを実装するための準備が整いました。
具体的なテストケースを実装する
例えば 0LGTM の記事が存在するケースを実装します。
@Test
fun search_0LGTMの記事あり() {
behavior.apply {
setDelay(0, TimeUnit.MILLISECONDS) // 即座に結果が返ってくるようにする
setVariancePercent(0)
setFailurePercent(0)
setErrorPercent(0)
}
val articleList = listOf(
Article("", "", 0, "", User("", "", ""))
)
searchService.response = articleList
viewModel.search(1, 1, "")
val result = LiveDataTestUtil.getValue(viewModel.articleList)
// 成功扱いか
assertThat(result?.isSuccess).isTrue()
// データが存在するか
assertThat(result?.getOrNull()).isNotNull()
// データが1件以上存在するか
assertThat(result?.getOrNull()).isNotEmpty()
}
まずネットーワークの振る舞いを設定するため、behavior
を設定します。
behavior.apply {
setDelay(0, TimeUnit.MILLISECONDS) // 即座に結果が返ってくるようにする
setVariancePercent(0)
setFailurePercent(0)
setErrorPercent(0)
}
各設定項目の内容およびデフォルト値は以下の通りです。
設定項目 | 設定内容 | デフォルト値 |
---|---|---|
setDelay | 応答が受信されるまでにかかる時間 | 2000 ミリ秒 |
setVariancePercent | ネットワークの遅延が遅くなる確率 | ±40% |
setFailurePercent | ネットワーク障害(例外)が発生する確率 | 3% |
setErrorPercent | HTTP エラーが発生する確率 | 0% |
例えば例外を意図的に発生させたい場合はsetFailurePercent(100)
にし、HTTP エラーを意図的に発生させたい場合はsetErrorPercent(100)
にしてください。
必ず成功させたい場合はいずれも 0 にしてください。
次に適当なダミーデータを用意してレスポンスに設定し、処理を実行します。
val articleList = listOf(
Article("", "", 0, "", User("", "", ""))
)
searchService.response = articleList
viewModel.search(1, 1, "")
最後にLiveDataTestUtil
で LiveData の値を取得し、各種チェックを行います。
val result = LiveDataTestUtil.getValue(viewModel.articleList)
// 成功扱いか
assertThat(result?.isSuccess).isTrue()
// データが存在するか
assertThat(result?.getOrNull()).isNotNull()
// データが1件以上存在するか
assertThat(result?.getOrNull()).isNotEmpty()
あとは同じ要領で他のテストケースを実装していくだけです。
まとめ
Retrofit + LiveData のテストコードを実装しました。
Retrofit のモックライブラリでさまざまな振る舞いができるのは便利だと感じました。
色々ハマりポイントがあったので、似たような境遇の方の助けになると嬉しいです。
参考 URL
- Retrofit の API レスポンスをモッキング - Qiita
- Y.A.M の 雑記帳: LiveData を UnitTest でテストする
- Coroutines の Unit Test メモ. まだよく分かってないこと多いですが、試したことをメモしておきます。 | by Kenji Abe | Medium
- Room の更新監視の方法 4 種類を比較してみた - Qiita
Discussion