Способ тестирования блоков или модулей, взаимодействующих с потоком, зависит от того, использует ли тестируемый объект поток в качестве входных или выходных данных.
- Если тестируемый объект наблюдает поток, вы можете генерировать потоки в рамках фиктивных зависимостей, которые можно контролировать с помощью тестов.
- Если модуль или устройство предоставляет поток, вы можете прочитать и проверить один или несколько элементов, испускаемых потоком, в тесте.
Создание поддельного профиля продюсера
Если тестируемый объект является потребителем потока, то один из распространенных способов тестирования заключается в замене производителя фиктивной реализацией. Например, если у вас есть класс, который отслеживает репозиторий, получающий данные из двух источников в рабочей среде:
Чтобы сделать тест детерминированным, вы можете заменить репозиторий и его зависимости на поддельный репозиторий, который всегда выдает одни и те же поддельные данные:
Чтобы передать в поток заранее определенную серию значений, используйте конструктор 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
Сторонняя библиотека 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 так же, как и любые другие данные, в том числе с помощью 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 }
Работа с StateFlow, созданными с помощью 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- Тестирование сопрограмм