흐름과 통신하는 단위나 모듈을 테스트하는 방법은 테스트 대상이 흐름을 입력으로 사용하는지 또는 출력으로 사용하는지에 따라 다릅니다.
- 테스트 대상이 흐름을 식별하는 경우에는 테스트에서 제어할 수 있는 모조 종속 항목 내에 흐름을 생성할 수 있습니다.
- 단위나 모듈이 흐름을 노출하는 경우에는 테스트에서 흐름이 내보내는 항목을 하나 이상 읽고 확인할 수 있습니다.
모조 생산자 만들기
테스트 대상이 흐름의 소비자인 경우 한 가지 일반적인 테스트 방법은 생산자를 모조 구현으로 대체하는 것입니다. 예를 들어 프로덕션 환경의 두 데이터 소스에서 데이터를 가져오는 저장소를 식별하는 클래스가 있다고 가정해 보겠습니다.
![테스트 대상 및 데이터 영역](https://developer.android.com/static/images/kotlin/flow/sut-data-layer.png?authuser=0&hl=ko)
확정된 테스트를 만들려면 저장소와 종속 항목을 항상 동일한 모조 데이터를 내보내는 모조 저장소로 대체하면 됩니다.
![종속 항목이 모조 구현으로 대체됨](https://developer.android.com/static/images/kotlin/flow/fake-implementation.png?authuser=0&hl=ko)
흐름에 사전 정의된 일련의 값을 내보내려면 flow
빌더를 사용합니다.
class MyFakeRepository : MyRepository {
fun observeCount() = flow {
emit(ITEM_1)
}
}
테스트에서 이 모조 저장소를 삽입하여 실제 구현을 대체합니다.
@Test
fun myTest() {
// Given a class with fake dependencies:
val sut = MyUnitUnderTest(MyFakeRepository())
// Trigger and verify
...
}
이제 테스트 대상의 출력을 제어할 수 있으므로 출력을 확인하여 올바르게 작동하는지 확인할 수 있습니다.
테스트에서 흐름 내보내기 항목 어설션하기
테스트 대상이 흐름을 노출하는 경우에는 테스트에서 데이터 스트림 요소에 관해 어설션을 만들어야 합니다.
이전 예시의 저장소가 흐름을 노출한다고 가정해 보겠습니다.
![흐름을 노출하는 모조 종속 항목이 있는 저장소](https://developer.android.com/static/images/kotlin/flow/test-exposed-flow.png?authuser=0&hl=ko)
특정 테스트에서는 흐름에서 내보낸 항목 중 첫 번째 항목 또는 일정 수의 항목만 확인하면 됩니다.
first()
를 호출하여 첫 번째 항목을 흐름에 사용할 수 있습니다. 다음 함수는 첫 번째 항목이 수신될 때까지 대기한 후 생산자에게 취소 신호를 전송합니다.
@Test
fun myRepositoryTest() = runTest {
// Given a repository that combines values from two data sources:
val repository = MyRepository(fakeSource1, fakeSource2)
// When the repository emits a value
val firstItem = repository.counter.first() // Returns the first item in the flow
// Then check it's the expected item
assertEquals(ITEM_1, firstItem)
}
테스트에서 여러 값을 확인해야 하는 경우 toList()
를 호출하면 흐름은 소스가 모든 값을 내보낼 때까지 대기했다가 이 값을 목록으로 반환합니다. 이 방법은 유한한 데이터 스트림에서만 작동합니다.
@Test
fun myRepositoryTest() = runTest {
// Given a repository with a fake data source that emits ALL_MESSAGES
val messages = repository.observeChatMessages().toList()
// When all messages are emitted then they should be ALL_MESSAGES
assertEquals(ALL_MESSAGES, messages)
}
더 복잡한 항목 컬렉션이 필요하거나 한정된 수의 항목을 반환하지 않는 데이터 스트림의 경우에는 Flow
API를 사용하여 항목을 선택하고 변환할 수 있습니다. 다음은 몇 가지 예입니다.
// Take the second item
outputFlow.drop(1).first()
// Take the first 5 items
outputFlow.take(5).toList()
// Takes the first item verifying that the flow is closed after that
outputFlow.single()
// Finite data streams
// Verify that the flow emits exactly N elements (optional predicate)
outputFlow.count()
outputFlow.count(predicate)
테스트 중 연속 수집
이전 예시처럼 toList()
를 사용하여 흐름을 수집하면 내부적으로 collect()
가 사용되며, 전체 결과 목록을 반환할 준비가 될 때까지 정지됩니다.
흐름에서 내보낸 값과 그 값에 대한 어설션을 내보내는 작업을 인터리브 처리하려면 테스트 중에 흐름에서 값을 연속적으로 수집하면 됩니다.
예를 들어, 테스트할 Repository
클래스, 그리고 이와 함께 제공되는 모조 데이터 소스 구현을 사용해 보겠습니다. 이 구현에는 emit
메서드가 있어 테스트 중에 동적으로 값을 생성할 수 있습니다.
class Repository(private val dataSource: DataSource) {
fun scores(): Flow<Int> {
return dataSource.counts().map { it * 10 }
}
}
class FakeDataSource : DataSource {
private val flow = MutableSharedFlow<Int>()
suspend fun emit(value: Int) = flow.emit(value)
override fun counts(): Flow<Int> = flow
}
테스트에서 이 모조 구현을 사용하면 연속적으로 Repository
에서 값을 수신하는 수집 코루틴을 만들 수 있습니다. 이 예에서는 값을 수집하여 목록으로 만든 후 목록의 콘텐츠에 관해 어설션을 실행합니다.
@Test
fun continuouslyCollect() = runTest {
val dataSource = FakeDataSource()
val repository = Repository(dataSource)
val values = mutableListOf<Int>()
backgroundScope.launch(UnconfinedTestDispatcher(testScheduler)) {
repository.scores().toList(values)
}
dataSource.emit(1)
assertEquals(10, values[0]) // Assert on the list contents
dataSource.emit(2)
dataSource.emit(3)
assertEquals(30, values[2])
assertEquals(3, values.size) // Assert the number of items collected
}
여기서 Repository
에서 노출한 흐름은 완료되지 않으므로 값을 수집하는 toList
함수는 반환되지 않습니다. TestScope.backgroundScope
에서 수집 코루틴을 시작하면 테스트가 종료되기 전에 코루틴이 취소됩니다. 그러지 않으면 runTest
가 완료될 때까지 계속 대기하여 테스트가 응답을 중지하고 최종적으로 실패합니다.
여기에서 수집 코루틴에 UnconfinedTestDispatcher
가 어떻게 사용되는지 살펴보세요. 이렇게 하면 launch
가 반환된 후 수집 코루틴이 실행되고 값을 수신할 준비가 됩니다.
Turbine 사용하기
서드 파티 Turbine 라이브러리는 수집 코루틴을 만드는 편리한 API와 흐름을 테스트하는 기타 편의 기능을 제공합니다.
@Test
fun usingTurbine() = runTest {
val dataSource = FakeDataSource()
val repository = Repository(dataSource)
repository.scores().test {
// Make calls that will trigger value changes only within test{}
dataSource.emit(1)
assertEquals(10, awaitItem())
dataSource.emit(2)
awaitItem() // Ignore items if needed, can also use skip(n)
dataSource.emit(3)
assertEquals(30, awaitItem())
}
}
자세한 내용은 라이브러리 문서를 참고하세요.
StateFlow 테스트
StateFlow
는 관찰 가능한 데이터 홀더로, 시간이 지남에 따라 스트림으로 보관되는 값을 관찰하기 위해 수집할 수 있습니다. 이 값 스트림은 혼합되어 있습니다. 즉, 값이 StateFlow
에 빠르게 설정되면 StateFlow
의 수집기가 최신 값만 수신하고 모든 중간 값의 수신을 보장하지 않습니다.
테스트에서 혼합을 염두에 두고 있으면 터빈을 포함한 다른 흐름을 수집할 수 있으므로 StateFlow
값을 수집할 수 있습니다. 일부 테스트 시나리오에서는 모든 중간 값을 수집하고 어설션하도록 시도하는 것이 바람직할 수 있습니다.
하지만 일반적으로 StateFlow
를 데이터 홀더로 취급하고 value
속성에 어설션하는 것이 좋습니다. 이렇게 하면 테스트가 특정 시점에 객체의 현재 상태를 확인하며 혼합이 발생하는지 여부에 따라 달라지지 않습니다.
예를 들어 Repository
에서 값을 수집하고 StateFlow
의 UI에 노출하는 ViewModel을 살펴보겠습니다.
class MyViewModel(private val myRepository: MyRepository) : ViewModel() {
private val _score = MutableStateFlow(0)
val score: StateFlow<Int> = _score.asStateFlow()
fun initialize() {
viewModelScope.launch {
myRepository.scores().collect { score ->
_score.value = score
}
}
}
}
이 Repository
에 관한 모조 구현은 다음과 같습니다.
class FakeRepository : MyRepository {
private val flow = MutableSharedFlow<Int>()
suspend fun emit(value: Int) = flow.emit(value)
override fun scores(): Flow<Int> = flow
}
이 모조 구현을 사용하는 ViewModel을 테스트할 때 모조 구현에서 값을 내보내 ViewModel의 StateFlow
에서 업데이트를 트리거한 다음 업데이트된 value
에서 어설션할 수 있습니다.
@Test
fun testHotFakeRepository() = runTest {
val fakeRepository = FakeRepository()
val viewModel = MyViewModel(fakeRepository)
assertEquals(0, viewModel.score.value) // Assert on the initial value
// Start collecting values from the Repository
viewModel.initialize()
// Then we can send in values one by one, which the ViewModel will collect
fakeRepository.emit(1)
assertEquals(1, viewModel.score.value)
fakeRepository.emit(2)
fakeRepository.emit(3)
assertEquals(3, viewModel.score.value) // Assert on the latest value
}
stateIn으로 생성된 StateFlow로 작업하기
이전 섹션에서 ViewModel은 MutableStateFlow
를 사용하여 Repository
의 흐름에서 내보낸 최신 값을 저장합니다. 이는 일반적인 패턴이며 일반적으로 콜드 흐름을 핫 StateFlow
로 변환하는 stateIn
연산자를 사용하여 더 간단하게 구현됩니다.
class MyViewModelWithStateIn(myRepository: MyRepository) : ViewModel() {
val score: StateFlow<Int> = myRepository.scores()
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000L), 0)
}
stateIn
연산자에는 활성 상태가 되고 기본 흐름을 소비하기 시작하는 시점을 결정하는 SharingStarted
매개변수가 있습니다. SharingStarted.Lazily
및 SharingStarted.WhileSubsribed
와 같은 옵션은 ViewModel에서 자주 사용됩니다.
테스트에서 StateFlow
의 value
에 어설션하더라도 수집기를 만들어야 합니다. 수집기가 비어 있어도 됩니다.
@Test
fun testLazilySharingViewModel() = runTest {
val fakeRepository = HotFakeRepository()
val viewModel = MyViewModelWithStateIn(fakeRepository)
// Create an empty collector for the StateFlow
backgroundScope.launch(UnconfinedTestDispatcher(testScheduler)) {
viewModel.score.collect()
}
assertEquals(0, viewModel.score.value) // Can assert initial value
// Trigger-assert like before
fakeRepository.emit(1)
assertEquals(1, viewModel.score.value)
fakeRepository.emit(2)
fakeRepository.emit(3)
assertEquals(3, viewModel.score.value)
}