ViewModel을 테스트해보자. 테스트 완성 코드는 아래 경로를 참조하자.
https://github.com/linuxias/Android-Testing/tree/testing_viewmodel/Setup_For_Testing
들어가기 앞서
원래 테스트의 목적은 테스트주도개발(Test Driven Development) 방법론을 사용한 개발 시 비지니스 로직과 함께 테스트를 작성하며 프로젝트를 완성해 나아가는 것이다. 하지만 블로그 정리 시 그러한 과정을 하나하나 설명하기에는 무리가 있기에 완성된 코드와 완성된 테스트 코드 기반으로 설명하려 한다.
추가로 테스트 더블에 대한 이해가 없다면 해당 글을 읽기 전에 테스트 더블에 관한 글을 먼저 읽는 것을 추천한다. 여기서 ViewModel을 테스트 하기 위해 Fake 테스트 더블을 사용하기 때문이다. 물론 코드만 보고 이해가 가능하지만 테스트 더블의 종류와 어떤 용도로 사용되는지 이해하고 있다면 추후 테스트 작성 시에도 매우 유용할 것이라 믿는다.
프로젝트 구조 (단순화)
현재의 프로젝트의 구조를 단순화 하면 아래와 같다.
- MarsPhotoViewModel : Repository에 의존성을 가지고 사용자 인터페이스 (UI)와 인터렉션을 한다.
- MarsPhotoRepository :
- Local : Room 라이브러리을 이용한 DB
- Remote : Retrofit2 라이브러리를 이용한 외부 시스템과의 RestAPI
우리가 원하는 테스트는 Android InstrumentedTest 가 아니라 UnitTest를 하길 원한다. ViewModel 자체의 기능이 정상적인지 확인하고자 하는 것이다.
그렇다면 위에서 확인한 것과 같이 MarsPhotoRepository에 대한 의존성은 어떻게 해야하는가? 실제로 MarsPhotoRepository의 인스턴스를 의존성 주입받아 테스트한다면? 만약 의존성 주입받은 인스턴스에 문제가 있다면 MarsPhotoViewModel이 정상적으로 테스트가 될 것인가? 물론 정확한 테스트가 불가능하다.
앞의 Testing 관련 글들에도 설명했듯이 테스트 시 가장 중요한 점 중 하나가 독립적으로 테스트가 가능한지이며 이러한 독립성을 만들어 주기 위한 도구로 테스트 더블(Test Double)이 사용된다.
여기서는 Repository의 의존성을 자체적으로 작성한 FakeRepository로 대체한다.
Test 하려는 ViewModel 코드
테스트 하려는 VIewModel의 코드이다. Dagger-Hilt를 이용하여 MarsPhotoRepository 의존성을 주입받고 있다.
@HiltViewModel
class MarsPhotoViewModel @Inject constructor(
private val repository: MarsPhotoRepository
) : ViewModel() {
val marsPhotoItem = repository.observeAllItems()
val totalPrice = repository.observeTotalPrice()
private val _images = MutableLiveData<Event<MarsPhotoProperty>>()
val images: LiveData<Event<MarsPhotoProperty>> = _images
private val _curImageUrl = MutableLiveData<String>()
val curImageUrl: LiveData<String> = _curImageUrl
private val _insertMarsPhotoItemStatus = MutableLiveData<Event<Resource<MarsPhotoItem>>>()
val insertMarsPhotoItemStatus: LiveData<Event<Resource<MarsPhotoItem>>> = _insertMarsPhotoItemStatus
fun setCurImageUrl(url: String) {
_curImageUrl.postValue(url)
}
fun insertMarsPhotoItemIntoDb(marsPhotoItem: MarsPhotoItem) = viewModelScope.launch {
repository.insertMarsPhotoItem(marsPhotoItem)
}
fun deleteMarsPhotoItemIntoDb(marsPhotoItem: MarsPhotoItem) = viewModelScope.launch {
repository.deleteMarsPhotoItem(marsPhotoItem)
}
fun insertMarsPhotoItem(id: String, price: Int, type: String) {
if (id.isEmpty() || price < 0 || type.isEmpty()) {
_insertMarsPhotoItemStatus.postValue(Event(Resource.error("Invalid field", null)))
return
}
val marsPhotoItem = MarsPhotoItem(price, id, type, _curImageUrl.value ?: "")
insertMarsPhotoItemIntoDb(marsPhotoItem)
setCurImageUrl("")
_insertMarsPhotoItemStatus.postValue(Event(Resource.success(marsPhotoItem)))
}
}
FakeRepositoty
기존의 MarsPhotoRepository는 Remote와 Local 모듈의 의존성 가지고, ViewModel에서 원하는 데이터를 생성 및 LiveData로 전달하는 방식을 사용하였다. 하지만 그러한 구조는 모두 덜어내고, FakeRepository에서 자체적으로 생성 및 데이터를 자동 전달하도록 하여 기존의 Repository 기능과 동일하게 동작하는 것처럼 ViewModel이 착각하도록 만든다.
이러한 방식으로 ViewModel 테스트 시 의존성 주입받는 Repository에서 문제가 발생할 수 있다는 것을 방지하고 테스트 단위의 독립성을 확보한다.
class FakeMarsPhotoRepository: MarsPhotoRepository {
private val marsPhotoItems = mutableListOf<MarsPhotoItem>()
private val observableMarsPhotoItems = MutableLiveData<List<MarsPhotoItem>>(marsPhotoItems)
private val observableTotalPrice = MutableLiveData<Int>(0)
private var shouldReturnNetworkError = false
fun setShouldReturnNetworkError(value: Boolean) {
shouldReturnNetworkError = value
}
private fun getTotalPrice(): Int {
return marsPhotoItems.sumOf { it.price }
}
private fun refreshLiveData() {
observableMarsPhotoItems.postValue(marsPhotoItems)
observableTotalPrice.postValue(getTotalPrice())
}
override suspend fun insertMarsPhotoItem(item: MarsPhotoItem) {
marsPhotoItems.add(item)
refreshLiveData()
}
override suspend fun deleteMarsPhotoItem(item: MarsPhotoItem) {
marsPhotoItems.add(item)
refreshLiveData()
}
override fun observeAllItems(): LiveData<List<MarsPhotoItem>> {
return observableMarsPhotoItems
}
override fun observeTotalPrice(): LiveData<Int> {
return observableTotalPrice
}
override suspend fun searchAllMarsPhotos(): Resource<List<MarsPhotoProperty>> {
return if(shouldReturnNetworkError) {
Resource.error("Error", null)
} else {
Resource.success(emptyList())
}
}
}
LiveDataUtils - LiveData 사용한 테스트 시 필요한 기능
ViewModel 코드를 아래에서 확인하면 LiveData를 사용하여 데이터를 얻어온다. LiveData를 이용하여 데이터를 전달 받는 경우 해당 LiveData를 관측(Observing)하고 있어야 한다. 이러한 경우 테스트에서 사용하는 코드이다.
Timeout 기능이 포함된 데이터 관측 가능한 함수이다. 코드 분석은 간단하여 추가 설명은 하지 않는다.
@VisibleForTesting(otherwise = VisibleForTesting.NONE)
fun <T> LiveData<T>.getOrAwaitValueTest(
time: Long = 2,
timeUnit: TimeUnit = TimeUnit.SECONDS,
afterObserve: () -> Unit = {}
): T {
var data: T? = null
val latch = CountDownLatch(1)
val observer = object : Observer<T> {
override fun onChanged(o: T?) {
data = o
latch.countDown()
this@getOrAwaitValueTest.removeObserver(this)
}
}
this.observeForever(observer)
try {
afterObserve.invoke()
// Don't wait indefinitely if the LiveData is not set.
if (!latch.await(time, timeUnit)) {
throw TimeoutException("LiveData value was never set.")
}
} finally {
this.removeObserver(observer)
}
@Suppress("UNCHECKED_CAST")
return data as T
}
ViewModel 테스트 코드
최종적인 ViewModel의 테스트코드이다. 모든 코너케이스까지 테스트할 수 있는 코드는 아님을 참고하자. 학습을 위한 VIewModel의 테스트 방식에 중점을 두었다.
JUnit Rule - InstantTaskExecutorRule
InstantTaskExecutorRule을 이용하면 안드로이드 구성요소 관련 작업들을 모두 한 스레드에서 테스트 할 수 있다. 하나의 스레드에서 동작한다는 것은 모든 테스트가 비동기적으로 서로 영향을 주지 않고 독립적으로 테스트 가능함을 알 수 있다. 즉 비동기적으로 동작 시 발생할 수 있는 문제를 차단한다. 특히 LiveData를 이용한다면 필수적으로 InstantTaskExecutorRule를 사용해야 할 것이다. Coroutine 이용 시 비동기적으로 동작하므로 어떠한 문제가 발생할 수 있을지 예측이 쉽지 않기 때문이다.
FakeRepository 의존성 주입
아래 @Before setup 함수를 보면 MarsPhotoViewModel 인스턴스 생성 시 인자로 FakeMarsPhotoRepository() 인스턴스를 전달하여 의존성을 주입해 주는 것을 확인할 수 있다.
package com.linuxias.setup_for_testing.ui
@ExperimentalCoroutinesApi
class MarsPhotoViewModelTest {
@get: Rule
var instantExecutorRule = InstantTaskExecutorRule()
private lateinit var viewModel: MarsPhotoViewModel
@Before
fun setup() {
Dispatchers.setMain(UnconfinedTestDispatcher())
viewModel = MarsPhotoViewModel(FakeMarsPhotoRepository())
}
@Test
fun `insert mars photo item with empty id field, returns error`() {
viewModel.insertMarsPhotoItem("", 3500, "buy")
val value = viewModel.insertMarsPhotoItemStatus.getOrAwaitValueTest()
assertThat(value.getContentIfNotHandled()?.status).isEqualTo(Status.ERROR)
}
@Test
fun `insert mars photo item with negative price field, returns error`() {
viewModel.insertMarsPhotoItem("10000", -40, "buy")
val value = viewModel.insertMarsPhotoItemStatus.getOrAwaitValueTest()
assertThat(value.getContentIfNotHandled()?.status).isEqualTo(Status.ERROR)
}
@Test
fun `insert mars photo item with negative type field, returns error`() {
viewModel.insertMarsPhotoItem("10000", 100, "")
val value = viewModel.insertMarsPhotoItemStatus.getOrAwaitValueTest()
assertThat(value.getContentIfNotHandled()?.status).isEqualTo(Status.ERROR)
}
@Test
fun `insert mars photo item with valid input, returns success`() {
viewModel.insertMarsPhotoItem("10000", 100, "buy")
val value = viewModel.insertMarsPhotoItemStatus.getOrAwaitValueTest()
assertThat(value.getContentIfNotHandled()?.status).isEqualTo(Status.SUCCESS)
}
@Test
fun `total price is correct`() {
viewModel.insertMarsPhotoItem("10000", 100, "buy")
viewModel.insertMarsPhotoItem("10001", 200, "buy")
viewModel.insertMarsPhotoItem("10002", 300, "buy")
val value = viewModel.totalPrice.getOrAwaitValueTest()
assertThat(value).isEqualTo(600)
}
}
테스트 결과
'Android > Testing' 카테고리의 다른 글
[Android/Testing] #9. Dagger-Hilt 적용하여 프레그먼트 테스트하기 (0) | 2022.11.13 |
---|---|
[Android/Testing] #8. 테스트에 Dagger-Hilt 적용해보기 (0) | 2022.11.08 |
[Android/Testing] #6. 테스트 시작 전에 테스트 더블(Test Double) 이해하기 (0) | 2022.10.08 |
[Android/Testing] #5. 테스트를 위한 기반 프로젝트 생성하기 (0) | 2022.10.02 |
[Android/Testing] #4. Room Database 테스트 (0) | 2022.09.18 |