Тестирование потоков Kotlin на Android

Способ тестирования модулей, взаимодействующих с потоком данных , зависит от того, использует ли тестируемый объект поток данных в качестве входных или выходных данных.

  • Если тестируемый объект наблюдает определенный поток, вы можете генерировать потоки внутри фиктивных зависимостей, которыми можно управлять из тестов.
  • Если модуль или блок предоставляет доступ к потоку данных, вы можете считывать и проверять один или несколько элементов, генерируемых этим потоком в ходе тестирования.

Создание фиктивного продюсера

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

Предмет исследования и слой данных
Рисунок 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 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)
}

Дополнительные ресурсы