在應用程式中實作分頁程式庫時,應搭配完善的測試策略。建議您測試 PagingSource 和 RemoteMediator 等資料載入元件,確保元件運作正常。此外,您也應撰寫端對端測試,確認分頁實作中的所有元件都能正常運作,而不會產生意外的副作用。
本指南說明如何在應用程式的不同架構層測試 Paging 程式庫,以及如何為整個 Paging 實作項目撰寫端對端測試。
UI 層測試
由於 Compose 會透過 collectAsLazyPagingItems 以宣告方式使用 Paging 資料,因此 UI 層測試可以完全著重於 ViewModel 發出的 Flow<PagingData<Value>>。如要撰寫測試,驗證 UI 中的資料是否符合預期,請納入 paging-testing 依附元件。這個依附元件包含 asSnapshot 的 Flow<PagingData<Value>> 擴充功能,其中的 lambda 接收器提供 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 物件。以下範例說明如何透過測試貼文清單來初始化 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>() {
...
}
您可以按照 PagingSource 測試一節的說明,來提供 Retrofit 介面和搜尋字串。提供 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。
請按照下列步驟測試第一個案例:
- 使用要傳回的訊息資料設定
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 }
}
端對端測試
單元測試可保證個別分頁元件能夠獨立運作,但端對端測試更能確保應用程式整體都能正常運作。這些測試有助於驗證資料層 (PagingSource 或 RemoteMediator)、ViewModel 和 Compose UI 是否能順暢整合,不會產生非預期的副作用。這些測試仍需要一些模擬依附元件,但通常已涵蓋大部分的應用程式程式碼。
本節中的範例使用模擬 API 依附元件,避免在測試中使用網路。模擬 API 已設為傳回一組一致的測試資料,進而產生可重複的測試。在端對端測試中,您通常會將實際的網路 API 換成假的,但仍會讓 Paging 程式庫處理實際擷取和本機資料庫快取 (如果使用 RemoteMediator),以維持測試的準確度。
編寫程式碼時,請採取可讓您輕鬆替換依附元件模擬版本的方式。以下範例使用基本的服務定位器實作,並透過模擬 API 設定測試,確認 Compose 畫面正確取用及顯示分頁資料。在大型應用程式中,使用依附元件插入程式庫 (例如 Hilt) 有助管理更複雜的依附元件圖表。
設定測試結構後,下一步就是驗證 Pager 實作傳回的資料是否正確。一項測試應驗證 Compose UI 在畫面初次載入時是否填入正確項目,另一項測試則應驗證 UI 是否可根據使用者互動正確載入額外資料。
在下列範例中,測試會驗證 UI 是否顯示預期的分頁資料。
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 中算繪。
其他資源
Views content
為您推薦
- 注意:系統會在 JavaScript 關閉時顯示連結文字
- 從網路和資料庫進行分頁
- 遷移至 Paging 3
- 載入並顯示分頁資料