LoginSignup
1
4

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

Posted at

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

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

解説するコードの全容

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

その中で、テスト対象となるファイルとテストコードは以下。

テスト対象

テストコード

必要なライブラリの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

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
                        }
                    }
                }
            })
        }
    }

RakutenApiServicesalesItemsを呼び出した部分の処理は以下。_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で公開していますので、上記の解説を読みつつ全体のソースコードを参照してもらうとより理解が深められると思います。
また、ソースコード上にはコメントなどもなるべく記載しているので、分かりやすくなっている(はず!)と思います。
せっかくなので、アプリの方もリンクを貼っておきますので、動きを確認するのにも使えると思います!

1
4
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
1
4