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으로 리턴을 받게 되면, UnitTest도 가능하고, 함수의 실행도 가능한 형태를 만들 수 있습니다.

기존 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 개발자)

cashapp/turbine

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을 함께 사용하는 방법입니다. 그럼 함수 호출을 직접이 아닌 간접적인 방법으로 사용할 수 있어집니다.

다음 글에서 한 번 더 추가해 보겠습니다.



About Taehwan

My name is Taehwan Kwon. I have developed Android for 6 years and blog has been active for eight years.

Comments