پیادهسازی کتابخانه 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<Int, RedditPost>() {
...
}
شما میتوانید رابط 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را برگرداند.
برای بررسی حالت اول، مراحل زیر را دنبال کنید:
-
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 }
}
آزمونهای پایان به پایان
تستهای واحد تضمین میکنند که اجزای صفحهبندی به صورت جداگانه کار میکنند، اما تستهای سرتاسری اطمینان بیشتری از عملکرد کلی برنامه ارائه میدهند. این تستها به تأیید ادغام یکپارچه لایه داده ( PagingSource یا RemoteMediator )، ViewModel و 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 شما رندر شدهاند یا خیر.
منابع اضافی
محتوا را مشاهده میکند
{% کلمه به کلمه %}برای شما توصیه میشود
- توجه: متن لینک زمانی نمایش داده میشود که جاوا اسکریپت غیرفعال باشد.
- صفحه از شبکه و پایگاه داده
- مهاجرت به صفحهبندی ۳
- بارگذاری و نمایش دادههای صفحهبندیشده