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

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)
先來看在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是因為待會需要驗證是否有呼叫IProductRepository與IProductView的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該做什麼事。
在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加上 onBuySuccess、onBuyFail
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)