Android view에서 ViewModel을 호출했는데, UnitTest는 어떻게 할까?
개인 광고 영역
Android ViewModel에서 Flow를 이용한 View Controller 방법을 살펴보았습니다.
이전 글에서도 작성했지만 그 코드 상태로는 테스트 코드 활용이 불가능합니다.
이번 글에서는 테스트 가능한 형태를 만들기 위한 작업을 해보겠습니다.
이 글에서 알아볼 내용
- UnitTest 가능한 형태로 함수 분리
- UnitTest를 위한 dependency 추가
- UnitTest 작업
함수의 분리
아래의 load() 함수의 onClick 이벤트는 2개가 등록되어 있다. 2개의 이벤트를 각각의 함수로 동작할 수 있도록 수정합니다.
fun load(viewController: OnClickEventControl) {
viewController.onClick(R.id.button_second)
.onEach {
_clickEvent.value = true
}
.launchIn(viewModelScope)
/**
* Plus 버튼 이벤트 처리
*/
viewController.onClick(R.id.btn_plus_count)
.onEach {
_updateCount.value = (_updateCount.value ?: 0) + 1
}
.launchIn(viewModelScope)
}
위 코드는 load()에 속해있지만 2개의 이벤트를 하나의 함수에 처리하고 있기 때문에 각각을 테스트할 수 없습니다.
2개의 이벤트를 각각의 함수로 처리하고 return type을 Flow
launchIn은 포함하지 않아야 Job 리턴을 하지 않고, Flow
/**
* Second 버튼 이벤트 처리
*/
fun flowButtonSecond(viewController: OnClickEventControl): Flow<Boolean> =
viewController.onClick(R.id.button_second)
.onEach {
Log.i(TAG, "Click next.")
_clickEvent.value = true
}
/**
* Plus 버튼 이벤트 처리
*/
fun flowButtonPlusCount(viewController: OnClickEventControl): Flow<Boolean> =
viewController.onClick(R.id.btn_plus_count)
.onEach {
Log.i(TAG, "Click plus next.")
_updateCount.value = (_updateCount.value ?: 0) + 1
}
Flow
기존 load() 함수는 아래와 같이 수정합니다.
fun load(viewController: OnClickEventControl) {
flowButtonSecond(viewController)
.launchIn(viewModelScope)
flowButtonPlusCount(viewController)
.launchIn(viewModelScope)
}
UnitTest - 준비작업
위와 같이 함수를 분리하였기 때문에 UnitTest를 진행할 수 있습니다.
UnitTest를 위한 별도의 모듈을 하나 추가했는데 아래와 같습니다.
class TestOnClickEvent : OnClickEventControl {
private val sharedFlow = MutableSharedFlow<Boolean>(
extraBufferCapacity = 1,
onBufferOverflow = BufferOverflow.DROP_OLDEST
)
fun click() =
sharedFlow.tryEmit(true)
override fun onClick(resourceId: Int): Flow<Boolean> =
sharedFlow
}
TestOnClickEvent는 말 그대로 UnitTest를 위한 Test class입니다. onClick 이벤트가 발생하면, resourceId를 검증하는 부분을 제외하고, sharedFlow를 리턴합니다.
click() 함수가 호출되면 true를 stream으로 흐르게 합니다.
UnitTest
준비한 TestOnClickEvent를 이용해 UnitTest를 할 수 있습니다.
ViewModelTest를 하나 생성하고, SecondViewModel() 객체를 하나 생성합니다.
flowButtonSecond에 대한 테스트를 작성할 때 mockEvent를 이용해서 테스트 가능한 형태를 만듭니다.
이 코드의 검증은 ClickEvent가 발생하면 awaitItem()의 응답 값이 true 임을 확인하는 단순한 코드입니다.
internal class SecondViewModelTest {
private val viewModel = SecondViewModel()
@ExperimentalTime
@Test
fun testFlowButtonSecond() = runBlocking {
val mockEvent = TestOnClickEvent()
viewModel.flowButtonSecond(mockEvent)
.test {
mockEvent.click()
Assert.assertTrue(awaitItem())
cancelAndConsumeRemainingEvents()
}
}
}
UnitTest에서 사용한 라이브러리
UnitTest 작성에 사용한 라이브러리는 아래와 같습니다.
coroutines test
coroutines test는 Dispatcher.Main을 UnitTest에서 활용할 수 없기에 추가한 dependency입니다.
testImplementation("org.jetbrains.kotlinx:kotlinx-coroutines-test:$version")
UnitTest에서는 Dispatcher.Main을 사용할 수 없기 때문에 Unconfined으로 변경하는 작업을 진행하였습니다.
@ExperimentalCoroutinesApi
@Before
fun setUp() {
// Dispatcher 상태를 Unconfined으로 변경
Dispatchers.setMain(Dispatchers.Unconfined)
}
좀 더 유용한 테스트를 하기 위해서는 다른 Dispatcher 상태도 변경하는 게 좋긴 하지만 위처럼 테스트할 수 있습니다.
turbine
Cashapp의 오픈소스인 turbine을 이용합니다. turbine은 flow 테스트를 좀 더 쉽게 할 수 있습니다.
이 역시 JakeWharton이 참여하고 있죠.(cashapp 개발자)
testImplementation("app.cash.turbine:turbine:0.6.1")
flow로 작성한 ViewModel의 테스트 코드를 .test {} 람다 표현으로 작업할 수 있습니다. suspend 함수이기 때문에 runBlocking {}과 함께 사용합니다.
awaitItem으로 아이템 정보를 대기하고, awaitComplete()이 떨어지기를 기다립니다.
flowOf("one", "two").test {
assertEquals("one", awaitItem())
assertEquals("two", awaitItem())
awaitComplete()
}
하지만 위 샘플에서 사용한 코드는 sharedFlow이기 때문에 아래와 같이 사용하였습니다.
val mutableSharedFlow = MutableSharedFlow<Int>(replay = 0)
mutableSharedFlow.emit(1)
mutableSharedFlow.test {
assertEquals(awaitItem(), 1)
cancelAndConsumeRemainingEvents()
}
core testing
여기서 core testing을 추가한 이유는 LiveData 사용 때문입니다.
LiveData 역시 UI 스케줄러에서 동작하도록 설계되어 있기 때문에 UnitTest에서 활용하려면 추가해야 합니다.
testImplementation("androidx.arch.core:core-testing:2.1.0")
그리고 아래의 코드를 추가해야 합니다.
@get:Rule
val rule = InstantTaskExecutorRule()
이 코드는
이 코드는 GitHub에 업로드해두었으니 참고하시면 되겠습니다.
GitHub ClickEvent UnitTest 샘플 코드
마무리
이전 글에 이어 ViewModel testing 작업을 어떻게 할 수 있을지에 대해서 정리해 보았습니다.
결국 launchIn의 위치를 다른 곳으로 이동시켜주면 가능하다는 이야기입니다.
이 코드를 좀 더 유용하게 사용하려면 Lifecycle을 함께 사용하는 방법입니다. 그럼 함수 호출을 직접이 아닌 간접적인 방법으로 사용할 수 있어집니다.
다음 글에서 한 번 더 추가해 보겠습니다.
Comments