[Kotlin]Retrofitを利用したViewModelをJUnitでテストコードを書く

前回の記事では、Retrofitを利用して楽天APIを叩く実装について紹介しました。

https://qiita.com/highcom/items/47fd2e98422d71ef02a2

そこで、今回はRetrofitを利用した実装のViewModel部分に対して、JUnitを利用したテストコードの書き方について紹介していきたいと思います。

# 解説するコードの全容
今回解説するコードは以下のAndroidプロジェクトになります。

https://github.com/highcom/ComicMemo

その中で、テスト対象となるファイルとテストコードは以下。
#### テスト対象
- [RakutenBookViewModel.kt](https://github.com/highcom/ComicMemo/blob/master/app/src/main/java/com/highcom/comicmemo/viewmodel/RakutenBookViewModel.kt)
#### テストコード
- [RakutenBookViewModelUnitTest.kt](https://github.com/highcom/ComicMemo/blob/master/app/src/test/java/com/highcom/comicmemo/viewmodel/RakutenBookViewModelUnitTest.kt)
- [MockRakutenApiService.kt](https://github.com/highcom/ComicMemo/blob/master/app/src/test/java/com/highcom/comicmemo/viewmodel/MockRakutenApiService.kt)

# 必要なライブラリのimplementation
build.gradleのdependenciesに以下のimplementationを追加します。
RetrofitをMock化するためにretrofit-mockライブラリを利用します。
```build.gradle
dependencies {
    testImplementation 'junit:junit:4.13.2'
    testImplementation 'androidx.test:core:1.5.0'
    testImplementation 'org.mockito:mockito-core:2.19.0'
    testImplementation 'androidx.arch.core:core-testing:2.2.0'
    testImplementation 'org.jetbrains.kotlinx:kotlinx-coroutines-test:1.6.4'
    testImplementation 'io.mockk:mockk:1.10.5'
    testImplementation "com.squareup.retrofit2:retrofit-mock:$retrofitVersion"
}
```

# Retrofit部分をMock化する
まず最初に、JUnitでテストを実行できるようにするために、`RakutenApiService`クラスがRetrofitを利用した通信処理部分になるため、そこをMockに差し替えられるように`MockRakutenApiService`を用意します。
![クラス図_RakutenBookViewModel.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/545703/c02b3fc8-e836-9146-e35b-b1e1f54938ed.png)
```RakutenApiService.kt
private const val baseApiUrl = "https://app.rakuten.co.jp/services/api/"

private val httpLogging = HttpLoggingInterceptor().setLevel(HttpLoggingInterceptor.Level.BODY)
private val httpClientBuilder = OkHttpClient.Builder().addInterceptor(httpLogging)

private val retrofit = Retrofit.Builder()
    .addConverterFactory(GsonConverterFactory.create())
    .baseUrl(baseApiUrl)
    .client(httpClientBuilder.build())
    .build()

/**
 * 楽天APIサービスインターフェース
 */
interface RakutenApiService {
    @GET("BooksBook/Search/20170404?format=json&sort=sales&hits=30")
    fun salesItems(@Query("booksGenreId") genreId: String, @Query("page") page: String, @Query("applicationId") appId: String): retrofit2.Call<RakutenBookData>
}

/**
 * 楽天API
 */
object RakutenApi {
    val retrofitService: RakutenApiService by lazy { retrofit.create(RakutenApiService::class.java)}
}
```
上記クラスを継承したMockクラスを以下のように実装します。
```MockRakutenApiService.kt
import retrofit2.mock.BehaviorDelegate

/**
 * 楽天APIサービスのモッククラス
 *
 * @property delegate
 */
class MockRakutenApiService(private val delegate: BehaviorDelegate<RakutenApiService>) : RakutenApiService {
    override fun salesItems(page: String, appId: String): Call<RakutenBookData> {
        val data = RakutenBookData(
            // 説明のため、ここは省略
        )
        return delegate.returningResponse(data).salesItems(page, appId)
    }
```
引数で渡される`BehaviorDelegate`を利用して最後に
`delegate.returningResponse(data).salesItems(page, appId)`
をreturnすることで通信部分を省略して応答を返せるようになっています。

# テスト対象のRakutenBookViewModel
```RakutenBookViewModel.kt
/**
 * 楽天書籍検索ViewModel
 *
 * @property rakutenApiService 楽天APIサービスIF
 * @property appId 楽天API利用アプリケーションID
 */
class RakutenBookViewModel(private val rakutenApiService: RakutenApiService, private val appId: String) : ViewModel() {

```
`RakutenBookViewModel`は引数に`RakutenApiService`を受け取るようにしているため、テスト時には`MockRakutenApiService`を受け取るようにします。
```RakutenBookViewModel.kt
    /** 楽天APIのステータスを保持する内部変数 */
    private val _status = MutableLiveData<RakutenApiStatus>()
    /** 楽天APIのステータス */
    val status: LiveData<RakutenApiStatus>
        get() = _status
```
また、上記のようにViewModelを利用する側にはLiveDataをobserveしてもらうための`status`を定義しています。
```RakutenBookViewModel.kt
    /**
     * 楽天APIを利用した書籍データ取得処理
     *
     * @param id 楽天API検索ジャンルID
     */
    fun getSalesList(id: String = genreId) {
        // 説明のため、途中を省略
        viewModelScope.launch {
            _status.value = RakutenApiStatus.LOADING
            rakutenApiService.salesItems(genreId, page.toString(), appId).enqueue(object : retrofit2.Callback<RakutenBookData> {
                override fun onFailure(call: retrofit2.Call<RakutenBookData>?, t: Throwable?) {
                    _status.value = RakutenApiStatus.ERROR
                }

                override fun onResponse(call: retrofit2.Call<RakutenBookData>?, response: retrofit2.Response<RakutenBookData>) {
                    if (response.isSuccessful) {
                        response.body()?.let {
                            _status.value = RakutenApiStatus.DONE
                        }
                    }
                }
            })
        }
    }
```
`RakutenApiService`の`salesItems`を呼び出した部分の処理は以下。`_status`を更新する事で通信が成功したかどうかを通知します。

# RetrofitMockを利用したテストコード
```RakutenBookViewModelUnitTest.kt
    // LiveDataをテストするための設定
    // java.lang.RuntimeException: Method getMainLooper in android.os.Looper not mocked. 対策で必要
    @get:Rule
    val instantTaskExecutorRule: InstantTaskExecutorRule = InstantTaskExecutorRule()
```
`RakutenBookViewModelUnitTest`では、ViewModelをテストする場合に`RuntimeException`が発生するのを防ぐために上記の定義をします。
```RakutenBookViewModelUnitTest.kt
    private lateinit var behavior: NetworkBehavior
    private lateinit var mockRakutenApiService: MockRakutenApiService

    @ExperimentalCoroutinesApi
    @Before
    fun setUp() {
        // viewModelScopeを利用するための設定
        // Exception in thread "main" java.lang.IllegalStateException 対策で必要
        Dispatchers.setMain(Dispatchers.Unconfined)
```
次に、テストのセットアップ時にviewModelScopeの`IllegalStateException`を防ぐために上記のようにディスパッチャーの設定をしておきます。

```RakutenBookViewModelUnitTest.kt
        // Retrofitのモックライブラリを作成
        val httpLogging = HttpLoggingInterceptor().setLevel(HttpLoggingInterceptor.Level.BODY)
        val httpClientBuilder = OkHttpClient.Builder().addInterceptor(httpLogging)
        val retrofit = Retrofit.Builder()
            .addConverterFactory(GsonConverterFactory.create())
            .baseUrl("https://mock.com")
            .client(httpClientBuilder.build())
            .build()

        behavior = NetworkBehavior.create()

        val mockRetrofit = MockRetrofit.Builder(retrofit)
            .networkBehavior(behavior)
            .build()
        val delegate = mockRetrofit.create(RakutenApiService::class.java)
        mockRakutenApiService = MockRakutenApiService(delegate)
    }
```
続けて、上記のように`NetworkBehavior`を利用して`MockRetrofit`を生成し、それを`MockRakutenApiService`の引数に渡すことで、Retrofitの挙動をMock化できます。
そして最後に、
```RakutenBookViewModelUnitTest.kt
    /**
     * 楽天APIを利用した書籍データ取得処理の初回呼び出し成功テスト
     *
     */
    @Test
    fun getSalesList_success() {
        behavior.apply {
            setDelay(0, TimeUnit.MILLISECONDS) // 即座に結果が返ってくるようにする
            setVariancePercent(0)
            setFailurePercent(0)
            setErrorPercent(0)
        }
        val target = RakutenBookViewModel.Factory(mockRakutenApiService, "123").create(RakutenBookViewModel::class.java)
        val mockObserver = spyk<Observer<List<Item>?>>()
        target.bookList.observeForever(mockObserver)

        val result = mutableListOf<Item>()
        result.add(Item(
            // 説明のため、途中を省略
        ))

        // ここがテスト対象
        target.getSalesList()
        // LiveDataの値の変更が通知されるまで待つ
        Thread.sleep(1000)
        // 変化通知が呼び出されている事を確認
        verify(exactly = 1) {
            mockObserver.onChanged(result)
        }

        assertEquals(RakutenApiStatus.DONE, target.status.value)
    }
```
`getSalesList`のテストが上記部分。
テストメソッドの最初では`behavior.apply`で擬似ネットワーク通信の部分を設定しています。
`val target = RakutenBookViewModel.Factory(mockRakutenApiService, "123").create(RakutenBookViewModel::class.java)`
ここで、セットアップ時に生成したMockを渡す事で、Retrofit部分をMock化したViewModelが生成されます。
`target.getSalesList()`を呼び出すことで`statusが変化するので、最後に
`assertEquals(RakutenApiStatus.DONE, target.status.value)`
で更新されたことを確認します。

# さいごに
ここまで読んで頂きありがとうございました！
冒頭でリンクを貼った通り、AndroidプロジェクトはGitHubで公開していますので、上記の解説を読みつつ全体のソースコードを参照してもらうとより理解が深められると思います。
また、ソースコード上にはコメントなどもなるべく記載しているので、分かりやすくなっている(はず！)と思います。
せっかくなので、アプリの方もリンクを貼っておきますので、動きを確認するのにも使えると思います！

https://play.google.com/store/apps/details?id=com.highcom.comicmemo