Testing Kotlin data flows on Android

Article directory

    • 1. Create a fictitious data provider
    • 2. Assert that the data stream is emitted in the test
      • Collect continuously during testing
    • 3. Test StateFlow
      • StateFlow created using stateIn

Reprinted from:


https://developer.android.google.cn/kotlin/flow/test?hl=zh-cn#producer

How a unit or module that communicates with a data stream is tested depends on whether the object under test uses the data stream as input or output

  • If the object under test observes the data flow, you can generate the data flow in fictitious dependencies, which can be controlled by the test
  • If a unit or module exposes a dataflow, you can read and verify one or more data items emitted by the dataflow under test

1 Create a fictional data provider

When the object under test is a data flow consumer, a common testing approach is to replace the provider with a fictional implementation.

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
    ...
}

Two Assert that the data stream is emitted in the test

1. Some tests, you only need to check the first emitted item from the data stream or a limited number of items

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

2. If the test needs to check multiple values, calling toList() will cause the data flow to wait for the data source to emit all its values, and then return these values in the form of a list

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

3. For data flows that require more complex collection of data items or that do not return limited data items, you can use the Flow API to select and transform data items.

// 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)

Continuous collection during testing

When using a fictional implementation in your tests, you can create a collection coroutine that continuously receives values from the Repository

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
}

You can create a collection coroutine that continuously receives values from the 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
}

Since the data stream exposed by the Repository here never completes, the toList call that collects it never returns.
Using the Turbine library
The third-party Turbine library provides a convenient API for creating collection coroutines, as well as other convenient functions for testing data flows

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

Three tests StateFlow

StateFlow is an observable data store that can be collected to observe its stored values over time in the form of a data flow.

The following ViewModel collects values from the Repository and provides the values to the interface in StateFlow

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

A fictional implementation of this Repository might look like this:

class FakeRepository : MyRepository {<!-- -->
    private val flow = MutableSharedFlow<Int>()
    suspend fun emit(value: Int) = flow.emit(value)
    override fun scores(): Flow<Int> = flow
}

When testing a ViewModel using this fictional implementation, you can emit a value from the fictional implementation to trigger an update in the ViewModel’s StateFlow, and then assert on the updated 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
}

StateFlow created using stateIn

ViewModel uses MutableStateFlow to store the latest value emitted by the data flow in the Repository. This is a common pattern, often implemented in a simpler way using the stateIn operator, which converts a cold data flow into a hot StateFlow:

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