5
5

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 5 years have passed since last update.

Retrofit + LiveData のテストコードを実装してみた

5
Posted at

以前、Android Jetpack をフル活用して、Qiita の LGTM 数ゼロの記事だけ検索できるアプリを作ったという記事を執筆しました。
その際、下記のようなArticleListViewModelを実装しました。

ArticleListViewModel.kt
/**
 * 記事表示用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に下記を追記してください。

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のモックとして下記のクラスを実装します。

MockSearchService.kt
/**
 * 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.

以下のルールを設定します。

ArticleListViewModelTest.kt
// LiveDataをテストするために必要
@get:Rule
val instantTaskExecutorRule = InstantTaskExecutorRule()

ViewModel のインスタンスを生成するための準備を行う

下記のように ViewModel のインスタンスを生成するための準備をします。

ArticleListViewModelTest.kt
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.BuilderBehaviorDelegateのインスタンスを生成します。

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 に置き換えます。

ArticleListViewModelTest.kt
@ExperimentalCoroutinesApi
class ArticleListViewModelTest {
    ...
    @Before
    fun setUp() {
        Dispatchers.setMain(Dispatchers.Unconfined)
    }

    @After
    fun tearDown() {
        Dispatchers.resetMain()
    }
    ...
}

LiveData の値を取得するための準備を行う

以下のようにテストコード上で直接 LiveData の値を取得しようとすると、値の取得が間に合わず null となり失敗します。

ArticleListViewModelTest.kt
viewModel.search(1, 1, "")
assertThat(viewModel.articleList.value?.isSuccess).isTrue() // isSuccessがnullとなる

architecture-components-samplesLiveDataTestUtil.javaという LiveData を取得するためのクラスがあるのでコピーします。

LiveDataTestUtil.kt
/**
 * 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 の記事が存在するケースを実装します。

ArticleListViewModelTest.kt
@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を設定します。

ArticleListViewModelTest.kt
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 にしてください。

次に適当なダミーデータを用意してレスポンスに設定し、処理を実行します。

ArticleListViewModelTest.kt
val articleList = listOf(
    Article("", "", 0, "", User("", "", ""))
)
searchService.response = articleList
viewModel.search(1, 1, "")

最後にLiveDataTestUtilで LiveData の値を取得し、各種チェックを行います。

ArticleListViewModelTest.kt
val result = LiveDataTestUtil.getValue(viewModel.articleList)

// 成功扱いか
assertThat(result?.isSuccess).isTrue()

// データが存在するか
assertThat(result?.getOrNull()).isNotNull()

// データが1件以上存在するか
assertThat(result?.getOrNull()).isNotEmpty()

あとは同じ要領で他のテストケースを実装していくだけです。

まとめ

Retrofit + LiveData のテストコードを実装しました。
Retrofit のモックライブラリでさまざまな振る舞いができるのは便利だと感じました。
色々ハマりポイントがあったので、似たような境遇の方の助けになると嬉しいです。

参考 URL

ソースコード

hiesiea/Qiita0LgtmViewer

5
5
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
5
5

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?