How do I create a PagedList of an object for tests?
Asked Answered
R

3

22

I have been working with the arch libraries from Google, but one thing that has made testing difficult is working with PagedList.

For this example, I am using the repository pattern and returning details from either an API or network.

So within the ViewModel I make a call to this interface method:

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

The Repository will then use RxPagedListBuilder to create the Observable that is of type PagedList:

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

I want to be able for tests to setup the return from these methods that return a PagedList<Foo>. Something similar to

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

Two questions:

  1. Is this possible?
  2. How do I create a PagedList<Foo>?

My goal is to verify in a more end-to-end fashion (such as ensuring that the correct list of Foos is displayed on the screen). The fragment/activity/view is the one observing the PagedList<Foo> from a ViewModel.

Roslynrosmarin answered 20/5, 2018 at 14:22 Comment(3)
What I find is missing from your question is: what verification do you want to make in the tests?Devilfish
@Devilfish thank you for the feedback...I updated the question with what I'm trying to verifyRoslynrosmarin
Any update on which strategy you implemented @isuPatches? I'm working on writing the same test with Kotlin, JUnit 5, and MockK. I'm happy to share my solution here once I get the proof of concept working.Geometry
G
17

Paging 3

The Paging 3 library offers a builder method PagingData.from(someList).

Paging 2

Convert List Into PagedList With a Mock DataSource.Factory.

@saied89 shared this solution in this googlesamples/android-architecture-components issue. I've implemented the mocked PagedList in the Coinverse Open App in order to local unit test a ViewModel using Kotlin, JUnit 5, MockK, and AssertJ libraries.

To observe the LiveData from the PagedList I've used Jose Alcérreca's implementation of getOrAwaitValue from the LiveDataSample sample app under Google's Android Architecture Components samples.

The asPagedList extension function is implemented in the sample test ContentViewModelTest.kt below.

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() = LivePagedListBuilder<Int, T>(createMockDataSourceFactory(this),
        Config(enablePlaceholders = false,
                prefetchDistance = 24,
                pageSize = if (size == 0) 1 else size))
        .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's 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 liveData { 
               emit(Lce.Content(
                   ContentResult.PagedListResult(
                        pagedList = liveData {emit(listOf(content).asPagedList())}, 
                        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

This is required for JUnit 5 when using LiveData in order to ensure the Observer is not on the main thread. Below is Jeroen Mols' implementation.

    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)
        }
    }
Geometry answered 4/9, 2019 at 18:37 Comment(2)
would it be similar for paging 3 lib?Thymelaeaceous
Great question @Raghunandan! I have not tested the Paging 3 library yet. Please follow-up on the post with comments/edits if you implement and find changes.Geometry
F
12

an easy way to achieve this, is to mock the PagedList. This fun will "convert" a list to a PagedList (in this case, we are not using the real PagedList rather just a mocked version, if you need other methods of PagedList to be implemented, add them in this fun)

 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
 }
Fluorometer answered 26/9, 2018 at 7:6 Comment(5)
Don't forget to add Mockito first: testImplementation "org.mockito:mockito-core:2.25.0"Ashcroft
Can you cast a List to a PagedList @bsobat?Geometry
@bsobat, Is this supposed to return a PagedList with data populated or an empty PagedList to avoid the test from failing? If empty, it will not be useful for testing how the app handles PagedList data.Geometry
@Hurwitz no, you can't castFluorometer
@bsobat, check out the solution above that uses MockK to implement the behavior of using a PagedList when writing local JUnit 5 tests.Geometry
A
0
  1. You cannot cast List to PagedList.
  2. You cannot create PagedList directly, only through DataSource. One way is creating FakeDataSource returning the test data.

If it is an end-to-end test, you could just use in-memory db. Add your test data before calling it. Example: https://medium.com/exploring-android/android-architecture-components-testing-your-room-dao-classes-e06e1c9a1535

Arbogast answered 27/6, 2018 at 7:58 Comment(1)
Thanks for the insight! I've implemented a mock PagedList using MockK for a local JUnit 5 test.Geometry

© 2022 - 2024 — McMap. All rights reserved.