כשמטמיעים את ספריית Paging באפליקציה, צריך לשלב אותה עם אסטרטגיית בדיקה חזקה. כדאי לבדוק רכיבים של טעינת נתונים כמו PagingSource ו-RemoteMediator כדי לוודא שהם פועלים כמו שצריך. כדאי גם לכתוב בדיקות מקצה לקצה כדי לוודא שכל הרכיבים בהטמעה של Paging פועלים יחד בצורה תקינה, בלי תופעות לוואי לא צפויות.
במדריך הזה מוסבר איך לבדוק את ספריית Paging בשכבות הארכיטקטורה השונות של האפליקציה, ואיך לכתוב בדיקות מקצה לקצה לכל ההטמעה של Paging.
בדיקות של שכבת ממשק המשתמש
מכיוון ש-Compose צורך נתוני Paging באופן הצהרתי דרך collectAsLazyPagingItems, הבדיקות של שכבת ממשק המשתמש יכולות להתמקד בעיקר ב-Flow<PagingData<Value>> שנוצר על ידי ה-ViewModel. כדי לכתוב בדיקות שמאמתות שהנתונים בממשק המשתמש הם כמו שציפיתם, צריך לכלול את התלות paging-testing. הוא מכיל את התוסף asSnapshot ב-Flow<PagingData<Value>>. הוא מציע ממשקי API ב-lambda receiver שמאפשרים לדמות אינטראקציות של גלילה. היא מחזירה 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" }
}
בדיקת הטרנספורמציות
כדאי גם לכתוב בדיקות יחידה (unit tests) שמכסות את כל הטרנספורמציות שאתם מחילים על הסטרימינג של PagingData. משתמשים בסיומת asPagingSourceFactory. התוסף הזה זמין בסוגי הנתונים הבאים:
List<Value>.Flow<List<Value>>.
הבחירה בתוסף שבו תשתמשו תלויה במה שאתם מנסים לבדוק. שימוש:
-
List<Value>.asPagingSourceFactory(): אם רוצים לבדוק טרנספורמציות סטטיות כמוmap()ו-insertSeparators()בנתונים. -
Flow<List<Value>>.asPagingSourceFactory(): אם רוצים לבדוק איך עדכונים בנתונים, כמו כתיבה למקור הנתונים לגיבוי, משפיעים על צינור עיבוד הנתונים של הדפדוף.
כדי להשתמש באחד מהתוספים האלה, צריך לפעול לפי התבנית הבאה:
- יוצרים את
PagingSourceFactoryבאמצעות התוסף שמתאים לצרכים שלכם. - משתמשים ב-
PagingSourceFactoryשהוחזר ב-fake בשבילRepository. - מעבירים את
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) בנפרד, פולטים תמונות מצב סטטיות של PagingData עם LoadStates מפורש באמצעות PagingData.empty ו-PagingData.from דרך MutableStateFlow:
@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 לבדיקה, צריך לספק נתונים מזויפים ל-constructor. כך אתם יכולים לשלוט בנתונים בבדיקות שלכם. בדוגמה הבאה, הפרמטר 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. בדוגמה הבאה מוצגת הטמעה של RemoteMediator שדורשת מסד נתונים של Room, ממשק Retrofit ומחרוזת חיפוש:
@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.
כדי לבדוק את המקרה הראשון, פועלים לפי השלבים הבאים:
- מגדירים את
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 לפני שיוצרים טענות נכונות (assertions). כדי לעשות זאת, משתמשים ב-composeTestRule.waitUntil או ב-waitUntilExactlyOneExists, כמו שמוצג בדוגמה הקודמת.
אחרי שהנתונים נטענים, אפשר להצהיר ישירות על העץ הסמנטי של Compose באמצעות onNodeWithText כדי לוודא שהפריטים באמת עוברים רינדור ב-LazyColumn.
מקורות מידע נוספים
צפייה בתוכן
מומלץ בשבילכם
- הערה: טקסט הקישור מוצג כש-JavaScript מושבת
- דף מהרשת וממסד הנתונים
- מעבר ל-Paging 3
- טעינה והצגה של נתונים בדפים