Как создать PagedList объекта для тестов?

Я работаю с PagedList от Google, но одна вещь, которая затрудняет тестирование, - это работа с PagedList.

В этом примере я использую шаблон репозитория и возвращаю информацию из API или сети.

Поэтому в ViewModel я вызываю этот метод интерфейса:

override fun getFoos(): Observable<PagedList<Foo>>

Затем репозиторий будет использовать RxPagedListBuilder для создания Observable который имеет тип PagedList:

 override fun getFoos(): Observable<PagedList<Foo>> =
            RxPagedListBuilder(database.fooDao().selectAll(), PAGED_LIST_CONFIG).buildObservable()

Я хочу, чтобы тесты могли настраивать возврат из этих методов, которые возвращают PagedList<Foo>. Что-то похожее

when(repository.getFoos()).thenReturn(Observable.just(TEST_PAGED_LIST_OF_FOOS)

Два вопроса:

  1. Это возможно?
  2. Как создать PagedList<Foo>?

Моя цель состоит в том, чтобы убедиться в более полном конце (например, убедиться, что на экране отображается правильный список Foos). Фрагмент/активность/представление - это тот, который наблюдает PagedList<Foo> из ViewModel.

Ответ 1

простой способ добиться этого, - издеваться над PagedList. Эта забава будет "преобразовывать" список в PagedList (в этом случае мы не используем реальную PagedList, а просто издеваемую версию, если вам нужны другие методы PagedList для их добавления, добавьте их в это удовольствие)

 fun <T> mockPagedList(list: List<T>): PagedList<T> {
     val pagedList = Mockito.mock(PagedList::class.java) as PagedList<T>
     Mockito.'when'(pagedList.get(ArgumentMatchers.anyInt())).then { invocation ->
        val index = invocation.arguments.first() as Int
        list[index]
     }
     Mockito.'when'(pagedList.size).thenReturn(list.size)
     return pagedList
 }

Ответ 2

  1. Вы не можете использовать List to PagedList.
  2. Вы не можете создавать PagedList напрямую, только через DataSource. Одним из способов является создание FakeDataSource, возвращающего тестовые данные.

Если это сквозной тест, вы можете просто использовать in-memory db. Добавьте свои тестовые данные перед вызовом. Пример: https://medium.com/exploring-android/android-architecture-components-testing-your-room-dao-classes-e06e1c9a1535

Ответ 3

Преобразование списка в PagedList с фиктивным DataSource.Factory

@saied89 поделился этим решением в этой проблеме googlesamples/android-Architecture-components. Я реализовал фиктивный PagedList в Coinverse Open App для локального модульного тестирования ViewModel с использованием библиотек Kotlin, JUnit 5, MockK и AssertJ.

Для наблюдения LiveData из PagedList я использовал реализацию Хосе Альсеррека из getOrAwaitValue из примера приложения LiveDataSample в разделе "Примеры компонентов архитектуры Google Google".

Функция расширения asPagedList реализована в примере теста ContentViewModelTest.kt ниже.

PagedListTestUtil.kt

import android.database.Cursor
import androidx.paging.DataSource
import androidx.paging.LivePagedListBuilder
import androidx.paging.PagedList
import androidx.room.RoomDatabase
import androidx.room.RoomSQLiteQuery
import androidx.room.paging.LimitOffsetDataSource
import io.mockk.every
import io.mockk.mockk

fun <T> List<T>.asPagedList(config: PagedList.Config? = null): PagedList<T>? {
    val defaultConfig = PagedList.Config.Builder()
        .setEnablePlaceholders(false)
        .setPageSize(size)
        .setMaxSize(size + 2)
        .setPrefetchDistance(1)
        .build()
    return LivePagedListBuilder<Int, T>(
        createMockDataSourceFactory(this),
        config ?: defaultConfig
    ).build().getOrAwaitValue()
}

private fun <T> createMockDataSourceFactory(itemList: List<T>): DataSource.Factory<Int, T> =
    object : DataSource.Factory<Int, T>() {
        override fun create(): DataSource<Int, T> = MockLimitDataSource(itemList)
    }

private val mockQuery = mockk<RoomSQLiteQuery> {
    every { sql } returns ""
}

private val mockDb = mockk<RoomDatabase> {
    every { invalidationTracker } returns mockk(relaxUnitFun = true)
}

class MockLimitDataSource<T>(private val itemList: List<T>) : LimitOffsetDataSource<T>(mockDb, mockQuery, false, null) {
    override fun convertRows(cursor: Cursor?): MutableList<T> = itemList.toMutableList()
    override fun countItems(): Int = itemList.count()
    override fun isInvalid(): Boolean = false
    override fun loadRange(params: LoadRangeParams, callback: LoadRangeCallback<T>) { /* Not implemented */ }

    override fun loadRange(startPosition: Int, loadCount: Int) =
        itemList.subList(startPosition, startPosition + loadCount).toMutableList()

    override fun loadInitial(params: LoadInitialParams, callback: LoadInitialCallback<T>) {
        callback.onResult(itemList, 0)
    }
}

LiveDataTestUtil.kt

import androidx.lifecycle.LiveData
import androidx.lifecycle.Observer
import java.util.concurrent.CountDownLatch
import java.util.concurrent.TimeUnit
import java.util.concurrent.TimeoutException

/**
 * Gets the value of a [LiveData] or waits for it to have one, with a timeout.
 *
 * Use this extension from host-side (JVM) tests. It recommended to use it alongside
 * 'InstantTaskExecutorRule' or a similar mechanism to execute tasks synchronously.
 */
fun <T> LiveData<T>.getOrAwaitValue(
    time: Long = 2,
    timeUnit: TimeUnit = TimeUnit.SECONDS,
    afterObserve: () -> Unit = {}
): T {
    var data: T? = null
    val latch = CountDownLatch(1)
    val observer = object : Observer<T> {
        override fun onChanged(o: T?) {
            data = o
            latch.countDown()
            [email protected](this)
        }
    }
    this.observeForever(observer)
    afterObserve.invoke()
    // Don't wait indefinitely if the LiveData is not set.
    if (!latch.await(time, timeUnit)) {
        this.removeObserver(observer)
        throw TimeoutException("LiveData value was never set.")
    }
    @Suppress("UNCHECKED_CAST")
    return data as T
}

ContentViewModelTest.kt

...
import androidx.paging.PagedList
import com.google.firebase.Timestamp
import io.mockk.*
import org.assertj.core.api.Assertions.assertThat
import org.junit.jupiter.api.AfterAll
import org.junit.jupiter.api.BeforeAll
import org.junit.jupiter.api.BeforeEach
import org.junit.jupiter.api.Test
import org.junit.jupiter.api.extension.ExtendWith

@ExtendWith(InstantExecutorExtension::class)
class ContentViewModelTest {
    val timestamp = getTimeframe(DAY)

    @BeforeAll
    fun beforeAll() {
        mockkObject(ContentRepository)
    }

    @BeforeEach
    fun beforeEach() {
        clearAllMocks()
    }

    @AfterAll
    fun afterAll() {
        unmockkAll()
    }

    @Test
    fun 'Feed Load'() {
        val content = Content("85", 0.0, Enums.ContentType.NONE, Timestamp.now(), "",
            "", "", "", "", "", "", MAIN,
            0, 0.0, 0.0, 0.0, 0.0,
            0.0, 0.0, 0.0, 0.0)
        every {
            getMainFeedList(any(), any())
        } returns MutableLiveData<Lce<ContentResult.PagedListResult>>().also { lce ->
            lce.value = Lce.Content(
                ContentResult.PagedListResult(
                    pagedList = MutableLiveData<PagedList<Content>>().apply {
                        this.value = listOf(content).asPagedList(
                            PagedList.Config.Builder().setEnablePlaceholders(false)
                                .setPrefetchDistance(24)
                                .setPageSize(12)
                                .build())
                        }, errorMessage = ""))
        }
        val contentViewModel = ContentViewModel(ContentRepository)
        contentViewModel.processEvent(ContentViewEvent.FeedLoad(MAIN, DAY, timestamp, false))
        assertThat(contentViewModel.feedViewState.getOrAwaitValue().contentList.getOrAwaitValue()[0])
            .isEqualTo(content)
        assertThat(contentViewModel.feedViewState.getOrAwaitValue().toolbar).isEqualTo(
            ToolbarState(
                    visibility = GONE,
                    titleRes = app_name,
                    isSupportActionBarEnabled = false))
        verify {
            getMainFeedList(any(), any())
        }
        confirmVerified(ContentRepository)
    }
}

InstantExecutorExtension.kt

Это требуется для JUnit 5 при использовании LiveData, чтобы гарантировать, что Observer не находится в главном потоке. Ниже приведена реализация ДжероенаМолса.

import androidx.arch.core.executor.ArchTaskExecutor
import androidx.arch.core.executor.TaskExecutor
import org.junit.jupiter.api.extension.AfterEachCallback
import org.junit.jupiter.api.extension.BeforeEachCallback
import org.junit.jupiter.api.extension.ExtensionContext

class InstantExecutorExtension : BeforeEachCallback, AfterEachCallback {
    override fun beforeEach(context: ExtensionContext?) {
        ArchTaskExecutor.getInstance().setDelegate(object : TaskExecutor() {
            override fun executeOnDiskIO(runnable: Runnable) = runnable.run()
            override fun postToMainThread(runnable: Runnable) = runnable.run()
            override fun isMainThread(): Boolean = true
        })
    }

    override fun afterEach(context: ExtensionContext?) {
        ArchTaskExecutor.getInstance().setDelegate(null)
    }
}