iT邦幫忙

第 11 屆 iThome 鐵人賽

DAY 21
1
Mobile Development

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

Day21 - Android MVVM 架構的單元測試

  • 分享至 

  • xImage
  •  

介紹完了 DataBinding、ViewModel、LiveData,可以開始來寫MVVM的單元測試了。

測試 ProductViewModel.getProduct

先看ProductionCode,getProduct 負責跟Repository要資料後將取得的資料放到LiveData。與MVP的差異在於Presenter的在取得資料後,會呼叫view的callback去要求View應該做什麼事。而在MVVM的ViewModel只負責將資料放到LiveData。而View有沒有正確的顯示資料跟ViewModel就沒有關係了。

ProductViewModel的Production Code

class ProductViewModel(private val productRepository: IProductRepository) : ViewModel(){

    var productName: MutableLiveData<String> = MutableLiveData()
    var productDesc: MutableLiveData<String> = MutableLiveData()
    var productPrice: MutableLiveData<Int> = MutableLiveData()
    var productItems: MutableLiveData<String> = MutableLiveData()

    fun getProduct(productId: String) {
        productRepository.getProduct(productId, object : IProductRepository.LoadProductCallback {
            override fun onProductResult(productResponse: ProductResponse) {
                productName.value = productResponse.name
                productDesc.value = productResponse.desc
                productPrice.value = productResponse.price
            }
        })
    }
}

build.Gradle加上

testImplementation "android.arch.core:core-testing:$archLifecycleVersion"

建立ProductViewModelTest,加上InstantTaskExecutorRule

class ProductViewModelTest {

    @get:Rule
    var instantExecutorRule = InstantTaskExecutorRule()
}

在測試的初始化,建立被測試物件viewModel、模擬物件repository及驗證用的ProductResponse。

class ProductViewModelTest {

    @get:Rule
    var instantExecutorRule = InstantTaskExecutorRule()

    @Mock
    lateinit var repository: IProductRepository
    private var productResponse = ProductResponse()
    private lateinit var viewModel: ProductViewModel

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

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

        viewModel = ProductViewModel(repository)
    }
}

驗證getProductTest

1.驗證是否有呼叫IProductRepository.getProduct
2.驗證是否有改變LiveData的值。

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

    val loadProductCallbackCaptor = argumentCaptor<IProductRepository.LoadProductCallback>()

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

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

    Assert.assertEquals(productResponse.name, viewModel.productName.value)
    Assert.assertEquals(productResponse.desc, viewModel.productDesc.value)
    Assert.assertEquals(productResponse.price, viewModel.productPrice.value)
}

購買成功、購買失敗的測試

Production Code
1.購買成功將buySuccessText 指定為Event("購買成功")
2.購買失敗將buyFailText 指定為Event("購買失敗")

fun buy(view: View) {
    val productId = productId.value ?: ""
    val numbers = (productItems.value ?: "0").toInt()

    productRepository.buy(productId, numbers, object : IProductRepository.BuyProductCallback {
        override fun onBuyResult(isSuccess: Boolean) {
            if (isSuccess) {
                buySuccessText.value = Event("購買成功")
            } else {
                alertText.value = Event("購買失敗")
            }
        }
    })
}

對於viewModel的buy,當購買成功時,只需要知道buySuccessText會被改變。MVVM的好處,ViewModel只要改變LiveData,而有沒有真的在Snack跳出buySuccessText就是View的事情了。這裡我們用一個模糊的比對,只要buySuccessText不為null,就算測試成功。而不是直接去比對字串為相等,因為這會導至測試變得脆弱。如果之後購買成功的文字有改時,測試就會失敗。

購買成功的測試
1.驗證是否有呼叫IProductRepository.getProduct
2.驗證購買成功是否有設定buySuccessText

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

    val productId = "pixel3"
    val items = 3
    val productViewModel = ProductViewModel(repository)
    productViewModel.productId.value =  productId
    productViewModel.productItems.value = items.toString()

    productViewModel.buy()

    //驗證是否有呼叫IProductRepository.getProduct
    verify(repository).buy(eq(productId), eq(items), capture(buyProductCallbackCaptor))

    buyProductCallbackCaptor.value.onBuyResult(true)

    Assert.assertTrue(productViewModel.buySuccessText.value != null)
}

購買失敗的測試
1.驗證是否有呼叫IProductRepository.getProduct
2.驗證購買失敗是否有設定alertText

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

    val productId = "pixel3"
    val items = 11
    val productViewModel = ProductViewModel(repository)
    productViewModel.productId.value = productId
    productViewModel.productItems.value = items.toString()

    productViewModel.buy()

    //驗證是否有呼叫IProductRepository.getProduct
    verify(repository).buy(eq(productId), eq(items), capture(buyProductCallbackCaptor))

    buyProductCallbackCaptor.value.onBuyResult(false)

    Assert.assertTrue(productViewModel.alertText.value != null)
}

以上就是MVVM 的單元測試,拆成Model、View、ViewModel之後,是不是就更好寫測試了。在MVP的架構時,Presenter仍擁有View。而在MVVM,ViewModel就完全跟View就無關了,兩者完全沒有依賴。

為了更方便單元測試,我們使用了依賴注入的方式,也導至在Activity需要建立Repository注入ViewModel,這樣的做法有點麻煩,且Activity出現Repository也怪怪的,下一篇將介紹Kotlin的依賴注入框架Koin來簡化注入的方式。

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


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

尚未有邦友留言

立即登入留言