在应用中实现 Paging 库需要辅以稳健的测试策略。您应测试 PagingSource 和 RemoteMediator 等数据加载组件,以确保这些组件按预期运行。您还应编写端到端测试,以验证 Paging 实现中的所有组件能否正确协同工作,而不会产生意外的副效应。
本指南将介绍如何在应用的不同 架构层中测试 Paging 库,以及如何为整个 Paging 实现编写 端到端测试。
界面层测试
由于 Compose 通过
collectAsLazyPagingItems以声明方式使用 Paging 数据,因此界面层测试可以完全专注于
Flow<PagingData<Value>>发出的 ViewModel。如需编写测试来验证界面中的数据是否符合预期,请添加
paging-testing 依赖项。它
包含 asSnapshot 扩展,适用于 Flow<PagingData<Value>>。并且其 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.
}
数据层测试
为数据层中的组件编写单元测试,以确保这些组件能从数据源正确加载数据。提供
虚构版本的
依赖项,以验证被测组件在
隔离运行时是否正常。您需要在代码库层中测试的主要组件是
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<Int, RedditPost>() {
...
}
您可以提供 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。
请按以下步骤测试第一种情况:
- 使用要返回的 post 数据设置
mockApi。 - 初始化
RemoteMediator对象: - 测试
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<Int, RedditPost>(
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<Int, RedditPost>(
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<Int, RedditPost>(
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 中。
其他资源
查看内容
为您推荐
- 注意:当 JavaScript 处于关闭状态时,系统会显示链接文字
- 从网络和数据库加载页面
- 迁移到 Paging 3
- 加载并显示分页数据