Android에서 Kotlin 흐름 테스트

흐름과 통신하는 단위나 모듈을 테스트하는 방법은 테스트 대상이 흐름을 입력으로 사용하는지 또는 출력으로 사용하는지에 따라 다릅니다.

  • 테스트 대상이 흐름을 식별하는 경우에는 테스트에서 제어할 수 있는 모조 종속 항목 내에 흐름을 생성할 수 있습니다.
  • 단위나 모듈이 흐름을 노출하는 경우에는 테스트에서 흐름이 내보내는 항목을 하나 이상 읽고 확인할 수 있습니다.

모조 생산자 만들기

테스트 대상이 흐름의 소비자인 경우 한 가지 일반적인 테스트 방법은 생산자를 모조 구현으로 대체하는 것입니다. 예를 들어 프로덕션 환경의 두 데이터 소스에서 데이터를 가져오는 저장소를 식별하는 클래스가 있다고 가정해 보겠습니다.

테스트 대상 및 데이터 영역
그림 1. 테스트 대상 및 데이터 영역

확정된 테스트를 만들려면 저장소와 종속 항목을 항상 동일한 모조 데이터를 내보내는 모조 저장소로 대체하면 됩니다.

종속 항목이 모조 구현으로 대체됨
그림 2. 종속 항목이 모조 구현으로 대체됨

흐름에 사전 정의된 일련의 값을 내보내려면 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
    ...
}

이제 테스트 대상의 출력을 제어할 수 있으므로 출력을 확인하여 올바르게 작동하는지 확인할 수 있습니다.

테스트에서 흐름 내보내기 항목 어설션하기

테스트 대상이 흐름을 노출하는 경우에는 테스트에서 데이터 스트림 요소에 관해 어설션을 만들어야 합니다.

이전 예시의 저장소가 흐름을 노출한다고 가정해 보겠습니다.

흐름을 노출하는 모조 종속 항목이 있는 저장소
그림 3. 흐름을 노출하는 모조 종속 항목이 있는 저장소(테스트 대상).

특정 테스트에서는 흐름에서 내보낸 항목 중 첫 번째 항목 또는 일정 수의 항목만 확인하면 됩니다.

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를 테스트할 때 모조 구현에서 값을 내보내 ViewModelStateFlow에서 업데이트를 트리거한 다음 업데이트된 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로 작업하기

이전 섹션에서 ViewModelMutableStateFlow를 사용하여 Repository의 흐름에서 내보낸 최신 값을 저장합니다. 이는 일반적인 패턴이며 보통 콜드 흐름을 핫 StateFlow로 변환하는 stateIn 연산자를 사용하여 더 간단하게 구현됩니다.

class MyViewModelWithStateIn(myRepository: MyRepository) : ViewModel() {
    val score: StateFlow<Int> = myRepository.scores()
        .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000L), 0)
}

stateIn 연산자에는 활성 상태가 되고 기본 흐름을 소비하기 시작하는 시점을 결정하는 SharingStarted 매개변수가 있습니다. SharingStarted.LazilySharingStarted.WhileSubscribed와 같은 옵션은 뷰 모델에서 자주 사용됩니다.

테스트에서 StateFlowvalue에 어설션하더라도 수집기를 만들어야 합니다. 수집기가 비어 있어도 됩니다.

@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)
}

추가 리소스