(Kotlin/코틀린) Flow 테스트 — Turbine 라이브러리
1. Flow 테스트가 어려운 이유
Flow는 비동기 스트림이라 “언제 값이 나오는지”, “몇 개가 나오는지”, “에러가 나는지”를 검증하기가 번거롭습니다.
// 문제 상황: toList()로 모으면 무한 Flow나 에러 발생 시나리오 테스트가 어려움
@Test
fun badTest() = runTest {
val results = someFlow().toList() // Flow가 완료되지 않으면 영원히 대기
assertEquals(listOf(1, 2, 3), results)
}
Turbine은 Flow를 하나씩 순서대로 검증할 수 있는 테스트 전용 라이브러리입니다.
의존성 추가:
testImplementation("app.cash.turbine:turbine:1.2.0")
2. test { } — 기본 사용법
import app.cash.turbine.test
import kotlinx.coroutines.flow.flowOf
import kotlinx.coroutines.test.runTest
import org.junit.Test
class FlowTest {
@Test
fun `flow가 순서대로 값을 방출한다`() = runTest {
val flow = flowOf(1, 2, 3)
// test { }: Flow를 구독하고 방출되는 이벤트를 하나씩 검증할 수 있는 블록 시작
flow.test {
// awaitItem(): 다음 방출값을 기다렸다가 반환
assertEquals(1, awaitItem())
assertEquals(2, awaitItem())
assertEquals(3, awaitItem())
// awaitComplete(): Flow가 정상 종료되었는지 확인
awaitComplete()
}
}
}
test 블록의 원리:
test { }는 내부적으로 Flow를 구독하는 코루틴을 실행하고,awaitItem()호출마다 다음 이벤트가 도착할 때까지 (가상 시간으로) 대기합니다. 블록이 끝나면 자동으로 구독을 취소합니다.
3. awaitError — 에러 방출 검증
import kotlinx.coroutines.flow.flow
@Test
fun `데이터 조회 실패 시 에러가 방출된다`() = runTest {
val errorFlow = flow<Int> {
emit(1)
throw RuntimeException("네트워크 오류")
}
errorFlow.test {
assertEquals(1, awaitItem()) // 첫 값은 정상 방출
// awaitError(): 다음 이벤트가 예외인지 확인하고 그 예외를 반환
val error = awaitError()
assertEquals("네트워크 오류", error.message)
// 에러 이후에는 Flow가 종료된 것으로 간주 — 추가 awaitItem 불필요
}
}
4. expectNoEvents — 이벤트가 없음을 검증
특정 시점까지 아무 값도 방출되지 않아야 하는 시나리오를 검증합니다 (디바운스, 스로틀 등).
import kotlinx.coroutines.flow.debounce
import kotlinx.coroutines.flow.MutableSharedFlow
@Test
fun `debounce는 짧은 간격의 입력을 무시한다`() = runTest {
val searchQueryFlow = MutableSharedFlow<String>()
val debouncedFlow = searchQueryFlow.debounce(500)
debouncedFlow.test {
searchQueryFlow.emit("k")
searchQueryFlow.emit("ko")
searchQueryFlow.emit("kot")
// expectNoEvents(): 이 시점까지 아무 이벤트도 없어야 함 (아직 500ms 안 지남)
expectNoEvents()
advanceTimeBy(500) // 가상 시간을 500ms 진행 → debounce 시간 경과
// 마지막 입력값("kot")만 방출됨 (중간값들은 무시됨)
assertEquals("kot", awaitItem())
}
}
5. StateFlow / SharedFlow 테스트
ViewModel의 StateFlow를 테스트할 때 자주 마주치는 패턴입니다.
class ProductViewModel(private val repository: ProductRepository) : ViewModel() {
private val _uiState = MutableStateFlow(ProductUiState())
val uiState: StateFlow<ProductUiState> = _uiState.asStateFlow()
fun loadProducts() {
viewModelScope.launch {
_uiState.update { it.copy(isLoading = true) }
val products = repository.fetchProducts()
_uiState.update { it.copy(products = products, isLoading = false) }
}
}
}
@Test
fun `상품 로드 시 로딩 → 완료 순서로 상태가 변경된다`() = runTest {
val fakeRepository = FakeProductRepository()
val viewModel = ProductViewModel(fakeRepository)
viewModel.uiState.test {
// StateFlow는 구독 즉시 현재 값을 방출 — 초기 상태부터 검증
val initial = awaitItem()
assertEquals(false, initial.isLoading)
viewModel.loadProducts()
// 로딩 시작 상태
val loading = awaitItem()
assertEquals(true, loading.isLoading)
// 로딩 완료 상태
val loaded = awaitItem()
assertEquals(false, loaded.isLoading)
assertEquals(3, loaded.products.size)
// cancelAndIgnoreRemainingEvents(): StateFlow는 무한 스트림이므로
// 검증이 끝나면 명시적으로 구독 취소 (awaitComplete는 호출 불가)
cancelAndIgnoreRemainingEvents()
}
}
StateFlow는 완료되지 않는다:
StateFlow는 값을 계속 유지하는 무한 스트림이라awaitComplete()를 호출하면 영원히 대기합니다. 검증이 끝나면cancelAndIgnoreRemainingEvents()로 마무리해야 합니다.
6. skipItems / awaitItem 조합 — 중간값 건너뛰기
관심 없는 중간 이벤트는 건너뛰고 원하는 시점만 검증할 수 있습니다.
@Test
fun `여러 중간 상태 중 최종 결과만 검증한다`() = runTest {
viewModel.uiState.test {
skipItems(1) // 초기 상태 하나 건너뛰기
viewModel.loadProducts()
skipItems(1) // 로딩 중 상태 건너뛰기
val finalState = awaitItem() // 최종 완료 상태만 검증
assertEquals(3, finalState.products.size)
cancelAndIgnoreRemainingEvents()
}
}
7. 여러 Flow 동시 테스트 — turbineScope
두 개 이상의 Flow가 상호작용하는 시나리오를 검증할 때 사용합니다.
import app.cash.turbine.turbineScope
@Test
fun `이벤트 발생 시 상태와 알림이 함께 갱신된다`() = runTest {
turbineScope {
// 두 Flow를 동시에 구독하는 turbine 생성
val stateTurbine = viewModel.uiState.testIn(backgroundScope)
val eventTurbine = viewModel.uiEvent.testIn(backgroundScope)
viewModel.onSubmit()
// 각 Flow를 독립적으로 검증
assertEquals(true, stateTurbine.awaitItem().isSubmitting)
assertEquals(UiEvent.ShowToast("제출 완료"), eventTurbine.awaitItem())
stateTurbine.cancelAndIgnoreRemainingEvents()
eventTurbine.cancelAndIgnoreRemainingEvents()
}
}
8. 정리
| API | 용도 |
|---|---|
flow.test { } |
Flow 구독 및 이벤트 순차 검증 시작 |
awaitItem() |
다음 방출값 대기 후 반환 |
awaitError() |
다음 이벤트가 예외인지 확인 |
awaitComplete() |
Flow가 정상 종료되었는지 확인 (무한 Flow에는 사용 불가) |
expectNoEvents() |
특정 시점까지 이벤트 없음을 검증 |
skipItems(n) |
관심 없는 중간 이벤트 n개 건너뛰기 |
cancelAndIgnoreRemainingEvents() |
StateFlow 등 무한 스트림 검증 후 마무리 |
runTest와 함께 사용하면delay,debounce등 시간 기반 연산자도 가상 시간으로 빠르게 검증StateFlow는awaitComplete()대신 반드시cancelAndIgnoreRemainingEvents()로 종료- 여러 Flow를 동시에 검증할 때는
turbineScope+testIn(backgroundScope)사용