如何对Paging 3(PagingSource)进行单元测试?

20

谷歌最近宣布推出全新的Paging 3库,这是一款Kotlin优先的库,支持协程和Flow等特性...

我尝试了他们提供的codelab,但目前似乎还没有测试支持。我还查看了文档,但他们没有提到任何关于测试的内容。例如,我想对这个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)
    }
  }
}  

那么,我该如何测试这个,有人能帮助我吗?


我很想看到关于那个的一些更新。 - Tiago Dávila
我在另一篇帖子中回答了这个问题 https://dev59.com/1L_qa4cB1Zd3GeqPAx5Y#67548760。看看它是否有所帮助。 - Robert
6个回答

1

我目前有类似的经历,发现分页库并不是为了可测试性而设计的。我相信一旦它成为更成熟的库,Google会使其更具可测试性。

我能够为PagingSource编写一个测试。我使用了RxJava 3插件和mockito-kotlin,但测试的一般思路应该可以在协程版本的API和大多数测试框架中复现。

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)
    }
}

这并不完美,因为verify(onSuccess).accept(LoadResult.Page(itemList, null, 2))依赖于LoadResult.Page是一个data class,可以通过其属性的值进行比较。但它确实测试了PagingSource


1

使用AsyncPagingDataDiffer可以实现这一点。

步骤1. 创建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
    }
}

步骤2. 创建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) {}
}

步骤3. 将数据提交到差异分析器并截取屏幕截图

@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)
        }
    }

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


captureUiModel 的用途是什么? - undefined

0

我刚遇到了同样的问题,这是答案:

第一步是创建一个模拟。

@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) }
  }
}

第二步是对PageSource的核心方法load进行单元测试:

@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
      )
    ),
  )
}

我想知道PostFactory和MockRedditApi()是从哪里来的。 - Edijae Crusar
@Cool 我查看了官方文档并尝试了,但代码编译时出现了错误。 "期望"类型与"实际"类型不同。 - yoonhok

0
刚刚发布了新的分页测试API,使得可以通过将PagingSources的输出作为基本List来测试“状态”。
有两个主要的API:
  • Flow<PagingData<Value>>.asSnapshot() 返回一个List<Value>
  • Flow<List<Value>>. asPagingSourceFactory() 返回一个() -> PagingSource<Value>
每个都有不同的用例。通常,您使用第一个来断言业务逻辑状态持有者的输出,通常是 AAC ViewModel。您使用后者来创建可以传递给ViewModel伪造对象,让您在UI层单独进行分页集成单元测试,而无需依赖于实际的PagingSource实现来自数据层。

0

我有解决方案,但我认为这不是分页v3测试的好主意。我的所有分页v3测试都在仪器测试上运行,而不是本地单元测试,因为如果我在本地测试中使用相同的方法(也使用robolectric),它仍然无法正常工作。

所以这是我的测试用例,我使用mockwebserver来模拟和计算必须等于我的预期的网络请求。

@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
    }
}

#请纠正我,如果我错了 :)


你能分享一下你的SearchMoviePagingDispatcher类吗? - Amit Kundu

-3

Kotlin协程流

您可以在测试运行之前和之后使用JUnit本地测试并设置TestCoroutineDispatcher。然后,调用发出PagingSource的Kotlin Flow的方法,以观察本地测试环境中生成的数据,并将其与您期望的数据进行比较。

不需要JUnit 5测试扩展程序。只需在每个测试之前和之后设置和清除分派器,以便在测试环境中观察协程与Android系统上的协程进行比较。

@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()
    }

    ...
}

你可以在 app/src/test/java/app/coinverse/feedViewModel/FeedViewTest 下的 Coinverse示例应用程序 的 Paging 2 中查看本地 JUnit 5 测试。
对于 Paging 3,不同之处在于你不需要设置 LiveData 执行器,因为 Kotlin Flow 可以返回 PagingData

1
感谢您的有益解释,但我的问题实际上不在于如何测试Flows或协程。我的问题是,没有可靠的强大解决方案来测试这个“PagingSource”,例如库提供的“FakePagingSource”。 话虽如此,每次我需要测试这个“PagingSource”时,我都依赖于模拟,但这并不总是一个好的解决方案,至少对我来说,因为它会导致实现与测试代码耦合。 - MR3YY
感谢您的澄清,@MR3YY。我在上面的示例代码中提供的解决方案确实严重依赖于模拟和返回模拟版本的PagindData。Paging 3库仍处于早期阶段,因此我敢打赌,Android团队将会在测试方面做出更多努力。 - AdamHurwitz

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接