20

Google recently announced the new Paging 3 library, Kotlin-first library, Support for coroutines and Flow...etc.

I played with the codelab they provide but it seems there's not any support yet for testing, I also checked documentation. They didn't mention anything about testing, So For Example I wanted to unit test this PagingSource:

 class GithubPagingSource(private val service: GithubService,
                     private val query: String) : PagingSource<Int, Repo>() {

override suspend fun load(params: LoadParams<Int>): LoadResult<Int, Repo> {
    //params.key is null in loading first page in that case we would use constant GITHUB_STARTING_PAGE_INDEX
    val position = params.key ?: GITHUB_STARTING_PAGE_INDEX
    val apiQuery = query + IN_QUALIFIER
    return try {
        val response = service.searchRepos(apiQuery, position, params.loadSize)
        val data = response.items
        LoadResult.Page(
                        data,
                        if (position == GITHUB_STARTING_PAGE_INDEX) null else position - 1,
                        if (data.isEmpty()) null else position + 1)
    }catch (IOEx: IOException){
        Log.d("GithubPagingSource", "Failed to load pages, IO Exception: ${IOEx.message}")
        LoadResult.Error(IOEx)
    }catch (httpEx: HttpException){
        Log.d("GithubPagingSource", "Failed to load pages, http Exception code: ${httpEx.code()}")
        LoadResult.Error(httpEx)
    }
  }
}  

So, How can I test this, is anyone can help me??

MR3YY
  • 638
  • 1
  • 8
  • 18

6 Answers6

1

I'm currently having a similar experience of finding out that the paging library isn't really designed to be testable. I'm sure Google will make it more testable once it's a more mature library.

I was able to write a test for PagingSource. I used the RxJava 3 plugin and mockito-kotlin, but the general idea of the test should be reproducible with the Coroutines version of the API and most testing frameworks.

class ItemPagingSourceTest {

    private val itemList = listOf(
            Item(id = "1"),
            Item(id = "2"),
            Item(id = "3")
    )

    private lateinit var source: ItemPagingSource

    private val service: ItemService = mock()

    @Before
    fun `set up`() {
        source = ItemPagingSource(service)
    }

    @Test
    fun `getItems - should delegate to service`() {
        val onSuccess: Consumer<LoadResult<Int, Item>> = mock()
        val onError: Consumer<Throwable> = mock()
        val params: LoadParams<Int> = mock()

        whenever(service.getItems(1)).thenReturn(Single.just(itemList))
        source.loadSingle(params).subscribe(onSuccess, onError)

        verify(service).getItems(1)
        verify(onSuccess).accept(LoadResult.Page(itemList, null, 2))
        verifyZeroInteractions(onError)
    }
}

It's not perfect, since verify(onSuccess).accept(LoadResult.Page(itemList, null, 2)) relies on LoadResult.Page being a data class, which can be compared by the values of its properties. But it does test PagingSource.

Dharman
  • 30,962
  • 25
  • 85
  • 135
Adrian Czuczka
  • 113
  • 1
  • 8
1

There is a way to do that with AsyncPagingDataDiffer

Step 1. Create DiffCallback

class DiffFavoriteEventCallback : DiffUtil.ItemCallback<FavoriteEventUiModel>() {
    override fun areItemsTheSame(
        oldItem: FavoriteEventUiModel,
        newItem: FavoriteEventUiModel
    ): Boolean {
        return oldItem == newItem
    }

    override fun areContentsTheSame(
        oldItem: FavoriteEventUiModel,
        newItem: FavoriteEventUiModel
    ): Boolean {
        return oldItem == newItem
    }
}

Step 2. Create ListCallback

class NoopListCallback : ListUpdateCallback {
    override fun onChanged(position: Int, count: Int, payload: Any?) {}
    override fun onMoved(fromPosition: Int, toPosition: Int) {}
    override fun onInserted(position: Int, count: Int) {}
    override fun onRemoved(position: Int, count: Int) {}
}

Step 3. Submit data to the differ and take the screenshot

