Menerapkan library Paging di aplikasi Anda harus dipasangkan dengan strategi
pengujian yang kuat. Anda harus menguji komponen pemuatan data, seperti
PagingSource dan
RemoteMediator
untuk memastikan keduanya berfungsi seperti yang diharapkan. Anda juga harus menulis pengujian menyeluruh untuk
memverifikasi bahwa semua komponen dalam implementasi Paging berfungsi dengan benar
bersama-sama tanpa efek samping yang tidak terduga.
Panduan ini menjelaskan cara menguji library Paging di lapisan arsitektur yang berbeda pada aplikasi Anda, serta cara menulis pengujian menyeluruh untuk seluruh implementasi Paging.
Pengujian lapisan UI
Karena Compose menggunakan data Paging secara deklaratif melalui
collectAsLazyPagingItems, pengujian lapisan UI Anda dapat berfokus sepenuhnya pada
Flow<PagingData<Value>> yang dikeluarkan oleh ViewModel Anda. Untuk menulis pengujian guna memverifikasi
data di UI seperti yang Anda harapkan, sertakan dependensi paging-testing. Dependensi ini
berisi ekstensi asSnapshot pada Flow<PagingData<Value>>. Dependensi ini menawarkan
API di penerima lambda yang memungkinkan interaksi scroll tiruan. Dependensi ini menampilkan List<Value> standar yang dihasilkan oleh interaksi scroll tiruan.
Hal ini memungkinkan Anda menyatakan bahwa data yang di-page berisi elemen yang diharapkan yang dihasilkan oleh interaksi tersebut. Hal ini diilustrasikan dalam cuplikan
berikut:
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
)
}
Atau, Anda dapat men-scroll hingga predikat tertentu terpenuhi seperti yang terlihat dalam cuplikan di bawah:
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" }
}
Menguji transformasi
Anda juga harus menulis pengujian unit yang mencakup transformasi apa pun yang diterapkan ke
aliran PagingData. Gunakan ekstensi
asPagingSourceFactory. Ekstensi ini tersedia di jenis data berikut:
List<Value>.Flow<List<Value>>.
Pilihan ekstensi yang akan digunakan bergantung pada apa yang Anda coba uji. Gunakan:
List<Value>.asPagingSourceFactory(): Jika Anda ingin menguji transformasi statis sepertimap()daninsertSeparators()pada data.Flow<List<Value>>.asPagingSourceFactory(): Jika Anda ingin menguji pengaruh update pada data, seperti menulis ke sumber data pendukung atau pengaruhnya terhadap pipeline paging.
Untuk menggunakan salah satu ekstensi ini, ikuti pola berikut:
- Buat
PagingSourceFactorymenggunakan ekstensi yang sesuai dengan kebutuhan Anda. - Gunakan
PagingSourceFactoryyang ditampilkan dalam palsu untukRepository. - Teruskan
Repositorytersebut keViewModelAnda.
ViewModel kemudian dapat diuji seperti yang dibahas di bagian sebelumnya.
Pertimbangkan ViewModel berikut:
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
}
}
}
Untuk menguji transformasi di MyViewModel, sediakan instance
MyRepository palsu yang didelegasikan ke List untuk mewakili data yang akan diubah
seperti yang ditunjukkan dalam cuplikan berikut:
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()
}
Kemudian, Anda dapat menulis pengujian untuk logika pemisah seperti dalam cuplikan berikut:
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.
}
Pengujian lapisan data
Tulis pengujian unit untuk komponen dalam lapisan data Anda guna memastikan bahwa unit
memuat data dari sumber data Anda dengan tepat. Berikan
versi palsu
dependensi untuk memverifikasi bahwa komponen yang sedang diuji berfungsi dengan benar secara
terpisah. Komponen utama yang perlu Anda uji di lapisan repositori adalah
PagingSource dan RemoteMediator.
Pengujian PagingSource
Pengujian unit untuk implementasi PagingSource melibatkan penyiapan
instance PagingSource dan memuat data darinya dengan TestPager.
Untuk menyiapkan instance PagingSource guna pengujian, berikan data palsu ke
konstruktor. Hal ini memberi Anda kontrol atas data dalam pengujian.
Dalam contoh berikut, parameter RedditApi
adalah antarmuka Retrofit
yang menentukan permintaan server dan class respons.
Versi palsu dapat menerapkan antarmuka, mengganti fungsi yang diperlukan,
dan menyediakan metode yang mudah untuk mengonfigurasi reaksi objek palsu
dalam pengujian.
Setelah kode palsu diterapkan, siapkan dependensi dan inisialisasi objek PagingSource
dalam pengujian. Contoh berikut menunjukkan
inisialisasi objek FakeRedditApi dengan daftar postingan pengujian, dan pengujian
instance 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 juga memungkinkan Anda melakukan hal berikut:
- Uji muatan berturut-turut dari
PagingSource:
@Test
fun test_consecutive_loads() = runTest {
val page = with(pager) {
refresh()
append()
append()
} as LoadResult.Page
assertThat(page.data)
.containsExactlyElementsIn(testPosts)
.inOrder()
}
- Uji skenario error di
PagingSourceAnda:
@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()
}
}
Pengujian RemoteMediator
Sasaran pengujian unit RemoteMediator adalah untuk memverifikasi bahwa fungsi load()
menampilkan MediatorResult
yang benar.
Pengujian efek samping, seperti data yang dimasukkan ke dalam database,
lebih cocok untuk pengujian integrasi.
Langkah pertama adalah menentukan dependensi yang diperlukan
implementasi RemoteMediator Anda. Contoh berikut menunjukkan implementasi RemoteMediator
yang memerlukan database Room, antarmuka Retrofit, dan string
penelusuran:
@OptIn(ExperimentalPagingApi::class)
class PageKeyedRemoteMediator(
private val db: RedditDb,
private val redditApi: RedditApi,
private val subredditName: String
) : RemoteMediator<Int, RedditPost>() {
...
}
Anda dapat memberikan antarmuka Retrofit dan string penelusuran seperti yang ditunjukkan di
bagian pengujian PagingSource. Menyediakan versi tiruan
database Room sangat diperlukan, sehingga dapat lebih mudah untuk menyediakan
implementasi dalam memori
dari database daripada versi tiruan lengkap. Karena membuat database Room
memerlukan objek Context, Anda harus
menempatkan pengujian RemoteMediator ini di direktori androidTest dan menjalankannya
dengan runner pengujian AndroidJUnit4 sehingga memiliki akses ke konteks aplikasi
pengujian. Untuk informasi selengkapnya tentang pengujian berinstrumen, lihat Membuat pengujian unit berinstrumen.
Tentukan fungsi penghapusan untuk memastikan bahwa status tidak bocor di antara fungsi pengujian. Hal ini untuk memastikan hasil yang konsisten di antara pengujian.
@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()
}
}
Langkah berikutnya adalah menguji fungsi load(). Dalam contoh ini, ada tiga
kasus yang harus diuji:
- Kasus pertama adalah saat
mockApimenampilkan data yang valid. Fungsiload()akan menampilkanMediatorResult.Success, dan propertiendOfPaginationReachedharusfalse. - Kasus kedua adalah saat
mockApimengembalikan respons yang berhasil, tetapi data yang ditampilkan kosong. Fungsiload()harus menampilkanMediatorResult.Success, dan propertiendOfPaginationReachedharustrue. - Kasus ketiga adalah saat
mockApimemunculkan pengecualian sewaktu mengambil data. Fungsiload()harus menampilkanMediatorResult.Error.
Ikuti langkah-langkah berikut untuk menguji kasus pertama:
- Siapkan
mockApidengan data postingan yang akan ditampilkan. - Inisialisasi objek
RemoteMediator. - Uji fungsi
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 }
}
Pengujian kedua memerlukan mockApi untuk menampilkan hasil kosong. Karena Anda
menghapus data dari mockApi setelah setiap pengujian dijalankan, data akan menampilkan
hasil kosong secara default.
@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 }
}
Pengujian terakhir memerlukan mockApi untuk menampilkan pengecualian sehingga pengujian dapat
memverifikasi bahwa fungsi load() menampilkan MediatorResult.Error dengan benar.
@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 }
}
Pengujian menyeluruh
Pengujian unit memberikan jaminan bahwa setiap komponen Paging berfungsi
secara terpisah, tetapi pengujian menyeluruh memberikan keyakinan lebih bahwa aplikasi
berfungsi secara keseluruhan. Pengujian ini membantu memverifikasi bahwa lapisan data
(PagingSource atau RemoteMediator), ViewModel, dan UI Compose terintegrasi
dengan lancar tanpa efek samping yang tidak terduga. Pengujian masih memerlukan beberapa dependensi tiruan, tetapi umumnya akan mencakup sebagian besar kode aplikasi Anda.
Contoh di bagian ini menggunakan dependensi tiruan API untuk menghindari penggunaan
jaringan dalam pengujian. API tiruan dikonfigurasi untuk mengembalikan kumpulan data pengujian yang konsisten,
sehingga menghasilkan pengujian berulang. Untuk pengujian end-to-end, Anda biasanya mengganti
API jaringan asli dengan API palsu, tetapi Anda tetap membiarkan library Paging
menangani pengambilan data dan caching database lokal yang sebenarnya (jika menggunakan
RemoteMediator) untuk mempertahankan keakuratan pengujian Anda.
Tulis kode Anda dengan cara yang memudahkan Anda menukar versi tiruan dependensi Anda. Contoh berikut menggunakan implementasi pencari lokasi layanan dasar dan menyiapkan pengujian dengan API tiruan untuk memverifikasi bahwa layar Compose menggunakan dan menampilkan data yang di-paging dengan benar. Pada aplikasi yang lebih besar, penggunaan library injeksi dependensi seperti Hilt dapat membantu mengelola grafik dependensi yang lebih kompleks.
Setelah Anda menyiapkan struktur pengujian, langkah berikutnya adalah memverifikasi bahwa data
yang ditampilkan oleh implementasi Pager sudah benar. Satu pengujian harus memverifikasi bahwa
UI Compose diisi dengan item yang benar saat layar pertama kali dimuat, dan
pengujian lain harus memverifikasi bahwa UI memuat data tambahan dengan benar berdasarkan
interaksi pengguna.
Dalam contoh berikut, pengujian memverifikasi bahwa UI menampilkan data berpembagian halaman yang diharapkan.
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()
}
}
Karena Flow<PagingData> memuat data secara asinkron, Anda harus memberi waktu pada library Paging untuk mengambil muatan awal dan memancarkannya ke collectAsLazyPagingItems sebelum membuat pernyataan. Untuk melakukannya, gunakan
composeTestRule.waitUntil atau waitUntilExactlyOneExists, seperti yang ditunjukkan pada
contoh sebelumnya.
Setelah data dimuat, Anda dapat melakukan pernyataan langsung terhadap hierarki semantik Compose menggunakan onNodeWithText untuk memverifikasi bahwa item benar-benar dirender di LazyColumn Anda.
Referensi lainnya
Melihat konten
Direkomendasikan untuk Anda
- Catatan: teks link ditampilkan saat JavaScript nonaktif
- Halaman dari jaringan dan database
- Bermigrasi ke Paging 3
- Memuat dan menampilkan data yang dibagi-bagi