التحقق من تنفيذ عملية "نقل الصفحات"

يجب أن يقترن تنفيذ مكتبة Paging في تطبيقك باستراتيجية اختبار قوية. يجب اختبار مكوّنات تحميل البيانات، مثل PagingSource وRemoteMediator للتأكّد من أنّها تعمل على النحو المتوقّع. يجب أيضًا كتابة اختبارات شاملة للتحقّق من أنّ جميع المكوّنات في عملية تنفيذ Paging تعمل معًا بشكل صحيح بدون آثار جانبية غير متوقّعة.

يوضّح هذا الدليل كيفية اختبار مكتبة Paging في طبقات البنية المختلفة لتطبيقك، بالإضافة إلى كيفية كتابة اختبارات شاملة لتنفيذ مكتبة Paging بالكامل.

اختبارات طبقة واجهة المستخدم

بما أنّ Compose يستهلك بيانات Paging بشكل تعريفي من خلال collectAsLazyPagingItems، يمكن أن تركّز اختبارات طبقة واجهة المستخدم بشكل أساسي على Flow<PagingData<Value>> التي يصدرها ViewModel. لكتابة اختبارات للتحقّق من أنّ البيانات في واجهة المستخدم تظهر بالشكل المتوقّع، يجب تضمين التبعية paging-testing. وتحتوي على إضافة asSnapshot على Flow<PagingData<Value>>. وتوفّر واجهات برمجة تطبيقات في دالة الاستقبال 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 التي تم عرضها في 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 للاختبار، قدِّم بيانات وهمية إلى الدالة الإنشائية. ويمنحك ذلك إمكانية التحكّم في البيانات في اختباراتك. في المثال التالي، تكون المَعلمة 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 لكي يتمكّن من الوصول إلى سياق تطبيق اختباري. لمزيد من المعلومات حول اختبارات قياس حالة التطبيق، يُرجى الاطّلاع على إنشاء اختبارات وحدة لقياس حالة التطبيق.

تحديد دوال الإزالة لضمان عدم تسرُّب الحالة بين دوال الاختبار يضمن ذلك الحصول على نتائج متّسقة بين عمليات الاختبار.

@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 باستخدام بيانات المشاركة التي تريد عرضها.
  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 تتكامل بسلاسة بدون آثار جانبية غير متوقّعة. ستظل الاختبارات بحاجة إلى بعض التبعيات الوهمية، ولكنها ستغطي بشكل عام معظم الرموز البرمجية لتطبيقك.

يستخدم المثال في هذا القسم تبعية واجهة برمجة تطبيقات وهمية لتجنب استخدام الشبكة في الاختبارات. تم ضبط واجهة برمجة التطبيقات الوهمية لعرض مجموعة متسقة من بيانات الاختبار، ما يؤدي إلى اختبارات قابلة للتكرار. بالنسبة إلى الاختبارات الشاملة، يمكنك عادةً استبدال واجهة برمجة التطبيقات الحقيقية للشبكة بواجهة وهمية، ولكنك تترك لمكتبة Paging مهمة معالجة عملية الجلب الفعلية والتخزين المؤقت لقاعدة البيانات المحلية (في حال استخدام RemoteMediator) للحفاظ على دقة الاختبارات.

اكتب التعليمات البرمجية بطريقة تتيح لك التبديل بسهولة بين الإصدارات الوهمية من التبعيات. يستخدم المثال التالي تنفيذًا أساسيًا لمحدّد موقع الخدمة ويُعدّ اختبارًا باستخدام واجهة برمجة تطبيقات وهمية للتحقّق من أنّ شاشة 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، كما هو موضح في المثال السابق.

بعد تحميل البيانات، يمكنك التأكيد مباشرةً على شجرة دلالات Compose باستخدام onNodeWithText للتحقّق من أنّ العناصر يتم عرضها فعليًا في LazyColumn.

مراجع إضافية

محتوى المشاهدات