测试 Paging 实现

在应用中实现 Paging 库需要辅以稳健的测试策略。您应测试 PagingSource 和 RemoteMediator 等数据加载组件,以确保这些组件按预期运行。您还应编写端到端测试,以验证 Paging 实现中的所有组件能否正确协同工作,而不会产生意外的副效应。

本指南将介绍如何在应用的不同架构层中测试 Paging 库,以及如何为整个 Paging 实现编写端到端测试。

界面层测试

由于 Compose 会通过 collectAsLazyPagingItems 以声明方式使用 Paging 数据,因此界面层测试可以主要关注 ViewModel 发出的 Flow<PagingData<Value>>。若要编写测试以验证界面中的数据是否符合您的预期,请添加 paging-testing 依赖项。它包含对 Flow<PagingData<Value>> 的 asSnapshot 扩展。并且其 lambda 接收器中提供了一些 API,这些 API 用于模拟滚动互动。它会返回一个由模拟的滚动互动生成的标准 List<Value>。这样,您可以断言分页数据包含由这些互动生成的预期元素。如以下代码段所示:

fun test_items_contain_one_to_ten() = runTest {
  // Get the Flow of PagingData from the ViewModel under test
  val items: Flow<PagingData<String>> = viewModel.items

  val itemsSnapshot: List<String> = items.asSnapshot {
    // Scroll to the 50th item in the list. This will also suspend till
    // the prefetch requirement is met if there's one.
    // It also suspends until all loading is complete.
    scrollTo(index = 50)
  }

  // With the asSnapshot complete, you can now verify that the snapshot
  // has the expected values
  assertEquals(
    expected = (0..50).map(Int::toString),
    actual = itemsSnapshot
  )
}

或者,您也可以一直滚动,直至满足给定的谓词,如以下代码段所示:

