【问题标题】:How can I Unit test Paging 3(PagingSource)?如何对 Paging 3(PagingSource) 进行单元测试?
【发布时间】:2020-10-09 21:52:39
【问题描述】:

Google 最近宣布了新的 Paging 3 库、Kotlin-first 库、对协程和 Flow 的支持……等等。

我玩过他们提供的codelab,但似乎还没有任何测试支持,我还检查了documentation。他们没有提到任何关于测试的内容,所以例如我想对这个 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)
    }
  }
}  

那么,我该如何测试,有人可以帮助我吗?

【问题讨论】:

标签: android android-paging-library


【解决方案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.Pagedata class,可以通过其属性值进行比较。但它确实测试了PagingSource

【讨论】:

    【解决方案2】:

    有一种方法可以使用 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. 提交数据到diff并截图

    @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

    【讨论】:

      【解决方案3】:

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

      所以这是我的测试用例,我使用 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
          }
      }
      

      请#cmiiw :)

      【讨论】:

        【解决方案4】:

        我刚遇到同样的问题,这里是answer

        第 1 步是创建一个模拟。

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

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

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

        【讨论】:

          【解决方案5】:

          Kotlin 协程流程

          您可以在测试运行前后使用 JUnit 本地测试和 set the 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 sample app for Paging 2 中查看本地 JUnit 5 测试

          Paging 3 的不同之处在于您不需要设置 LiveData 执行器,因为 Kotlin Flow 可以返回 PagingData

          【讨论】:

          • 感谢您的有用解释,但我的问题实际上不在于如何测试流或协程。我的问题是没有可靠的强大解决方案来测试这个PagingSource,比如图书馆提供的FakePagingSource。话虽如此,每次我需要测试这个PagingSource 时,我都依赖于模拟,至少对我而言,这并不总是一个好的解决方案,因为诸如将实现与测试代码耦合。
          • 感谢您澄清@MR3YY。我在上面的示例代码中的解决方案确实严重依赖模拟并返回 PagindData 的模拟版本。 Paging 3 库还处于早期阶段,所以我敢打赌,Android 团队会在测试前线推出更多内容。
          猜你喜欢
          • 1970-01-01
          • 2020-12-12
          • 2021-11-06
          • 1970-01-01
          • 2021-11-20
          • 1970-01-01
          • 2012-01-08
          • 2019-11-02
          • 1970-01-01
          相关资源
          最近更新 更多