Тестирование потоков 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

Сторонняя библиотека 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)
}

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