iT邦幫忙

第 11 屆 iThome 鐵人賽

DAY 23
1
Mobile Development

Android TDD 測試驅動開發系列 第 23

Day23 - 使用Retrofit連接API的測試

  • 分享至 

  • xImage
  •  

在之前的範例,我們都是在Repository模擬呼叫WebAPI來取得資料,現在要實際接上一個WebAPI來看看應該怎麼測試。要測試有沒有真的呼叫到WebAPI,那這會是一個e2e的測試。這篇的重點將以單元測試的方式來介紹怎麼測試Repository。

我們使用RetrofitRxJava 來處理呼叫Web API。

這段我們要來處理Repository呼叫API的測試

呼叫Web 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

https://ithelp.ithome.com.tw/upload/images/20191007/20111896qNpnEZe7Ql.png

在兩個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測試

在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
https://ithelp.ithome.com.tw/upload/images/20191007/20111896w9P8Vc8xVh.png

在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


上一篇
Day22 - 依賴注入框架Koin
下一篇
Day24 - Rxjava的測試
系列文
Android TDD 測試驅動開發30
圖片
  直播研討會
圖片
{{ item.channelVendor }} {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言