בדיקת הטמעת הדפים

הטמעה של ספריית Paging באפליקציה צריכה להתבצע בשילוב עם אסטרטגיית בדיקה חזקה. כדאי לבדוק רכיבים לטעינת נתונים כמו PagingSource ו-RemoteMediator כדי לוודא שהם פועלים כמו שציפיתם. כדאי גם לכתוב בדיקות מקצה לקצה כדי לוודא שכל הרכיבים בהטמעה של Paging פועלים יחד בצורה תקינה בלי תופעות לוואי לא צפויות.

במדריך הזה מוסבר איך לבדוק את ספריית Paging בשכבות הארכיטקטורה השונות של האפליקציה, ואיך לכתוב בדיקות מקצה לקצה לכל ההטמעה של Paging.

בדיקות של שכבת ממשק המשתמש

מכיוון ש-Compose צורך נתוני החלפה בין דפים באופן הצהרתי דרך collectAsLazyPagingItems, הבדיקות של שכבת ממשק המשתמש יכולות להתמקד לחלוטין ב-Flow<PagingData<Value>> שמופק על ידי ViewModel. כדי לכתוב בדיקות לאימות הנתונים בממשק המשתמש, צריך לכלול את התלות paging-testing. הוא כולל את התוסף asSnapshot בתאריך Flow<PagingData<Value>>. היא מציעה ממשקי API במקלט ה-lambda שלה שמאפשרים לדמות אינטראקציות של גלילה. הפונקציה מחזירה 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 באמצעות התוסף המתאים לצרכים שלכם.
  • משתמשים בערך PagingSourceFactory שמוחזר בזיוף של 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.
}

בדיקות של שכבת הנתונים

כותבים בדיקות יחידה לרכיבים בשכבת הנתונים כדי לוודא שהם טוענים את הנתונים ממקורות הנתונים בצורה מתאימה. מספקים גרסאות מזויפות של יחסי תלות כדי לוודא שהרכיבים שנבדקים פועלים בצורה תקינה בבידוד. הרכיבים העיקריים שצריך לבדוק בשכבת המאגר הם 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 הפתרון. בדוגמה הבאה מוצגת הטמעה של RemoteMediator שדורשת מסד נתונים של Room, ממשק Retrofit ומחרוזת חיפוש:

@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 test runner כדי שתהיה לה גישה להקשר של אפליקציית בדיקה. מידע נוסף על בדיקות עם מכשור זמין במאמר יצירת בדיקות יחידה עם מכשור.

מגדירים פונקציות לניקוי כדי לוודא שהמצב לא דולף בין פונקציות הבדיקה. כך מובטחות תוצאות עקביות בין הרצות של הבדיקה.

@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. מגדירים את mockApi עם הנתונים שרוצים להחזיר בבקשת ה-POST.
  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 צריכה להקפיץ הודעת שגיאה (throw) כדי שהבדיקה תוכל לוודא שהפונקציה 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 }
}

בדיקות מקצה לקצה

בדיקות יחידה מספקות ודאות שרכיבי הדפדוף פועלים בנפרד, אבל בדיקות מקצה לקצה מספקות ודאות רבה יותר שהאפליקציה פועלת כמכלול. הבדיקות האלה עוזרות לוודא ששכבת הנתונים (PagingSource או RemoteMediator), ViewModel וממשק המשתמש של Compose משתלבים בצורה חלקה ללא תופעות לוואי בלתי צפויות. עדיין יהיו תלויות בבדיקות כמה יחידות מדומה, אבל בדרך כלל הן יכסו את רוב קוד האפליקציה.

בדוגמה שבקטע הזה נעשה שימוש בתלות מדומה ב-API כדי להימנע משימוש ברשת בבדיקות. ה-API המדומה מוגדר להחזיר קבוצה עקבית של נתוני בדיקה, וכך מאפשר בדיקות שניתנות לחזרה. בבדיקות מקצה לקצה, בדרך כלל מחליפים את ה-API האמיתי של הרשת ב-API מזויף, אבל עדיין מאפשרים לספריית Paging לטפל באחזור בפועל ובשמירת הנתונים במטמון של מסד הנתונים המקומי (אם משתמשים ב-RemoteMediator) כדי לשמור על מהימנות הבדיקות.

כדאי לכתוב את הקוד בצורה שתאפשר לכם להחליף בקלות את התלויות בגרסאות מדומה שלהן. בדוגמה הבאה נעשה שימוש בהטמעה בסיסית של איתור שירותים ומוגדרת בדיקה עם API מדומה כדי לוודא שמסך פיתוח נייטיב צורך ומציג את הנתונים המחולקים לדפים בצורה תקינה. באפליקציות גדולות יותר, שימוש בספריית הזרקת תלות כמו Hilt יכול לעזור בניהול גרפים מורכבים יותר של תלות.

אחרי שמגדירים את מבנה הבדיקה, השלב הבא הוא לוודא שהנתונים שמוחזרים מהטמעת Pager נכונים. בבדיקה אחת צריך לוודא שפריטים נכונים מאכלסים את ממשק המשתמש של פיתוח נייטיב כשהמסך נטען בפעם הראשונה, ובבדיקה אחרת צריך לוודא שממשק המשתמש טוען נתונים נוספים בצורה נכונה על סמך אינטראקציית משתמש.

בדוגמה הבאה, הבדיקה מוודאת שבממשק המשתמש מוצגים הנתונים הצפויים עם חלוקה לדפים.

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.

מקורות מידע נוספים

צפייה בתוכן