@Test
    fun WHEN_init_THEN_shouldGetEvents_AND_updateUiModel() {
        coroutineDispatcher.runBlockingTest {
            val eventList = listOf(FavoriteEvent(ID, TITLE, Date(1000), URL))
            val pagingSource = PagingData.from(eventList)

            val captureUiModel = slot<PagingData<FavoriteEventUiModel>>()
            every { uiModelObserver.onChanged(capture(captureUiModel)) } answers {}
            coEvery { getFavoriteUseCase.invoke() } returns flowOf(pagingSource)

            viewModel.uiModel.observeForever(uiModelObserver)

            val differ = AsyncPagingDataDiffer(
                diffCallback = DiffFavoriteEventCallback(),
                updateCallback = NoopListCallback(),
                workerDispatcher = Dispatchers.Main
            )

            val job = launch {
                viewModel.uiModel.observeForever {
                    runBlocking {
                        differ.submitData(it)
                    }
                }
            }

            val result = differ.snapshot().items[0]
            assertEquals(result.id, ID)
            assertEquals(result.title, TITLE)
            assertEquals(result.url, URL)

            job.cancel()

            viewModel.uiModel.removeObserver(uiModelObserver)
        }
    }

Documentation https://developer.android.com/reference/kotlin/androidx/paging/AsyncPagingDataDiffer

anna_manzhula
  • 326
  • 2
  • 10
0

I have the solution, but i don't think this is the good idea for paging v3 testing. My all test for paging v3 is working on instrumentation testing, not local unit testing, this because if i put the same way method in local test (with robolectrict too) it still doesn't work.

So this is my test case, I use the mockwebserver to mock and count the network request that must be equal to my expected

@RunWith(AndroidJUnit4::class)
@SmallTest
class SearchMoviePagingTest {
    private lateinit var recyclerView: RecyclerView
    private val query = "A"
    private val totalPage = 4

    private val service: ApiService by lazy {
        Retrofit.Builder()
                .baseUrl("http://localhost:8080")
                .addConverterFactory(GsonConverterFactory.create())
                .build().create(ApiService::class.java)
    }

    private val mappingCountCallHandler: HashMap<Int, Int> = HashMap<Int, Int>().apply {
        for (i in 0..totalPage) {
            this[i] = 0
        }
    }

    private val adapter: RecyclerTestAdapter<MovieItemResponse> by lazy {
        RecyclerTestAdapter()
    }

    private lateinit var pager: Flow<PagingData<MovieItemResponse>>

    private lateinit var mockWebServer: MockWebServer

    private val context: Context
        get() {
            return InstrumentationRegistry.getInstrumentation().targetContext
        }

    @Before
    fun setup() {
        mockWebServer = MockWebServer()
        mockWebServer.start(8080)

        recyclerView = RecyclerView(context)
        recyclerView.adapter = adapter

        mockWebServer.dispatcher = SearchMoviePagingDispatcher(context, ::receiveCallback)
        pager = Pager(
                config = PagingConfig(
                        pageSize = 20,
                        prefetchDistance = 3, // distance backward to get pages
                        enablePlaceholders = false,
                        initialLoadSize = 20
                ),
                pagingSourceFactory = { SearchMoviePagingSource(service, query) }
        ).flow
    }

    @After
    fun tearDown() {
        mockWebServer.dispatcher.shutdown()
        mockWebServer.shutdown()
    }

    @Test
    fun should_success_get_data_and_not_retrieve_anymore_page_if_not_reached_treshold() {
        runBlocking {
            val job = executeLaunch(this)
            delay(1000)
            adapter.forcePrefetch(10)
            delay(1000)

            Assert.assertEquals(1, mappingCountCallHandler[1])
            Assert.assertEquals(0, mappingCountCallHandler[2])
            Assert.assertEquals(20, adapter.itemCount)
            job.cancel()
        }
    }

....
    private fun executeLaunch(coroutineScope: CoroutineScope) = coroutineScope.launch {
        val res = pager.cachedIn(this)
        res.collectLatest {
            adapter.submitData(it)
        }
    }

    private fun receiveCallback(reqPage: Int) {
        val prev = mappingCountCallHandler[reqPage]!!
        mappingCountCallHandler[reqPage] = prev + 1
    }
}

#cmiiw please :)

0

I just come across the same question, and here is the answer:

Step 1 is to create a mock.

@OptIn(ExperimentalCoroutinesApi::class)
class SubredditPagingSourceTest {
  private val postFactory = PostFactory()
  private val mockPosts = listOf(
    postFactory.createRedditPost(DEFAULT_SUBREDDIT),
    postFactory.createRedditPost(DEFAULT_SUBREDDIT),
    postFactory.createRedditPost(DEFAULT_SUBREDDIT)
  )
  private val mockApi = MockRedditApi().apply {
    mockPosts.forEach { post -> addPost(post) }
  }
}