fun test_footer_is_visible() = runTest {
  // Get the Flow of PagingData from the ViewModel under test
  val items: Flow<PagingData<String>> = viewModel.items

  val itemsSnapshot: List<String> = items.asSnapshot {
    // Scroll till the footer is visible
    appendScrollWhile {  item: String -> item != "Footer" }
  }

测试转换

您还应编写单元测试,以涵盖您应用于 PagingData 流的所有转换。使用 asPagingSourceFactory 扩展。此扩展适用于以下数据类型:

  • List<Value>。
  • Flow<List<Value>>。

具体选择使用哪一个扩展取决于您要测试的内容。 使用:

  • List<Value>.asPagingSourceFactory():如果要测试数据的静态转换(如 map() 和 insertSeparators())。
  • Flow<List<Value>>.asPagingSourceFactory():如果要测试数据更新(例如写入后备数据源)对分页流水线有何影响。

如需使用上述任何一种扩展,请遵循以下模式:

  • 根据您的需求使用适当的扩展来创建 PagingSourceFactory。
  • 在 Repository 的虚构实例中使用返回的 PagingSourceFactory。
  • 将该 Repository 传递给 ViewModel。

然后,您可按照上一部分中介绍的方式测试 ViewModel。请考虑以下 ViewModel:

class MyViewModel(
  myRepository: myRepository
) {
  val items = Pager(
    config: PagingConfig,
    initialKey = null,
    pagingSourceFactory = { myRepository.pagingSource() }
  )
  .flow
  .map { pagingData ->
    pagingData.insertSeparators<String, String> { before, _ ->
      when {
        // Add a dashed String separator if the prior item is a multiple of 10
        before.last() == '0' -> "---------"
        // Return null to avoid adding a separator between two items.
        else -> null
      }
  }
}

若要在 MyViewModel 中测试转换,请提供 MyRepository 的虚构实例以委托给代表要转换的数据的 List,如以下代码段所示:

class FakeMyRepository() : MyRepository {
    private val items = (0..100).map(Any::toString)
    private val pagingSourceFactory = items.asPagingSourceFactory()

    // Expose as a function so a new PagingSource instance is
    // created each time it is called by the Pager
    fun pagingSource() = pagingSourceFactory()
}

然后,您可编写针对分隔符逻辑的测试,如以下代码段所示:

fun test_separators_are_added_every_10_items() = runTest {
  // Create your ViewModel
  val viewModel = MyViewModel(
    myRepository = FakeMyRepository()
  )
  // Get the Flow of PagingData from the ViewModel with the separator transformations applied
  val items: Flow<PagingData<String>> = viewModel.items

  val snapshot: List<String> = items.asSnapshot()

  // With the asSnapshot complete, you can now verify that the snapshot
  // has the expected separators.
}

测试 Compose 加载状态

如需单独测试 Compose 界面组件对不同加载状态(例如 LoadState.Loading 或 LoadState.Error)的反应,请通过 MutableStateFlow 使用 PagingData.empty 和 PagingData.from 发出具有显式 LoadStates 的静态 PagingData 快照:

@get:Rule
val composeTestRule = createComposeRule()

@Test
fun userListScreen_displaysLoadingDataAndErrorStates() {
  val idle = LoadState.NotLoading(endOfPaginationReached = false)
  // Emit static PagingData snapshots to test UI state transitions without a
  // Pager or ViewModel.
  val pagingDataFlow = MutableStateFlow(PagingData.empty<User>())

  composeTestRule.setContent {
    val lazyPagingItems = pagingDataFlow.collectAsLazyPagingItems()
    UserListScreen(lazyPagingItems = lazyPagingItems)
  }

  // Verify the initial loading UI before any items arrive.
  pagingDataFlow.value = PagingData.empty(
    sourceLoadStates = LoadStates(
      refresh = LoadState.Loading,
      prepend = idle,
      append = idle
    )
  )
  composeTestRule.onNodeWithTag("LoadingSpinner").assertIsDisplayed()

  // Verify that loaded items replace the loading indicator.
  pagingDataFlow.value = PagingData.from(
    data = listOf(User(id = "1", label = "User A")),
    sourceLoadStates = LoadStates(
      refresh = idle,
      prepend = idle,
      append = idle
    )
  )
  composeTestRule.onNodeWithTag("LoadingSpinner").assertIsNotDisplayed()
  composeTestRule.onNodeWithText("User A").assertIsDisplayed()

  // Verify that a refresh failure surfaces the error message in the UI.
  pagingDataFlow.value = PagingData.empty(
    sourceLoadStates = LoadStates(
      refresh = LoadState.Error(IOException("Network unavailable")),
      prepend = idle,
      append = idle
    )
  )
  composeTestRule.onNodeWithText("Network unavailable").assertIsDisplayed()
}

数据层测试

为数据层中的组件编写单元测试,以确保这些组件能从数据源正确加载数据。提供依赖项的虚构版本,以验证被测组件在单独运行时是否正常。您需要在代码库层中测试的主要组件是 PagingSource 和 RemoteMediator。

PagingSource 项测试

PagingSource 实现的单元测试涉及设置 PagingSource 实例,以及使用 TestPager 从该实例加载数据。

如需设置 PagingSource 实例以进行测试,请向构造函数提供虚构数据。这样,您就可以控制测试中的数据。在以下示例中,RedditApi 参数是一个 Retrofit 接口,用于定义服务器请求和响应类。虚构版本可以实现该接口、替换任何必需函数,并提供便捷方法来配置虚构对象在测试中的反应方式。

创建好虚构对象后,应在测试中设置依赖项并初始化 PagingSource 对象。以下示例演示了如何使用一系列测试 post 来初始化 FakeRedditApi 对象,以及如何测试 RedditPagingSource 实例:

class SubredditPagingSourceTest {
  private val mockPosts = listOf(
    postFactory.createRedditPost(DEFAULT_SUBREDDIT),
    postFactory.createRedditPost(DEFAULT_SUBREDDIT),
    postFactory.createRedditPost(DEFAULT_SUBREDDIT)
  )
  private val fakeApi = FakeRedditApi().apply {
    mockPosts.forEach { post -> addPost(post) }
  }

  @Test
  fun loadReturnsPageWhenOnSuccessfulLoadOfItemKeyedData() = runTest {
    val pagingSource = RedditPagingSource(
      fakeApi,
      DEFAULT_SUBREDDIT
    )

    val pager = TestPager(CONFIG, pagingSource)

    val result = pager.refresh() as LoadResult.Page

    // Write assertions against the loaded data
    assertThat(result.data)
    .containsExactlyElementsIn(mockPosts)
    .inOrder()
  }
}

借助 TestPager,您还可以执行以下操作:

  • 测试从 PagingSource 的连续加载情况:
    @Test
    fun test_consecutive_loads() = runTest {

      val page = with(pager) {
        refresh()
        append()
        append()
      } as LoadResult.Page

      assertThat(page.data)
      .containsExactlyElementsIn(testPosts)
      .inOrder()
    }
  • 测试 PagingSource 中的错误场景:
    @Test
    fun refresh_returnError() {
        val pagingSource = RedditPagingSource(
          fakeApi,
          DEFAULT_SUBREDDIT
        )
        // Configure your fake to return errors
        fakeApi.setReturnsError()
        val pager = TestPager(CONFIG, source)

        runTest {
            source.errorNextLoad = true
            val result = pager.refresh()
            assertTrue(result is LoadResult.Error)

            val page = pager.getLastLoadedPage()
            assertThat(page).isNull()
        }
    }

RemoteMediator 项测试

RemoteMediator 单元测试的目的是验证 load() 函数是否会返回正确的 MediatorResult。副效应测试(例如将数据插入数据库)更适合集成测试。

第一步是确定 RemoteMediator 实现需要的依赖项。以下示例演示了需要 Room 数据库、Retrofit 接口和搜索字符串的 RemoteMediator 实现:

@OptIn(ExperimentalPagingApi::class)
class PageKeyedRemoteMediator(
  private val db: RedditDb,
  private val redditApi: RedditApi,
  private val subredditName: String
) : RemoteMediator&lt;Int, RedditPost&gt;() {
  ...
}

您可以提供 Retrofit 接口和搜索字符串,如 PagingSource 测试部分所示。提供 Room 数据库的模拟版本非常复杂,而更简单的做法是提供数据库的内存中实现,而不是完整的模拟版本。由于创建 Room 数据库需要 Context 对象,您必须将此 RemoteMediator 测试放在 androidTest 目录中,并使用 AndroidJUnit4 测试运行程序来执行此测试,使其能够访问测试应用上下文。如需详细了解插桩测试,请参阅构建插桩单元测试。

定义拆解函数,以确保状态不会在测试函数之间泄漏。这可确保测试运行之间结果的一致。

@ExperimentalPagingApi
@OptIn(ExperimentalCoroutinesApi::class)
@RunWith(AndroidJUnit4::class)
class PageKeyedRemoteMediatorTest {
  private val postFactory = PostFactory()
  private val mockPosts = listOf(
    postFactory.createRedditPost(SubRedditViewModel.DEFAULT_SUBREDDIT),
    postFactory.createRedditPost(SubRedditViewModel.DEFAULT_SUBREDDIT),
    postFactory.createRedditPost(SubRedditViewModel.DEFAULT_SUBREDDIT)
  )
  private val mockApi = mockRedditApi()

  private val mockDb = RedditDb.create(
    ApplicationProvider.getApplicationContext(),
    useInMemory = true
  )

  @After
  fun tearDown() {
    mockDb.clearAllTables()
    // Clear out failure message to default to the successful response.
    mockApi.failureMsg = null
    // Clear out posts after each test run.
    mockApi.clearPosts()
  }
}

下一步是测试 load() 函数。在此示例中,要测试三种情况:

  • 第一种情况是 mockApi 返回有效数据。load() 函数应返回 MediatorResult.Success,endOfPaginationReached 属性应为 false。
  • 第二种情况是 mockApi 返回成功响应,但返回的数据为空。load() 函数应返回 MediatorResult.Success,endOfPaginationReached 属性应为 true。
  • 第三种情况是 mockApi 在获取数据时抛出异常。load() 函数应返回 MediatorResult.Error。

请按以下步骤测试第一种情况:

  1. 使用要返回的 post 数据设置 mockApi。
  2. 初始化 RemoteMediator 对象:
  3. 测试 load() 函数。
@Test
fun refreshLoadReturnsSuccessResultWhenMoreDataIsPresent() = runTest {
  // Add mock results for the API to return.
  mockPosts.forEach { post -> mockApi.addPost(post) }
  val remoteMediator = PageKeyedRemoteMediator(
    mockDb,
    mockApi,
    SubRedditViewModel.DEFAULT_SUBREDDIT
  )
  val pagingState = PagingState&lt;Int, RedditPost&gt;(
    listOf(),
    null,
    PagingConfig(10),
    10
  )
  val result = remoteMediator.load(LoadType.REFRESH, pagingState)
  assertTrue { result is MediatorResult.Success }
  assertFalse { (result as MediatorResult.Success).endOfPaginationReached }
}

第二个测试要求 mockApi 返回一个空结果。由于您在每次运行测试后都清除 mockApi 中的数据,因此默认情况下它会返回一个空结果。

@Test
fun refreshLoadSuccessAndEndOfPaginationWhenNoMoreData() = runTest {
  // To test endOfPaginationReached, don't set up the mockApi to return post
  // data here.
  val remoteMediator = PageKeyedRemoteMediator(
    mockDb,
    mockApi,
    SubRedditViewModel.DEFAULT_SUBREDDIT
  )
  val pagingState = PagingState&lt;Int, RedditPost&gt;(
    listOf(),
    null,
    PagingConfig(10),
    10
  )
  val result = remoteMediator.load(LoadType.REFRESH, pagingState)
  assertTrue { result is MediatorResult.Success }
  assertTrue { (result as MediatorResult.Success).endOfPaginationReached }
}

最后一项测试要求 mockApi 抛出异常,以便测试可以验证 load() 函数是否会正确返回 MediatorResult.Error。

@Test
fun refreshLoadReturnsErrorResultWhenErrorOccurs() = runTest {
  // Set up failure message to throw exception from the mock API.
  mockApi.failureMsg = "Throw test failure"
  val remoteMediator = PageKeyedRemoteMediator(
    mockDb,
    mockApi,
    SubRedditViewModel.DEFAULT_SUBREDDIT
  )
  val pagingState = PagingState&lt;Int, RedditPost&gt;(
    listOf(),
    null,
    PagingConfig(10),
    10
  )
  val result = remoteMediator.load(LoadType.REFRESH, pagingState)
  assertTrue {result is MediatorResult.Error }
}

端到端测试

单元测试可确保各个 Paging 组件在单独运行时可以正常工作,而端到端测试则可确保应用作为一个整体运行正常。这些测试有助于验证数据层(PagingSource 或 RemoteMediator)、ViewModel 和 Compose 界面是否无缝集成,而不会产生意外的副作用。这些测试仍需要一些模拟依赖项,但通常会涵盖大部分应用代码。

本部分中的示例使用模拟 API 依赖项,以避免在测试中使用网络。模拟 API 配置为返回一组一致的测试数据,从而使测试具有可重复性。对于端到端测试,您通常会将真实网络 API 换成虚假 API,但仍让 Paging 库处理实际提取和本地数据库缓存(如果使用 RemoteMediator),以保持测试的保真度。

编写代码时,请确保您可以轻松地换入依赖项的模拟版本。以下示例使用基本的服务定位器实现,并使用模拟 API 设置测试,以验证 Compose 界面是否正确使用和显示分页数据。在大型应用中,使用 Hilt 等依赖项注入库有助于管理更复杂的依赖项关系图。

设置测试结构后,下一步是验证 Pager 实现返回的数据是否正确。一项测试应验证 Compose 界面在屏幕首次加载时是否填充了正确的项,另一项测试应验证界面是否根据用户互动正确加载了其他数据。

在以下示例中,测试会验证界面是否显示预期的分页数据。

import androidx.compose.ui.test.assertIsDisplayed
import androidx.compose.ui.test.assertDoesNotExist
import androidx.compose.ui.test.hasText
import androidx.compose.ui.test.junit4.createComposeRule
import androidx.compose.ui.test.onNodeWithTag
import androidx.compose.ui.test.onNodeWithText
import androidx.compose.ui.test.performClick
import androidx.compose.ui.test.performTextClearance
import androidx.compose.ui.test.performTextInput
import androidx.test.ext.junit.runners.AndroidJUnit4
import kotlinx.coroutines.test.runTest
import org.junit.Before
import org.junit.Rule
import org.junit.Test
import org.junit.runner.RunWith

@RunWith(AndroidJUnit4::class)
class RedditScreenTest {

    @get:Rule
    val composeTestRule = createComposeRule()

    private val postFactory = PostFactory()
    private val mockApi = MockRedditApi()

    @Before
    fun setup() {
        // Pre-populate the mock API with test data for the default subreddit
        mockApi.addPost(postFactory.createRedditPost(subreddit = "androiddev", title = "Jetpack Compose Paging"))

        // Swap your real dependency injection module/Service Locator with the mock API
        ServiceLocator.swap(
            object : DefaultServiceLocator(useInMemoryDb = true) {
                override fun getRedditApi(): RedditApi = mockApi
            }
        )
    }

    @Test
    fun loadsTheDefaultResults() = runTest {
        // 1. Set the Compose UI content
        composeTestRule.setContent {
            MyTheme {
                // Assume that this composable uses `collectAsLazyPagingItems()` internally
                RedditScreen(initialSubreddit = "androiddev")
            }
        }

        // 2. Wait for the asynchronous Paging loads to complete
        composeTestRule.waitUntilExactlyOneExists(
            matcher = hasText("Jetpack Compose Paging"),
            timeoutMillis = 5000
        )

        // 3. Assert that the loaded paged items are displayed correctly on screen
        composeTestRule.onNodeWithText("Jetpack Compose Paging").assertIsDisplayed()
    }

    @Test
    fun loadsNewDataBasedOnUserInput() = runTest {
        // Add data for a different subreddit to the mock API
        mockApi.addPost(postFactory.createRedditPost(subreddit = "compose", title = "Compose Testing"))

        composeTestRule.setContent {
            MyTheme {
                RedditScreen(initialSubreddit = "androiddev")
            }
        }

        // Wait for the initial load to finish
        composeTestRule.waitUntilExactlyOneExists(hasText("Jetpack Compose Paging"))

        // Simulate user entering a new subreddit in a text field and clicking search
        composeTestRule.onNodeWithTag("SubredditInput").performTextClearance()
        composeTestRule.onNodeWithTag("SubredditInput").performTextInput("compose")
        composeTestRule.onNodeWithTag("SearchButton").performClick()

        // Wait for the new paged data to load
        composeTestRule.waitUntilExactlyOneExists(
            matcher = hasText("Compose Testing"),
            timeoutMillis = 5000
        )

        // Assert the old data is gone and the new data is displayed
        composeTestRule.onNodeWithText("Jetpack Compose Paging").assertDoesNotExist()
        composeTestRule.onNodeWithText("Compose Testing").assertIsDisplayed()
    }
}

由于 Flow<PagingData> 会异步加载数据,因此您必须先给 Paging 库一些时间来提取初始加载并将其发出到 collectAsLazyPagingItems,然后再进行断言。为此,请使用 composeTestRule.waitUntil 或 waitUntilExactlyOneExists,如上例所示。

加载数据后,您可以使用 onNodeWithText 直接针对 Compose 语义树进行断言,以验证这些项是否确实在 LazyColumn 中呈现。

其他资源

查看内容