Тестирование уровня данных в Android Room с помощью Rxjava, LiveData и сопрограмм Kotlin

В чем смысл начинать тестирование с уровня данных?

Выбор архитектуры, будь то MVVM, MVP, MVC или MV, по минимуму затрагивает уровень данных. Во время архитектурных миграций он остается прежним.

Уровень данных содержит минимальные зависимости, что делает его очень простым в тестировании. Его можно протестировать модульными тестами с помощью Robolectric (что экономит время)

Что такое уровень данных?

Этот уровень отвечает за предоставление данных для приложения с помощью сетевых запросов и локальной персистентности (т.е. базы данных пространств). Он формирует основу для бизнес-уровня и уровня представления.

Типы данных в слое данных

Есть два способа, с помощью которых бизнес-уровень может запрашивать данные: одноразовый запрос и поток данных.

  • Однократный запрос, или ван-шот (One-shot). Вы запрашиваете данные у БД, и она возвращает значение.
  • Поток (Stream). Это в основном шаблон Pub-Sub. Вместо того, чтобы запрашивать данные из базы данных, вы подписываетесь на нее. БД уведомит вас (наблюдателя) о любых изменениях в конкретных данных.

Мы разберем тестовые примеры для RxJava/RxKotlin, сопрограмм и живых данных. Вы можете сразу перейти к тому, что задействовано у вас в приложении.

Демонстрация

Рассмотрим раздел приложения для покупок, где выводится список продуктов. Вы можете увеличить/уменьшить количество продуктов, находящихся в корзине. Также отображается суммарная стоимость всех товаров в корзине в режиме реального времени.

  • Список продуктов → однократная операция.
  • Объем корзины в реальном времени → поток данных. Он изменяется при изменении количества товара (приращение/уменьшение).

База данных Room и Rxjava

Создание DAO (объекта доступа к данным):

  1. Вставка  —  это завершаемая (Completable) операция.
  2. Однократная операция извлечения продуктов  —  это однократная (Single) операция.
  3. Поток данных объема корзины  — это объект наблюдения (Observable).
@Dao
interface RxProductDao {

    @Insert
    fun insertAll(products: List<Product>): Completable

    // Однократная операция
    @Query("select * from product")
    fun getProductsInCart(): Single<List<Product>>

    // Поток данных
    @Query("select SUM( quantity * price ) from product")
    fun getCartAmount(): Observable<Double>
}

Тестирование в RxJava

RxJava test utils предоставляет метод .test(). Он создает наблюдателя TestObserver в источнике данных и подписывается на него. Таким образом, при подписке вы сразу получаете значение из источника данных, и, следовательно, можете проверить это значение.

Приведенный ниже пример проясняет это.

  1. Тестирование однократной операции выборки списка продуктов

(Пожалуйста, следуйте комментариям в коде)

class RxProductDaoTest {

  lateinit var repository: RxProductDao
  ..
  ..

  @Test
  fun getProductsSingleTest() {
    // 1. Создаем пять продуктов и помещаем их все в хранилище
    val testProducts = DataProvider.getProducts(5)
    repository.insertAll(testProducts).test()
    
    // 2 . Подтягиваем продукты и проверяем, все ли пять размещённых продуктов вернулись 
    repository.getProductsIncart().test()
      .assertValue { cachedProducts ->
        areContentsSame(testProducts, cachedProducts)
      }
    
  }
  
  private areContentsSame(list1: List<Product>, list2: List<Product>): Boolean { .. }
  
}

2. Тестирование потока данных для CartAmount в реальном времени

(Пожалуйста, следуйте комментариям в коде)

class RxProductDaoTest {

  lateinit var repository: RxProductDao
  ..
  ..

    @Test
    fun getCartAmountObservableTest() {
        // 1. Создаем пять продуктов и помещаем их все в хранилище
        val testProducts = DataProvider.getProducts(5)
        repository.insertAll(testProducts).test()

        // 2. Вычисляем ожидаемую цену
        var expectedPrice = 0.0
        testProducts.forEach { expectedPrice += it.price * it.quantity }

        // 3. Проверяем, что ожидаемая и действительная цена совпадают
        repository.getCartAmountObservable().test().assertValue { it == expectedPrice }

        // 4. Добавляем в хранилище еще один продукт 
        val testProduct = DataProvider.getProduct(6)
        repository.insert(testProduct).test()

        // 5. Вычисляем обновлённую цену
        val updatePrice = expectedPrice + (testProduct.quantity * testProduct.price)

        // 6. Проверяем, что ожидаемая и действительная цена совпадают
        repository.getCartPriceFlow().test().assertValue { it == updatePrice }
    }
  

  
}

База данных Room и сопрограммы (корутины)

В Room есть поддержка сопрограмм. Запросы выполняются на кастомном диспетчере. Сопрограммы известны своей последовательностью. Но единственное условие  —  функции должны быть типа suspend.

Создание DAO (Объекта доступа к данным)

  1. Однократная операция извлечения продуктов  —  это функция приостановки (suspend).
  2. Объем корзины (поток данных) объявляется как поток (Flow) из Double.
