Festago 를 개발하면서 ViewModel 의 UiState 및 Event 를 감지하기 위해 LiveData 를 사용했다.
이상적으로 ViewModel 은 Android 를 알아선 안된다. 테스트 가능성, 메모리 누수 안전성, 모듈성을 향상시킨다.
참고: AndroidDevelopers 블로그
https://medium.com/androiddevelopers/viewmodels-and-livedata-patterns-antipatterns-21efaef74a54
이를 이유로 안드로이드 의존성을 갖는 LiveData 를 사용하던 기존 코드들을 Kotlin 의존성을 갖는 Flow 로 Migration 해보기로 했다.
StateFlow 와 SharedFlow 에 대한 이론적인 글은 다음 글을 참고해주세요.
https://seonghoonc.tistory.com/30
화면 소개
Migration 할 학교 인증(StudentVerification)을 하는 화면이다.
- Activity 를 시작하면 학교 ID 로 학교 이메일을 받아온다
- 인증 번호 확인에 성공, 실패, 타임아웃 등 이벤트를 발생시킨다.
UiState LiveData to StateFlow
sealed interface StudentVerificationUiState {
// 로딩 상태
object Loading : StudentVerificationUiState
// 성공 상태
data class Success(
val schoolEmail: String,
val remainTime: Int,
val isValidateCode: Boolean = false,
) : StudentVerificationUiState
// 에러 상태
object Error : StudentVerificationUiState
...
}
uiState 는 위와 같이 sealed interface 로 정의된다. 로딩, 성공, 실패 상태가 있으며 그에 따라 각각 다른 화면을 보여준다.
class StudentsVerificationViewModel(
private val schoolRepository: SchoolRepository,
...
) : ViewModel() {
// 로딩 상태로 초기화
private val _uiState = MutableLiveData<StudentVerificationUiState>(StudentVerificationUiState.Loading)
val uiState: LiveData<StudentVerificationUiState> = _uiState
fun loadSchoolEmail(schoolId: Int) {
...
viewModelScope.launch {
schoolRepository.loadSchoolEmail(schoolId)
// 성공하면 성공 상태로 set
.onSuccess { email ->
_uiState.value = StudentVerificationUiState.Success(
schoolEmail = email,
remainTime = MIN_REMAIN_TIME,
)
}
// 실패하면 에러 상태로 set
.onFailure {
_uiState.value = StudentVerificationUiState.Error
...
}
}
}
이때 ViewModel 의 loadSchoolEmail() 을 호출하면 repository 로 학교 이메일을 요청하고 결과에 따라UiState 를 업데이트한다.
이를 StateFlow 로 바꾸면?
class StudentsVerificationViewModel(
private val schoolRepository: SchoolRepository,
...
) : ViewModel() {
// 로딩 상태로 초기화 LiveData 와 달리 초기값이 필수다.
private val _uiState = MutableStateFlow<StudentVerificationUiState>(StudentVerificationUiState.Loading)
val uiState: StateFlow<StudentVerificationUiState> = _uiState.asStateFlow()
ViewModel 에서 코드는 거의 비슷하다! StateFlow 는 초기 값을 필수로 지정해줘야 한다. 그것 말고는 별 다른게 없다.
Activity 에서는 다음과 같이 변경해야한다.
- observe 를 collect 로 변경한다.
- Lifecycle 변경을 감지할 수 있도록 처리한다.
lifecycleScope.launch {
repeatOnLifecycle(Lifecycle.State.STARTED) {
viewModel.uiState.collect {
uiState -> handleUiState(uiState)
}
}
}
stateFlow 는 LiveData 와 달리 알아서 LifeCycle 을 감지하지 못한다.
홈 화면을 누르거나 다른 액티비티에 가리는 등 생명주기가 Stopped 상태일 때 계속해서 collect 하고 있지 않도록 repeatOnLifecycle 을 사용해 처리했다.
이 과정에서 ViewModel 테스트는 어떻게 되었을까?
ViewModel Test with LiveData
기존 LiveData 사용 시 테스트다. 테스트에 관한 자세한 얘기는 생략하겠다.
@get:Rule
val instantExecutorRule = InstantTaskExecutorRule()
@Test
fun `이메일 불러오기에 실패하면 실패 상태가 된다`() {
// given
`이메일 요청 결과가 다음과 같을 때`(Result.failure(Exception()))
// when
viewModel.loadSchoolEmail(schoolId)
// then
assertThat(viewModel.uiState.value).isExactlyInstanceOf(StudentVerificationUiState.Error::class.java)
}
ViewModel 의 uiState 의 value 가져와 검증한다.
안드로이드 의존성을 갖는 LiveData 를 ViewModel 에서 사용하면 ViewModel 을 단위 테스트하기 위해 Rule을 추가해줘야 한다.
ViewModel Test with StateFlow
테스트를 짜다보면 리팩터링 할 때 기존 테스트코드에 빨간줄이 생기는 경험을 하게된다. 하지만 이 경우엔 없다!
value 로 뽑아서 가져오는 방식이 LiveData 와 똑같기 때문이다.
더 이상 사용하지 않는 Rule 만 제거해주면 된다.
Event 처리 LiveData to SharedFlow
Event Wrapping class 를 사용한 커스텀 SingleLiveData 를 사용하고 있었다.
그 이유도 다음 글로 확인하길 바란다.
https://seonghoonc.tistory.com/30#SharedFlow
private val _event = MutableSingleLiveData<StudentVerificationEvent>()
val event: SingleLiveData<StudentVerificationEvent> = _event
fun confirmVerificationCode() {
viewModelScope.launch {
...
studentVerificationRepository.requestVerificationCodeConfirm(...)
.onSuccess {
_event.setValue(StudentVerificationEvent.VerificationSuccess)
}.onFailure {
_event.setValue(StudentVerificationEvent.VerificationFailure)
}
}
발생시키고 싶은 Event 가 있다면 MutableSingleLiveData 의 value 를 set 하면 된다.
private val _event = MutableSharedFlow<StudentVerificationEvent>()
val event: SharedFlow<StudentVerificationEvent> = _event.asSharedFlow()
fun confirmVerificationCode() {
viewModelScope.launch {
...
studentVerificationRepository.requestVerificationCodeConfirm(...)
.onSuccess {
_event.emit(StudentVerificationEvent.VerificationSuccess)
}.onFailure {
_event.emit(StudentVerificationEvent.VerificationFailure)
}
}
하지만 sharedFlow 는 value 를 사용하지 않는다. 발생시키고 싶은 Event 가 있다면 emit 을 해주면 된다.
lifecycleScope.launch {
repeatOnLifecycle(Lifecycle.State.STARTED) {
viewModel.event.collect { event ->
handleEvent(event)
}
}
}
event 도 마찬가지로 Lifecycle 에 맞춰 동작하기 위해 위와 같은 이유로 repeatOnLifecycle 을 적용했다.
여기까진 아무 문제가 없었다. 하지만 테스트에서 몇가지 문제가 발생했다.
ViewModelTest with SharedFlow
문제점 1. sharedFlow 는 getValue() 로 값을 얻어올 수 없다.
sharedFlow 는 LiveData 혹은 StateFlow 와 다르게 value 를 가져올 수 없다. 그렇기 때문에 방출되는 값을 반환받을 방법이 필요했다.
해결 방법 : Flow 의 first() 확장함수 사용
first 의 내부 구조를 보면 알 수 있듯 이 함수는 방출되는 가장 첫번째 값을 반환하며 일정 시간동안 방출되지 않으면 NoSuchElementException() 을 발생시킨다.
@Test
fun `... 인증 번호 확인이 성공하면 인증 성공 이벤트가 발생한다`() = runTest {
// given
...
// when
viewModel.confirmVerificationCode()
// then
assertThat(vm.event.first()).isEqualTo(StudentVerificationEvent.VerificationSuccess)
}
flow 는 코루틴 위에서 동작한다. 따라서 suspend function 인 first() 를 사용하려면 runTest 를 사용해 TestScope 를 열어줘야 한다.
하지만 이것은 곧바로 다음 문제를 직면한다.
문제점 2. sharedFlow 가 값을 방출할 때 까지 기다리지 않는다.
이전의 코드는 처음으로 방출된 event 를 받아오지만 viewModel.confirmVerificationCode() 와 vm.event.first() 가 동기적으로 실행된다.
방출된 이후에 값을 기다리게 되며 기다리기만 하다가 결국 예외가 발생한다.
값이 들어올 때 까지 기다리게 하기 위해서 async scope 를 열고 값이 반환된 후에 assertThat 검증이 실행되도록 변경해주어야 한다.
@Test
fun `given ... 인증 번호 확인이 성공하면 인증 성공 이벤트가 발생한다`() = runTest(UnConfinedTestDispather()) {
// given
...
// sharedFlow Event 기다리기
val deferredEvent = async {
vm.event.first()
}
// when
`인증 번호를 확인하면`()
// then
assertThat(deferredEvent.await()).isEqualTo(StudentVerificationEvent.VerificationSuccess)
}
이렇게 하면 처음으로 방출되는 이벤트가 반환될 때까지 기다렸다가 검증하여 테스트가 정상적으로 작동한다.
이때 주의할 점은 각 코드의 즉각적인 실행을 위해 UnConfinedTestDispatcher 를 사용해야하고 예상 가능한 결과를 테스트할 수 있다.
테스트의 정상 작동은 확인했지만 그래도 아직 마음에 들지 않는다.
문제점3. 테스트는 편리해야 한다.
매번 deferred type 으로 기다리게 하고 await 으로 값을 체크하는 것은 용납할 수 없는 불편함이다.
그러다 찾은 것이 바로 turbine 이다.
turbine 은 Flow Test third-party 라이브러리로 공식문서에서 편리한 Flow 테스트를 위해 사용해라고 소개되어있다.
안드로이드 공식문서 Flow Test
https://developer.android.com/kotlin/flow/test
Turbine Github Repository
https://github.com/cashapp/turbine
이 turbine 라이브러리를 사용하면 turbine 스코프를 열 수 있는데 이를 사용하면 훨씬 간단하게 위의 테스트를 진행할 수 있다.
@Test
fun `... 인증 번호 확인이 성공하면 인증 성공 이벤트가 발생한다`() = runTest {
// given
...
vm.event.test { // turbineScope
// when
viewModel.confirmVerificationCode()
// then
assertThat(awaitItem()).isEqualTo(StudentVerificationEvent.VerificationSuccess)
cancelAndIgnoreRemainingEvents()
}
}
test turbine 스코프 내에서 awaitItem() 을 사용할 때마다 방출되는 값을 받을 수 있고 cancelAndIgnoreRemainingEvents() 를 사용해 남은 이벤트를 받지 않고 코루틴 스코프를 cancel 할 수 있다.
아무 이벤트도 일어나지 않음을 테스트하려면 expectNoEvents() 를 사용하면 된다.
Turbine 공식 문서 내용
Turbine 도 UnconfinedTestDispatcher 에 의존하고 있다. 이는 테스트를 더 쉽게 만들지만 이를 인지하고 사용해야 한다.
다음은 학습을 위해 작성한 테스트 코드이다.
class SharedFlowTest {
private val _sharedFlow = MutableSharedFlow<Int>(replay = 0)
val sharedFlow = _sharedFlow.asSharedFlow()
// 실패
@Test
fun `test sharedFlow in StandardTestDispatcher`() = runTest {
// when
_sharedFlow.emit(1)
// then
assertThat(sharedFlow.first()).isEqualTo(1)
}
// 실패
@OptIn(ExperimentalCoroutinesApi::class)
@Test
fun `test sharedFlow in UnconfinedTestDispatcher`() = runTest(UnconfinedTestDispatcher()) {
// when
_sharedFlow.emit(1)
// then
assertThat(sharedFlow.first()).isEqualTo(1)
}
// 실패
@Test
fun `test sharedFlow with async in StandardTestDispatcher`() = runTest {
val deferred = async {
sharedFlow.first()
}
// when
_sharedFlow.emit(1)
// then
assertThat(deferred.await()).isEqualTo(1)
}
// 성공
@OptIn(ExperimentalCoroutinesApi::class)
@Test
fun `test sharedFlow with async in UnconfinedTestDispatcher`() = runTest(UnconfinedTestDispatcher()) {
val deferred = async {
sharedFlow.first()
}
// when
_sharedFlow.emit(1)
// then
assertThat(deferred.await()).isEqualTo(1)
}
// 성공
@Test
fun `test sharedFlow with turbine in StandardTestDispatcher`() = runTest {
sharedFlow.test {
// when
_sharedFlow.emit(1)
// then
assertThat(awaitItem()).isEqualTo(1)
}
}
// 성공
@OptIn(ExperimentalCoroutinesApi::class)
@Test
fun `test sharedFlow with turbine in UnconfinedTestDispatcher`() = runTest(UnconfinedTestDispatcher()) {
sharedFlow.test {
// when
_sharedFlow.emit(1)
// then
assertThat(awaitItem()).isEqualTo(1)
}
}
이를 돌려보고 결과를 확인하면 더 쉽게 이해할 수 있을 것이다.
'Android' 카테고리의 다른 글
[안드로이드] Repository Pattern (저장소 패턴) (0) | 2023.10.26 |
---|---|
뷰가 그려지는 과정(View Lifecycle) 이해하기 : [우아한테크코스 5기 AN_베르] (0) | 2023.10.22 |
Reflection [우아한테크코스 5기 AN_베르] (0) | 2023.09.10 |
[안드로이드] 테스트 라이브러리 Robolectric (0) | 2023.09.03 |
[안드로이드] Flow 공식문서로 이해하기 - 2차 StateFlow & SharedFlow (0) | 2023.09.03 |