前回の記事では、Retrofitを利用して楽天APIを叩く実装について紹介しました。
そこで、今回はRetrofitを利用した実装のViewModel部分に対して、JUnitを利用したテストコードの書き方について紹介していきたいと思います。
解説するコードの全容
今回解説するコードは以下のAndroidプロジェクトになります。
その中で、テスト対象となるファイルとテストコードは以下。
テスト対象
テストコード
必要なライブラリのimplementation
build.gradleのdependenciesに以下のimplementationを追加します。
RetrofitをMock化するためにretrofit-mockライブラリを利用します。
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
を用意します。
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クラスを以下のように実装します。
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
/**
* 楽天書籍検索ViewModel
*
* @property rakutenApiService 楽天APIサービスIF
* @property appId 楽天API利用アプリケーションID
*/
class RakutenBookViewModel(private val rakutenApiService: RakutenApiService, private val appId: String) : ViewModel() {
RakutenBookViewModel
は引数にRakutenApiService
を受け取るようにしているため、テスト時にはMockRakutenApiService
を受け取るようにします。
/** 楽天APIのステータスを保持する内部変数 */
private val _status = MutableLiveData<RakutenApiStatus>()
/** 楽天APIのステータス */
val status: LiveData<RakutenApiStatus>
get() = _status
また、上記のようにViewModelを利用する側にはLiveDataをobserveしてもらうためのstatus
を定義しています。
/**
* 楽天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を利用したテストコード
// LiveDataをテストするための設定
// java.lang.RuntimeException: Method getMainLooper in android.os.Looper not mocked. 対策で必要
@get:Rule
val instantTaskExecutorRule: InstantTaskExecutorRule = InstantTaskExecutorRule()
RakutenBookViewModelUnitTest
では、ViewModelをテストする場合にRuntimeException
が発生するのを防ぐために上記の定義をします。
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
を防ぐために上記のようにディスパッチャーの設定をしておきます。
// 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化できます。
そして最後に、
/**
* 楽天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で公開していますので、上記の解説を読みつつ全体のソースコードを参照してもらうとより理解が深められると思います。
また、ソースコード上にはコメントなどもなるべく記載しているので、分かりやすくなっている(はず!)と思います。
せっかくなので、アプリの方もリンクを貼っておきますので、動きを確認するのにも使えると思います!