iT邦幫忙

第 11 屆 iThome 鐵人賽

DAY 18
2
Mobile Development

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

Day18 - Android MVP 架構的單元測試

  • 分享至 

  • xImage
  •  

延續上一篇的架構採用MVP後,我們就來看這個範例該怎麼撰寫單元測試。

https://ithelp.ithome.com.tw/upload/images/20191002/20111896msjqR0GSs3.png

build.gradle

testImplementation "org.mockito:mockito-core:2.8.47"

新增一個MockitoKotlinHelpers.kt,用來解決Kotlin在Mock的問題。

fun <T> eq(obj: T): T = Mockito.eq<T>(obj)

/**
 * Returns Mockito.any() as nullable type to avoid java.lang.IllegalStateException when
 * null is returned.
 */
fun <T> any(): T = Mockito.any<T>()

/**
 * Returns ArgumentCaptor.capture() as nullable type to avoid java.lang.IllegalStateException
 * when null is returned.
 */
fun <T> capture(argumentCaptor: ArgumentCaptor<T>): T = argumentCaptor.capture()

/**
 * Helper function for creating an argumentCaptor in kotlin.
 */
inline fun <reified T : Any> argumentCaptor(): ArgumentCaptor<T> =
        ArgumentCaptor.forClass(T::class.java)

ProductPresenter的測試

先來看在ProductionCode的ProductPresenter.getProduct做了什麼事。
1.呼叫productRepository.getProduct取得產品資料
2.呼叫view.onGetResult回傳productResponse
所以我們的測試必須驗證這兩個項目

override fun getProduct(productId: String) {
    productRepository.getProduct(productId, object : IProductRepository.LoadProductCallback {
        override fun onProductResult(productResponse: ProductResponse) {
            view.onGetResult(productResponse)
        }
    })
}

新增ProducePresenterTest

1.建立被測試物件ProductPresenter
2.建立Mock IProductRepsitory
3.建立Mock IProductView

會需要步驟2、3的 IProductRepsitory、IProductView是因為待會需要驗證是否有呼叫IProductRepositoryIProductView的Callback。

class ProductPresenterTest {
    private lateinit var presenter: ProductContract.IProductPresenter
    private var productResponse = ProductResponse()

    @Mock
    private lateinit var repository: IProductRepository

    @Mock
    private lateinit var productView: ProductContract.IProductView

}

在setupPresenter,將物件初始化
1.使用 MockitoAnnotations.initMocks(this)初始化Mock。
2.被測試物件
ProductPresenter
初始化
3.ProductResponse初始化,這是用來測試用的資料

@Before
fun setupPresenter() {
    MockitoAnnotations.initMocks(this)

    presenter = ProductPresenter(productView, repository)

    productResponse.id = "pixel3"
    productResponse.name = "Google Pixel 3"
    productResponse.price = 27000
    productResponse.desc = "Desc"
}

開始寫getProduct的測試。

@Test
fun getProductTest() {
    val productId = "pixel3"

    //呼叫SUT
    presenter.getProduct(productId)

    val loadProductCallbackCaptor = argumentCaptor<IProductRepository.LoadProductCallback>()

    //驗證是否有呼叫IProductRepository.getProduct
    Mockito.verify<IProductRepository>(repository).getProduct(eq(productId), capture(loadProductCallbackCaptor))

    //將callback攔截下載並指定productResponse的值。
    loadProductCallbackCaptor.value.onProductResult(productResponse)

    //驗證是否有呼叫View.onGetResult及是否傳入productResponse
    Mockito.verify(productView).onGetResult(productResponse)
}

在這個測試中,argumentCaptor用來取得callback,準備攔截並給值。

val loadProductCallbackCaptor = argumentCaptor<IProductRepository.LoadProductCallback>()

使用verity來驗證是否有呼叫repository.getProduct,使用eq()方法驗證repository.getProduct傳入的參數是否正確。
第2個參數則帶入
loadProductCallbackCaptor
,可以攔截callback並指定回傳的值。

verify<IProductRepository>(repository).getProduct(eq(productId), capture(loadProductCallbackCaptor))

將callback攔截下並指定productResponse的值。

loadProductCallbackCaptor.value.onProductResult(productResponse)

最後則是驗證是否有呼叫View的Callback

verify(productView).onGetResult(productResponse)

在MVP的架構,Presenter的測試非常重要,負責呼叫Repository與將資料處理過後呼叫View的Callback。對於Presenter的getProduct在取得資料後,只要呼叫View的Callback就做到Presenter的職責了,而畫面有沒有正確的顯示資料則是Activity該處理的事。在Presenter,你不會有處理View的行為,只會透過在Presenter初始化時傳進來的IProductView,要求View該做什麼事。

Model(Repository)的測試。

在ProductionCode,一樣來看getProduct做了什麼事。
1.跟ProductAPI取得產品資料
2.呼叫Callback回傳資料

class ProductRepository(private val productAPI: IProductAPI) : IProductRepository {

    override fun getProduct(productId: String, loadProductCallback: IProductRepository.LoadProductCallback) {

        productAPI.getProduct(productId, object : IProductAPI.LoadAPICallBack {
            override fun onGetResult(productResponse: ProductResponse) {
                loadProductCallback.onProductResult(productResponse)
            }
        })
    }
}

新增ProductRepositoryTest

