پیاده سازی Paging خود را تست کنید

پیاده‌سازی کتابخانه Paging در برنامه شما باید با یک استراتژی تست قوی همراه باشد. شما باید اجزای بارگذاری داده مانند PagingSource و RemoteMediator را آزمایش کنید تا مطمئن شوید که آنها مطابق انتظار کار می‌کنند. همچنین باید تست‌های سرتاسری بنویسید تا تأیید کنید که تمام اجزای پیاده‌سازی Paging شما به درستی و بدون عوارض جانبی غیرمنتظره با هم کار می‌کنند.

این راهنما نحوه‌ی تست کتابخانه‌ی Paging در لایه‌های مختلف معماری برنامه‌ی شما و همچنین نحوه‌ی نوشتن تست‌های سرتاسری برای کل پیاده‌سازی Paging را توضیح می‌دهد.

تست‌های لایه رابط کاربری

از آنجا که Compose داده‌های Paging را به صورت اعلانی از طریق collectAsLazyPagingItems مصرف می‌کند، تست‌های لایه رابط کاربری شما می‌توانند کاملاً روی Flow<PagingData<Value>> منتشر شده توسط ViewModel شما تمرکز کنند. برای نوشتن تست‌هایی برای تأیید اینکه داده‌ها در رابط کاربری همانطور که انتظار دارید هستند، وابستگی paging-testing اضافه کنید. این وابستگی شامل افزونه asSnapshot روی Flow<PagingData<Value>> است. این وابستگی 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 را ایجاد کنید.
  • از 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 است که درخواست‌های سرور و کلاس‌های پاسخ را تعریف می‌کند. یک نسخه جعلی می‌تواند رابط را پیاده‌سازی کند، هرگونه تابع مورد نیاز را لغو کند و روش‌های راحتی را برای پیکربندی نحوه واکنش شیء جعلی در آزمایش‌ها ارائه دهد.

پس از اینکه fakeها در جای خود قرار گرفتند، وابستگی‌ها را تنظیم کرده و شیء 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 نشان داده شده است، ارائه دهید. ارائه یک نسخه آزمایشی (mock) از پایگاه داده Room بسیار پیچیده است، بنابراین ارائه یک پیاده‌سازی درون حافظه‌ای از پایگاه داده به جای یک نسخه آزمایشی کامل می‌تواند آسان‌تر باشد. از آنجا که ایجاد یک پایگاه داده Room به یک شیء Context نیاز دارد، باید این تست RemoteMediator را در دایرکتوری androidTest قرار دهید و آن را با اجراکننده تست AndroidJUnit4 اجرا کنید تا به یک زمینه برنامه آزمایشی دسترسی داشته باشد. برای اطلاعات بیشتر در مورد تست‌های instrumented، به Build instrumented unit tests مراجعه کنید.

توابع tear-down را تعریف کنید تا اطمینان حاصل شود که حالت بین توابع تست نشت نمی‌کند. این امر نتایج ثابتی را بین اجراهای تست تضمین می‌کند.

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

آزمون‌های پایان به پایان

تست‌های واحد تضمین می‌کنند که اجزای صفحه‌بندی به صورت جداگانه کار می‌کنند، اما تست‌های سرتاسری اطمینان بیشتری از عملکرد کلی برنامه ارائه می‌دهند. این تست‌ها به تأیید ادغام یکپارچه لایه داده ( PagingSource یا RemoteMediatorViewModel و Compose UI بدون عوارض جانبی غیرمنتظره کمک می‌کنند. این تست‌ها همچنان به برخی وابستگی‌های شبیه‌سازی شده نیاز دارند، اما عموماً بیشتر کد برنامه شما را پوشش می‌دهند.

مثال این بخش از یک وابستگی API ساختگی (mock 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 زمان دهید تا بارگذاری اولیه را دریافت کرده و قبل از ایجاد assertionها، آن را به collectAsLazyPagingItems ارسال کند. برای انجام این کار، همانطور که در مثال قبل نشان داده شده است، از composeTestRule.waitUntil یا waitUntilExactlyOneExists استفاده کنید.

پس از بارگذاری داده‌ها، می‌توانید مستقیماً با استفاده از onNodeWithText در درخت معنایی Compose ادعا کنید که آیا موارد واقعاً در LazyColumn شما رندر شده‌اند یا خیر.

منابع اضافی

محتوا را مشاهده می‌کند

{% کلمه به کلمه %} {% فعل کمکی %} {% کلمه به کلمه %} {% فعل کمکی %}