@Dao
interface CoroutinesProductDao {

    @Insert
    suspend fun insertAll(products: List<Product>)

    // Однократная операция
    @Query("select * from product")
    suspend fun getProductsInCart(): List<Product>

    // Поток данных
    @Query("select SUM( quantity * price ) from product")
    fun getCartAmount(): Flow<Double>
}

Тестирование в Room-ktx

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

1.Тестирование работы с однократной операцией

Вот код:

class CoroutinesProductDaoTest {

    private lateinit var productRepository: CoroutinesProductDao
    ..
    ..

    @Test
    fun insertAll() {
        runBlocking {
            // 1. Создаем пять тестовых продуктов и отправляем в хранилище
            val testProducts = DataProvider.getProducts(5)
            productRepository.insertAll(testProducts)

            // 2. Извлекаем продукты из хранилища
            val cachedProducts = productRepository.getProductsIncart()

            // 3. Проверяем, что количество извлеченных продуктов совпадает с ожидаемым
            assertEquals(testProducts, cachedProducts)
        }
    }

}

2.Тестирование потока данных

Вот код:

class CoroutinesProductDaoTest {

    private lateinit var productRepository: CoroutinesProductDao
    ..
    ..


    @Test
    fun getCartPrice() {
        runBlocking {
            // 1. Создаем пять тестовых продуктов и отправляем в хранилище
            val testProducts = DataProvider.getProducts(5)
            productRepository.insertAll(testProducts)

            // 2. Вычисляем ожидаемую цену
            var expectedPrice = 0.0
            testProducts.forEach { expectedPrice += it.price * it.quantity }

            // 3. Извлекаем цену и проверяем, соответствует ли она ожидаемой
            val price = productRepository.getCartPriceFlow().take(1).toList()[0]
            assert(expectedPrice == price)

            // 4. Добавляем еще продукт
            val testProduct = DataProvider.getProduct(6)
            productRepository.insert(testProduct)

            // 5. Вычисляем ожидаемую цену
            val updatePrice = expectedPrice + (testProduct.quantity * testProduct.price)

            // 6. Сравниваем ожидаемую и действительную величину
            assert(updatePrice == productRepository.getCartPriceFlow().take(1).toList()[0])

        }
    }

}

Использование живых данных

Создание DAO (Объекта доступа к данным)

  1. Размещение и извлечение продуктов  —  это нормальные функции. Их необходимо запускать вне основного потока.
  2. Сумма товаров в корзине (Cart Amount), представляющая собой поток данных, объявляется как LiveData.
@Dao
interface ProductDaoAAC {

    @Insert
    fun insertAll(products: List<Product>)

    @Query("select * from product")
    fun getProductsInCart(): List<Product>

    @Query("select SUM( quantity * price ) from product")
    fun getCartAmount(): LiveData<Double>
}

Тестирование на живых данных

  1. Для однократной операции выполните обычный вызов, убедившись, что он выполняется вне основного потока.
  2. Для потока данных нам необходимо подписаться на текущие данные. Потому что он не будет выдавать значения в отсутствие активных наблюдателей. У LiveData есть расширительная функция getOrAwait(), которая мгновенно даст нам значение живых данных. Она позаимствована из этого репозитория GitHub.

Вот код:

class ProductRepositoryAACImplTest { 

  lateinit var repository: ProductDaoAAC
  ..
  ..
  
  @Test
    fun getCartAmountLiveDataTest() {
    
        // 1. Создаем пять тестовых продуктов и отправляем в хранилище
        val testProducts = DataProvider.getProducts(5)
        repository.insertAll(testProducts)

        //  2. Вычисляем ожидаемую цену
        var expectedPrice = 0.0
        testProducts.forEach { expectedPrice += it.price * it.quantity }

        // 3. Извлекаем цену и проверяем, соответствует ли она ожидаемой
        // функция getOrAwait() возвращает значение, предоставляя тестового активного подписчика
        var price = repository.getCartPriceLiveData().getOrAwaitValue()
        assertEquals(expectedPrice, price, 0.0)

        // 4. Добавляем еще один продукт
        val anotherProduct = DataProvider.getProduct(6)
        repository.insert(anotherProduct)

        // 5. Вычисляем ожидаемую цену
        expectedPrice += anotherProduct.price * anotherProduct.quantity

        // 6. Снова извлекаем сумму товаров в корзину. И сравниваем ожидаемую и действительную величину
        price = repository.getCartPriceLiveData().getOrAwaitValue()
        
        assertEquals(expectedPrice, price, 0.0)


    }

}

Спасибо за чтение!

Читайте также:

Читайте нас в Telegram, VK и Яндекс.Дзен


Перевод статьи: Anup Ammanavar, “Testing the Data Layer in Android Room With Rxjava, Live Data, and Kotlin Coroutines”

Предыдущая статьяКак добиться от моделей глубокого обучения большей генерализации?
Следующая статьяJavaScript: 5 нововведений 2021 года