在之前的範例,我們都是在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