1.建立被測試物件ProductRepository
2.建立Mock IProductAPI
3.建立Mock IProductRepository.loadProductCallback
4.初始化setup

class ProductRepositoryTest {
    private lateinit var repository: IProductRepository

    private var productResponse = ProductResponse()

    @Mock
    private lateinit var productAPI: IProductAPI

    @Mock
    private lateinit var repositoryCallback : IProductRepository.LoadProductCallback

    @Before
    fun setupPresenter() {
        MockitoAnnotations.initMocks(this)

        repository = ProductRepository(productAPI)

        productResponse.id = "pixel3"
        productResponse.name = "Google Pixel 3"
        productResponse.price = 27000
        productResponse.desc = "Desc"
    }
}

開始寫getProduct的測試

@Test
fun getProductTest() {

    //驗證跟Repository取得資料
    val productId = "pixel3"
    repository.getProduct(productId, repositoryCallback)

    //驗證是否有呼叫IProductAPI.getProduct
    val productAPICallbackCaptor = argumentCaptor<IProductAPI.LoadAPICallBack>()
    Mockito.verify<IProductAPI>(productAPI).getProduct(any(), capture(productAPICallbackCaptor))

    //將callback攔截下載並指定productResponse的值。
    productAPICallbackCaptor.value.onGetResult(productResponse)

    //驗證是否有呼叫Callback
    Mockito.verify(repositoryCallback).onProductResult(productResponse)
}

購買成功及失敗的處理

在上一篇給了大家的一個練習,按下「購買」後。如購買成功Toast「購買成功」,購買失敗則Alert「購買失敗」。

在ProductContract
1.IProductPresenter加上buy
2.在IProductView加上 onBuySuccessonBuyFail

class ProductContract {
    
    interface IProductPresenter {
        fun getProduct(productId: String)
        fun buy(productId: String, numbers: Int)
    }
   
    interface IProductView {
        fun onGetResult(productResponse: ProductResponse)
        fun onBuySuccess()
        fun onBuyFail()
    }
}

在ProductPresenter.buy實作,依Repository回傳的購買成功或失敗呼叫對映的callBack

override fun buy(productId: String, numbers: Int) {
    productRepository.buy(productId, numbers, object : IProductRepository.BuyProductCallback {
        override fun onBuyResult(isSuccess: Boolean) {
            if (isSuccess) {
                view.onBuySuccess()
            } else {
                view.onBuyFail()
            }
        }
    })
}

在Activity則實作購買成功與失敗要做的事。可以看到Activity不負責什麼情況視為購買成功與購買失敗。只接受Presenter告訴View現在該呈現購買成功的畫面還是購買失敗的畫面。Activity上的程式碼就會非常的簡單,幾乎是沒有邏輯。

Activity

override fun onBuySuccess() {
    Toast.makeText(this, "購買成功", Toast.LENGTH_LONG).show();
}

override fun onBuyFail() {
    val builder = AlertDialog.Builder(this)
    builder.setMessage("購買失敗").setTitle("錯誤")
    builder.show()
}

購買成功的測試

@Test
fun buySuccessTest() {
    val buyProductCallbackCaptor = argumentCaptor<IProductRepository.BuyProductCallback>()

    val productId = "pixel3"
    val items = 3

    presenter.buy(productId, items)

    verify(repository).buy(eq(productId), eq(items), capture(buyProductCallbackCaptor))

    buyProductCallbackCaptor.value.onBuyResult(true)

    verify(productView).onBuySuccess()
}

購買失敗的測試(假設Repository.getProduct 購買超過10份即會回傳失敗)

@Test
fun buyFailTest() {
    val buyProductCallbackCaptor = argumentCaptor<IProductRepository.BuyProductCallback>()

    val productId = "pixel3"
    val items = 11

    presenter.buy(productId, items)

    verify(repository).buy(eq(productId), eq(items), capture(buyProductCallbackCaptor))

    buyProductCallbackCaptor.value.onBuyResult(false)

    verify(productView).onBuyFail()
}

小結

MVP 架構將程式分為Model、View、Presenter。Presenter從Repository取得資料後,透過View的Interface告訴View要做什麼事。而View就只需要處理現在該呈現什麼資料。把商業邏輯與View的職責分開,這樣一來就讓可測試性提高了。

當我們在測試Presenter,只關注:
1.是否呼叫Repository
2.本身的邏輯是否正確
3.是否呼叫正確的View Callback

如果用Robolectirc來直接測試Activity,只關注。
1.Activity初始化是否有呼叫IPresenter.getProduct。
2.呼叫IProductView.onGetResult,是否有將商品結果放到UI上。
3.呼叫IProductView.onBuySuccess,是否有Toast。
4.呼叫IProductView.onBuyFail,是否有AlertDialog。

範例下載:
https://github.com/evanchen76/MVPUnitTestSample

下一篇將介紹另一個常用的架構MVVM。

出版書:
Android TDD 測試驅動開發:從 UnitTest、TDD 到 DevOps 實踐

線上課程:
Android 動畫入門到進階
Android UI 進階實戰(Material Design Component)


上一篇
Day17 - Android MVP 架構
下一篇
Day19 - Android MVVM 架構:DataBinding
系列文
Android TDD 測試驅動開發30
圖片
  直播研討會
圖片
{{ item.channelVendor }} {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言