Menguji implementasi Paging

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 seperti map() dan insertSeparators() 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 PagingSourceFactory menggunakan ekstensi yang sesuai dengan kebutuhan Anda.
  • Gunakan PagingSourceFactory yang ditampilkan dalam palsu untuk Repository.
  • Teruskan Repository tersebut ke ViewModel Anda.

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 PagingSource Anda:
    @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&lt;Int, RedditPost&gt;() {
  ...
}

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 mockApi menampilkan data yang valid. Fungsi load() akan menampilkan MediatorResult.Success, dan properti endOfPaginationReached harus false.
  • Kasus kedua adalah saat mockApi mengembalikan respons yang berhasil, tetapi data yang ditampilkan kosong. Fungsi load() harus menampilkan MediatorResult.Success, dan properti endOfPaginationReached harus true.
  • Kasus ketiga adalah saat mockApi memunculkan pengecualian sewaktu mengambil data. Fungsi load() harus menampilkan MediatorResult.Error.

Ikuti langkah-langkah berikut untuk menguji kasus pertama:

  1. Siapkan mockApi dengan data postingan yang akan ditampilkan.
  2. Inisialisasi objek RemoteMediator.
  3. 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&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 }
}

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

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&lt;Int, RedditPost&gt;(
    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