Step 2 is to unit test the core method of PageSource, load method :

@Test
// Since load is a suspend function, runBlockingTest is used to ensure that it
// runs on the test thread.
fun loadReturnsPageWhenOnSuccessfulLoadOfItemKeyedData() = runBlockingTest {
  val pagingSource = ItemKeyedSubredditPagingSource(mockApi, DEFAULT_SUBREDDIT)
  assertEquals(
    expected = Page(
      data = listOf(mockPosts[0], mockPosts[1]),
      prevKey = mockPosts[0].name,
      nextKey = mockPosts[1].name
    ),
    actual = pagingSource.load(
      Refresh(
        key = null,
        loadSize = 2,
        placeholdersEnabled = false
      )
    ),
  )
}
TooCool
  • 10,598
  • 15
  • 60
  • 85
0

New testing APIs for paging have just be released that make it possible to test PagingSources "as state", by making it possible to read the output of Paging as a basic List.

There are two major APIs:

  • Flow<PagingData<Value>>.asSnapshot() which returns a List<Value>
  • Flow<List<Value>>. asPagingSourceFactory() which returns a () -> PagingSource<Value>

Each have different use cases. You use the first typically to assert the output of your business logic state holder, typically an AAC ViewModel. You use the latter for fakes that can be passed to the ViewModel, letting you unit test paging integration in your UI layer alone without having to depend on actual PagingSource implementations from your data layer.

Tunji_D
  • 3,677
  • 3
  • 27
  • 35
-3

Kotlin Coroutines Flow

You can use JUnit local tests and set the TestCoroutineDispatcher before and after the tests run. Then, call the methods that emit the Kotlin Flow of the PagingSource to observe the resulting data in the local testing environment to compare with what you expect.

A JUnit 5 test extension is not required. The dispatchers just need to be set and cleared before and after each test in order to observe Coroutines in the test environment vs. on the Android system.

@ExperimentalCoroutinesApi
class FeedViewTestExtension : BeforeEachCallback, AfterEachCallback, ParameterResolver {

    override fun beforeEach(context: ExtensionContext?) {
        // Set TestCoroutineDispatcher.
        Dispatchers.setMain(context?.root
                ?.getStore(TEST_COROUTINE_DISPATCHER_NAMESPACE)
                ?.get(TEST_COROUTINE_DISPATCHER_KEY, TestCoroutineDispatcher::class.java)!!)
    }

    override fun afterEach(context: ExtensionContext?) {
        // Reset TestCoroutineDispatcher.
        Dispatchers.resetMain()
        context?.root
                ?.getStore(TEST_COROUTINE_DISPATCHER_NAMESPACE)
                ?.get(TEST_COROUTINE_DISPATCHER_KEY, TestCoroutineDispatcher::class.java)!!
                .cleanupTestCoroutines()
        context.root
                ?.getStore(TEST_COROUTINE_SCOPE_NAMESPACE)
                ?.get(TEST_COROUTINE_SCOPE_KEY, TestCoroutineScope::class.java)!!
                .cleanupTestCoroutines()
    }

    ...
}

You can see the local JUnit 5 tests in the Coinverse sample app for Paging 2 under app/src/test/java/app/coinverse/feedViewModel/FeedViewTest.

The difference for Paging 3 is that you don't need to set LiveData executor's since Kotlin Flow can return PagingData.

AdamHurwitz
  • 9,758
  • 10
  • 72
  • 134
  • 1
    Thanks for your helpful explanation, but my problem isn't actually with how to test Flows or coroutines. my problem is that there is no reliable robust solution to test this `PagingSource` like say For example `FakePagingSource` provided by the library. That being said I rely on mocks every time I need to test this `PagingSource` which isn't always a good solution at least for me because of things like coupling implementation with test code. – MR3YY Sep 21 '20 at 15:16
  • Thank you for clarifying @MR3YY. My solution in the sample code above does rely heavily on mocks and returning a mocked version of the PagindData. The Paging 3 library is still early so I'd bet there will be more to come on the testing front from the Android team. – AdamHurwitz Sep 21 '20 at 17:55