안녕하세요 점냥입니다 :)
개발에서 테스트의 중요성은 알고 있지만 개념이 어렵거나 테스트 환경 요인으로 인해 테스트 코드 작성의 어려움을 겪고 있어요. 특히 Unit Test는 로컬 JVM에서 실행되기 때문에 Android UI Thread을 관련된 비동기 코드를 테스트하는 것이 불가능하진 않지만 복잡하고 어렵더라고요. 여러분도 그러신가요?
그런데 최근 Flow 테스트 코드를 간결하게 작성할 수 있게 도와주는 Turbine이라는 라이브러리를 알게 되었어요. 안드로이드 공식문서에도 소개된 라이브러리로 공신력이 있고 최근 클론 코딩하고 있는 nowInAndroid 프로젝트에서도 사용한 테스트 라이브러리입니다. 그래서 이번 글은 Turbine 사용법에 대해서 간단히 알아보려고 해요 :)
Turbine
Turbine은 테스트 코드 작성에 도움을 주는 Flow<T>의 확장 함수를 제공해줘요. 참고로 모두 suspend 함수이기 때문에 코루틴 내부에서만 호출할 수 있어요. Turbine을 사용한 Flow TestCode를 살펴볼까요?
flowOf("one", "two").test { ----- (1)
assertEquals("one", awaitItem()) ---- (2)
assertEquals("two", awaitItem())
awaitComplete() ---- (3)
}
(1) 테스트 코드 작성하기
Turbine의 테스트 코드는 test 함수의 람다 내부에서 작성을 시작해요. test 함수를 실행하게 되면 내부적으로 Channel<T> 객체를 생성하고 Flow의 모든 요소를 collect를 통해 받아오기 시작해요.
(2) Flow 요소 가져오기
awaitItem 함수는 Flow 즉 test 함수로 만들어진 Channel에서 데이터가 방출될 때까지 지연돼요. Channel은 방출되는 요소의 순서를 보장해주기 때문에 첫 번째 awaitItem 함수에서는 "one" 문자열, 두 번째 awaitItem 함수에서는 " two" 문자열을 받게 됩니다 :)
(3) Flow 이벤트 처리
public sealed class Event<out T> {
public object Complete : Event<Nothing>() {
override fun toString(): String = "Complete"
}
public data class Error(val throwable: Throwable) : Event<Nothing>() {
override fun toString(): String = "Error(${throwable::class.simpleName})"
}
public data class Item<T>(val value: T) : Event<T>() {
override fun toString(): String = "Item($value)"
}
}
이 부분에서는 잠깐 Turbine의 특징을 알아야 해요. Turbine은 Flow의 요소들을 collect 하면서 Turbine 자체적인 Event로 변환을 하는데요. 이렇게 변환된 Event들을 모두 소비 혹은 처리하도록 강제하고 있어요.
flowOf("one", "two").test {
assertEquals("one", awaitItem())
assertEquals("two", awaitItem())
}
-결과-----
Unconsumed events found:
- Complete
app.cash.turbine.TurbineAssertionError: Unconsumed events found:
그래서 awaitComplete 함수를 빼고 테스트 코드를 실행하게 되면 Complete 이벤트를 소비하지 않았다는 에러 메시지와 함께 테스트가 실패하는 것을 확인할 수 있어요 ㅠㅠ
channelFlow {
withContext(IO) {
repeat(100) {
send("item $it")
}
}
}.test {
assertEquals("item 0", awaitItem())
cancelAndIgnoreRemainingEvents()
}
그런데 테스트하려는 Flow에서 방출되는 요소의 수가 엄청 많다면 모든 이벤트를 소비해야 한다는 강제성은 테스트 코드 작성에 불편함을 줄 수 있어요. 그래서 cancelAndIgnoreRemainingEvents() 함수를 통해 Channel을 close 시키면서 소비하지 않은 Event에 대한 책임을 회피할 수 있어요!
Exception 테스트하기
flow<Any> { throw RuntimeException("broken!") }.test {
assertEquals("broken!", awaitError().message)
}
때로 Flow 데이터 수집하는 과정에서 발생하는 Exception 조차도 테스트를 해보고 싶은 경우가 있어요. Turbine은 내부적으로 Exception 발생 시 해당 Exception을 catch 하여 Error Event로 변환시켜주기 때문에 별도의 try-catch 구문이 필요 없어요.
flow<Any> {
emit("data is first")
throw RuntimeException("broken!")
}.test {
assertEquals("broken!", awaitError().message)
assertEquals("data is first", awaitItem())
}
그런데 문득 이런 호기심이 생기더라고요. Data 요소를 모두 방출하고 Exception이 발생했을 때, awaitError 함수로 Exception을 먼저 가져오고 그다음 awaitItem 함수로 데이터를 가져오면 어떻게 될까 하는 호기심이요.
Expected error but found Item(data is first)
결과는 Item Event를 찾을 수 없다는 에러가 표시되었어요. awaitError() 함수는 Exception이 발생할 때까지 지연되는 함수이기 때문에 앞서 방출된 Data는 무시된 것 같아요.
시간을 따른 비동기 흐름 테스트하기
flowOf("one", "two", "three")
.map {
delay(100)
it
}
.test {
// 0 - 100ms -> no emission yet
// 100ms - 200ms -> "one" is emitted
// 200ms - 300ms -> "two" is emitted
// 300ms - 400ms -> "three" is emitted
delay(250)
assertEquals("two", expectMostRecentItem())
cancelAndIgnoreRemainingEvents()
}
또 시간에 따른 Flow의 데이터 변화를 테스트하고 싶은 경우가 있을 수 있어요. 이 부분은 Turbine의 2가지 특성을 이해해야 합니다.
- Turbine은 Flow<T>를 Channel<T>로 데이터를 재 수집하여 테스트를 진행하는데, Channel은 Hot Stream이기 때문에 데이터 받는 것을 보장해주지 않아요.
- Turbine은 Corotuine의 시간을 제어하는 기능에 대한 함수들은 제공해주지 않기 때문에 Java, Kotlin에서 제공해주는 sleep, delay 등의 함수들을 사용해야 합니다. kotlin-coroutine-test에서 제공해주는 advanceTimeBy 등의 함수를 사용해도 좋아요.
2가지 특성을 이해한 뒤 코드를 다시 보면 각 데이터는 100ms의 딜레이를 가지고 있어요. 그런데 test는 250ms가 지난 뒤 데이터를 수집한다고 하면 시간 상으로 이미 이전에 Flow로 방출된 "one" String은 awaitItem 함수로 접근할 수 없습니다. 하지만 마지막 데이터는 캐싱하고 있어서 expectMostRecentItem 함수를 통해 가장 최근에 방출된 "two" String은 가져온 것을 확인할 수 있네요!
마무리하기
테스트 코드 예제에서 선언한 Flow들이 값 2~3개 반환한다고 정말 참고용으로 대충 보려고 하실 수도 있는 데, 개인적인 경험으로 Flow에 5개 이상의 데이터가 방출되는 케이스가 많이 없더라고요. 그래서 위 예제 코드에서 사용한 방식만 숙지하신다면 웬만한 로직들은 다 테스트하실 수 있을 거라 생각해요!
그리고 이 글에서 다뤄보지 못한 Turbine에서 제공해주는 함수들이 많아요. Turbine 공식 Github 링크를 확인해서 추가적으로 확인하셔도 좋을 것 같아요!
참고 링크
- 안드로이드 |에서 Kotlin 흐름 테스트 안드로이드 개발자 (android.com)
'Android > Common' 카테고리의 다른 글
[Android] Very Long Vector Path 해결 (0) | 2022.11.18 |
---|---|
[Android] 디버그 앱, 출시 앱 분리하기 (4) | 2022.11.12 |
[Android] AAC ViewModel에서 Context 접근하는 방법 (0) | 2022.10.08 |
[Android] EventBus (0) | 2022.08.28 |
[Android] Bottom Navigation Bar State with Compose - (2) (0) | 2022.06.12 |