在之前的範例,我們都是在Repository模擬呼叫WebAPI來取得資料,現在要實際接上一個WebAPI來看看應該怎麼測試。要測試有沒有真的呼叫到WebAPI,那這會是一個e2e的測試。這篇的重點將以單元測試的方式來介紹怎麼測試Repository。
我們使用Retrofit 加 RxJava 來處理呼叫Web API。
這段我們要來處理Repository呼叫API的測試
與上一篇MVVM使用相同的範例,我們要先把 Retrofit 加 RxJava 呼叫WebAPI 的功能實做出來。
build.gradle
dependencies {
//Rxjava
implementation 'io.reactivex.rxjava2:rxjava:2.1.10'
implementation 'io.reactivex.rxjava2:rxandroid:2.0.2'
//Http
implementation 'com.squareup.retrofit2:retrofit:2.4.0'
implementation 'com.squareup.retrofit2:converter-gson:2.4.0'
implementation 'com.jakewharton.retrofit:retrofit2-rxjava2-adapter:1.0.0'
implementation 'com.squareup.okhttp3:logging-interceptor:3.8.1’
}
新增 NetworkService
,在這裡實作呼叫WebAPI
1.ApiConfig定義API網址
2.NetworkService 實作Retrofit 從WebAPI取得資料
object ApiConfig {
const val WEB_HOST = ""
const val TIME_OUT_CONNECT = 30
const val TIME_OUT_READ = 30
const val TIME_OUT_WRITE = 30
const val productUrl = "https://firebasestorage.googleapis.com/v0/b/phoneauth-e70bb.appspot.com/o/product.json?alt=media&token=c051df05-399a-42af-b60f-b5430643d78e"
const val buyUrl = "https://firebasestorage.googleapis.com/v0/b/phoneauth-e70bb.appspot.com/o/buy.json?alt=media&token=cad7488d-e1d2-49a9-b881-abdde57cb5da"
}
class NetworkService(interceptor: Interceptor) {
var serviceAPI: ServiceApi
init {
val client = OkHttpClient.Builder()
.addInterceptor(interceptor)
.connectTimeout(ApiConfig.TIME_OUT_CONNECT.toLong(), TimeUnit.SECONDS)
.readTimeout(ApiConfig.TIME_OUT_READ.toLong(), TimeUnit.SECONDS)
.writeTimeout(ApiConfig.TIME_OUT_WRITE.toLong(), TimeUnit.SECONDS)
.build()
val retrofit = Retrofit.Builder()
.addCallAdapterFactory(RxJava2CallAdapterFactory.create())
.addConverterFactory(GsonConverterFactory.create())
.baseUrl(ApiConfig.WEB_HOST)
.client(client)
.build()
serviceAPI = retrofit.create(ServiceApi::class.java)
}
加入interface ServiceApi,在這裡定義呼叫的API網址與回傳格式。
interface ServiceApi {
@GET(ApiConfig.productUrl)
fun getProduct(): Single<Response<ProductResponse>>
@GET(ApiConfig.buyUrl)
fun buy(): Single<Response<BuyResponse>>
}
新增 BaseInterceptor
class BaseInterceptor : Interceptor {
@Throws(IOException::class)
override fun intercept(chain: Interceptor.Chain): Response {
val original = chain.request()
val request = original.newBuilder()
.method(original.method(), original.body())
.build()
return chain.proceed(request)
}
}
build.gradle 加上ProductFlavors ,區分mock與prod兩個版本。待會會說明功用。
android {
flavorDimensions "default"
productFlavors {
mock {
}
prod {
}
}
}
切換至Project,分別建立mock與prod的Package
在兩個ProductFlavor分別建立各自的ScheduleProvider
,待會在使用RxJava抓資料時,要讓測試的時候不是用非同步的方式。
Prod的ScheduleProvider
class SchedulerProvider {
companion object {
fun computation() = Schedulers.computation()
fun mainThread() = AndroidSchedulers.mainThread()!!
fun io() = Schedulers.io()
}
}
Mock的ScheduleProvider
class SchedulerProvider {
companion object {
fun computation() = Schedulers.trampoline()
fun mainThread() = Schedulers.trampoline()
fun io() = Schedulers.trampoline()
}
}
接著修改ProductRepository,調整為使用Retrofit與RxJava的方式。
interface IProductRepository {
fun getProduct(): Single<ProductResponse>
fun buy(id: String, items: Int): Single<Boolean>
}
class ProductRepository(private val serviceApi: ServiceApi) : IProductRepository {
override fun getProduct(): Single<ProductResponse> {
return serviceApi.getProduct()
.map {
it.body()
}
}
override fun buy(id: String, items: Int): Single<Boolean> {
val buyRequest = BuyRequest()
buyRequest.id = id
buyRequest.number = items
return serviceApi.buy()
.map {
it.body()
}.map(BuyResponse::result)
}
companion object {
private var INSTANCE: ProductRepository? = null
@JvmStatic fun getInstance(serviceApi: ServiceApi) =
INSTANCE ?: synchronized(ProductRepository::class.java) {
INSTANCE ?: ProductRepository(serviceApi)
.also { INSTANCE = it }
}
@JvmStatic fun destroyInstance() {
INSTANCE = null
}
}
}
ViewModel也需要修改,原本使用Callback的方式,都改為RxJava了
lass ProductViewModel(private val productRepository: IProductRepository) : ViewModel(){
var productId: MutableLiveData<String> = MutableLiveData()
var productName: MutableLiveData<String> = MutableLiveData()
var productDesc: MutableLiveData<String> = MutableLiveData()
var productPrice: MutableLiveData<Int> = MutableLiveData()
var productItems: MutableLiveData<String> = MutableLiveData()
var alertText: MutableLiveData<Event<String>> = MutableLiveData()
var buySuccessText: MutableLiveData<Event<String>> = MutableLiveData()
fun getProduct(productId: String) {
this.productId.value = productId
productRepository.getProduct()
.subscribeOn(SchedulerProvider.io())
.observeOn(SchedulerProvider.mainThread())
.subscribe({ data ->
productName.value = data.name
productDesc.value = data.desc
productPrice.value = data.price
},
{ throwable ->
println(throwable)
})
}
fun buy(){
val productId = productId.value ?: ""
val numbers = (productItems.value ?: "0").toInt()
productRepository.buy(productId, numbers)
.subscribeOn(SchedulerProvider.io())
.observeOn(SchedulerProvider.mainThread())
.subscribe({ data ->
if (data) { //購買成功
buySuccessText.value = Event("購買成功")
} else {
//購買失敗
alertText.value = Event("購買失敗")
}
},
{ throwable ->
println(throwable)
})
}
}
DI的部分也要調整
di/AppModule.kt
val appModule = module {
viewModel {
val networkServiceApi = NetworkService(BaseInterceptor())
val productRepository = ProductRepository(networkServiceApi.serviceAPI)
ProductViewModel(productRepository)
}
}
在ProductRepository,這段取得產品資訊的getProduct。
override fun getProduct(): Single<ProductResponse> {
return serviceApi.getProduct()
.map {
it.body()
}
}
接著要驗證當WebAPI回傳這樣的Json時,是否有回傳Single。
{
"id":"pixel4",
"name":"Google Pixel 4",
"desc":"5.5吋全螢幕",
"price":27000
}
當然在單元測試,我們不可能去呼叫真實的WebAPI。這邊我會比較傾向用整合測試的方式,讓NetworkService
注入假資料Json
與http的Response status
,同時驗證Json轉ProductResponse是否符合結果及getProduct本身的邏輯處理。
新增shareTest
在build.gradle 指定sourceSets
讓測試可以使用shareTest這個package。
android{
sourceSets {
String sharedTestDir = 'src/sharedTest/java'
String fakeJsonDir = 'src/sharedTest/fakejson'
test {
java.srcDir sharedTestDir
resources.srcDirs += fakeJsonDir
}
androidTest {
java.srcDir sharedTestDir
resources.srcDirs += fakeJsonDir
}
}
ProductRepositoryTest的測試
步驟1:建立MockInterceptor,指定httpStatus = 200
、載入product.json
為假資料
val interceptor = MockInterceptor()
interceptor.setInterceptorListener(object : MockInterceptor.MockInterceptorListener {
override fun setAPIResponse(url: String): MockAPIResponse? {
val mockAPIResponse = MockAPIResponse()
mockAPIResponse.status = 200
mockAPIResponse.responseString = Utils.readStringFromResource("product.json")
return mockAPIResponse
}
})
步驟2:注入Repository
val networkService = NetworkService(interceptor)
repository = ProductRepository(networkService.serviceAPI)
步驟3:呼叫被測試物件及驗證。一般來說,我們可能不會在這裡把Json轉Response的所有欄位都做驗證,當API回傳的欄位太多時,這樣的測試就會顯得太鎖碎。這裡的測試主要應為API回應的Status狀態的處理、Json轉換Response正確與錯誤的處理,或者你可能會依Response的某個欄位做不同處理時。大部分時候JSON與欄位的對映是否正確就不是你的測試的重點了。
當有這些情況,這是更需要測試的:
1.Json轉換時正確與錯誤的處理
2.依Http Status 不同狀態的處理
3.整理API回傳的Response資料,自行處理的部分。
val id = "pixel4"
val name = "Google Pixel 4"
val desc = "5.5吋全螢幕"
val price = 27000
//呼叫被測試物件
val product = repository.getProduct().blockingGet()
Assert.assertEquals(id, product.id)
Assert.assertEquals(desc, product.desc)
Assert.assertEquals(name, product.name)
Assert.assertEquals(price, product.price)
ViewModel的測試
改成Rxjava之後,測試ViewModel就更方便了。直接Mock Repository,回傳值為Single.just(product)
@Test
fun getProduct() {
val product = ProductResponse()
product.id = "pixel3"
product.name = "Google Pixel3"
product.price = 27000
product.desc = "5.5吋全螢幕"
Mockito.`when`(stubRepository.getProduct()).thenReturn(Single.just(product))
val viewModel = ProductViewModel(stubRepository)
viewModel.getProduct(product.id)
Assert.assertEquals(product.name, viewModel.productName.value)
Assert.assertEquals(product.desc, viewModel.productDesc.value)
Assert.assertEquals(product.price, viewModel.productPrice.value)
}
購買成功的測試
@Test
fun buySuccess() {
Mockito.`when`(stubRepository.buy(ArgumentMatchers.anyString(), ArgumentMatchers.anyInt())).thenReturn(Single.just(true))
val productViewModel = ProductViewModel(stubRepository)
productViewModel.buy()
Assert.assertTrue(productViewModel.buySuccessText.value != null)
}
購買失敗的測試
@Test
fun buyFail() {
Mockito.`when`(stubRepository.buy(ArgumentMatchers.anyString(), ArgumentMatchers.anyInt())).thenReturn(Single.just(false))
val productViewModel = ProductViewModel(stubRepository)
productViewModel.productId.value = "pixel3"
productViewModel.productItems.value = "2"
productViewModel.buy()
Assert.assertTrue(productViewModel.alertText.value != null)
}
範例下載:
https://github.com/evanchen76/mvvmretrofitsample