Способ тестирования модулей, взаимодействующих с потоком данных , зависит от того, использует ли тестируемый объект поток данных в качестве входных или выходных данных.
- Если тестируемый объект наблюдает определенный поток, вы можете генерировать потоки внутри фиктивных зависимостей, которыми можно управлять из тестов.
- Если модуль или блок предоставляет доступ к потоку данных, вы можете считывать и проверять один или несколько элементов, генерируемых этим потоком в ходе тестирования.
Создание фиктивного продюсера
Когда объектом тестирования является потребитель потока данных, один из распространенных способов проверки — замена производителя на фиктивную реализацию. Например, рассмотрим класс, который отслеживает репозиторий, получающий данные из двух источников данных в рабочей среде:

Чтобы сделать тест детерминированным, можно заменить репозиторий и его зависимости фиктивным репозиторием, который всегда выдает одни и те же фиктивные данные:

Для вывода предопределенной последовательности значений в потоке используйте конструктор 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 // ... }
Теперь, когда вы контролируете выходные параметры тестируемого объекта, вы можете убедиться в его корректной работе, проверив его выходные данные.
Проверка выбросов потока в ходе испытания
Если проверяемый объект представляет собой поток данных, тест должен делать утверждения относительно элементов этого потока.
Предположим, что репозиторий из предыдущего примера предоставляет доступ к потоку данных:

При проведении некоторых тестов вам потребуется проверить только первую эмиссию или конечное число элементов, поступающих из потока.
Вы можете обработать первый отправленный в поток сигнал, вызвав функцию 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 myRepositoryTestList() = runTest { val repository = MyFakeRepository() // 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) }
Для потоков данных, требующих более сложной коллекции элементов или не возвращающих конечное число элементов, можно использовать API Flow для выбора и преобразования элементов. Вот несколько примеров:
// 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 . В этом примере мы собираем их в список, а затем выполняем проверки его содержимого:
@OptIn(ExperimentalCoroutinesApi::class) @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 предлагает удобный API для создания сопрограммы сбора данных, а также другие удобные функции для тестирования 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()) } }
Для получения более подробной информации обратитесь к документации библиотеки .
Тестирование StateFlows
StateFlow — это наблюдаемый контейнер данных, который можно собирать для наблюдения за значениями, которые он содержит, в виде потока. Следует отметить, что этот поток значений является объединенным, что означает, что если значения в StateFlow устанавливаются быстро, сборщики этого StateFlow не гарантируют получение всех промежуточных значений, а только самого последнего.
В тестах, если учитывать принцип конфляционного анализа, значения StateFlow можно собирать так же, как и значения любого другого потока, в том числе и с помощью Turbine. В некоторых тестовых сценариях может быть целесообразно попытаться собрать и проверить все промежуточные значения.
Однако мы обычно рекомендуем рассматривать StateFlow как контейнер данных и проверять его value . Таким образом, тесты проверяют текущее состояние объекта в данный момент времени и не зависят от того, происходит ли конфляция.
Например, возьмем эту ViewModel , которая собирает значения из Repository и предоставляет их пользовательскому интерфейсу через 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 } } } }
Пример реализации для этого Repository может выглядеть следующим образом:
class FakeRepository : MyRepository { private val flow = MutableSharedFlow<Int>() suspend fun emit(value: Int) = flow.emit(value) override fun scores(): Flow<Int> = flow }
При тестировании ViewModel с помощью этого фиктивного объекта вы можете передавать значения из фиктивного объекта для запуска обновлений в StateFlow ViewModel , а затем проверять обновленное 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 }
Работа с потоками состояний (StateFlows), созданными с помощью stateIn.
В предыдущем разделе ViewModel использует MutableStateFlow для хранения последнего значения, выданного потоком из Repository . Это распространенный шаблон, обычно реализуемый более простым способом с помощью оператора stateIn , который преобразует «холодный» поток в «горячий» StateFlow :
class MyViewModelWithStateIn(myRepository: MyRepository) : ViewModel() { val score: StateFlow<Int> = myRepository.scores() .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000L), 0) }
Оператор stateIn имеет параметр SharingStarted , который определяет, когда он становится активным и начинает потреблять базовый поток. Такие параметры, как SharingStarted.Lazily и SharingStarted.WhileSubscribed , часто используются в моделях представления.
Даже если вы проверяете value StateFlow в своем тесте, вам все равно потребуется создать коллектор. Это может быть пустой коллектор:
@OptIn(ExperimentalCoroutinesApi::class) @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) }
Дополнительные ресурсы
- Тестирование сопрограмм Kotlin на Android
- Kotlin работает на Android.
-
StateFlowиSharedFlow - Дополнительные ресурсы по сопрограммам и алгоритмам потока выполнения в Kotlin.