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

يجب أن يكون استخدام مكتبة 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 الذي تم إرجاعه في عملية تزوير 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. يوضّح المثال التالي عملية تنفيذ تتطلّب قاعدة بيانات Room وواجهة Retrofit وسلسلة بحث:RemoteMediator

@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.

مراجع إضافية

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