Sayfalandırma uygulamanızı test edin

Uygulamanızda Paging kitaplığını kullanırken sağlam bir test stratejisi de uygulamanız gerekir. PagingSource ve RemoteMediator gibi veri yükleme bileşenlerini test ederek beklendiği gibi çalıştıklarından emin olmanız gerekir. Ayrıca, Sayfalama uygulamanızdaki tüm bileşenlerin beklenmedik yan etkiler olmadan birlikte doğru şekilde çalıştığını doğrulamak için uçtan uca testler yazmanız gerekir.

Bu kılavuzda, uygulamanızın farklı mimari katmanlarında Paging kitaplığının nasıl test edileceği ve tüm Paging uygulamanız için uçtan uca testlerin nasıl yazılacağı açıklanmaktadır.

Kullanıcı arayüzü katmanı testleri

Compose, Paging verilerini collectAsLazyPagingItems aracılığıyla bildirimli olarak kullandığından kullanıcı arayüzü katmanı testleriniz öncelikle ViewModel'iniz tarafından yayınlanan Flow<PagingData<Value>> öğesine odaklanabilir. Kullanıcı arayüzündeki verilerin beklediğiniz gibi olduğunu doğrulamak için testler yazmak istiyorsanız paging-testing bağımlılığını ekleyin. Flow<PagingData<Value>> üzerinde asSnapshot uzantısını içerir. Lambda alıcısında, kaydırma etkileşimlerini taklit etmeye olanak tanıyan API'ler sunar. Taklit edilen kaydırma etkileşimleri tarafından üretilen standart bir List<Value> döndürür. Bu sayede, sayfalanan verilerin bu etkileşimler tarafından oluşturulan beklenen öğeleri içerdiğini doğrulayabilirsiniz. Bu durum, aşağıdaki snippet'te gösterilmektedir:

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

Alternatif olarak, aşağıdaki snippet'te gösterildiği gibi belirli bir koşul karşılanana kadar kaydırabilirsiniz:

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

Dönüşümleri test etme

Ayrıca PagingData akışına uyguladığınız tüm dönüşümleri kapsayan birim testleri de yazmanız gerekir. asPagingSourceFactory uzantısını kullanın. Bu uzantı aşağıdaki veri türlerinde kullanılabilir:

  • List<Value>.
  • Flow<List<Value>>.

Hangi uzantıyı kullanacağınız, neyi test etmek istediğinize bağlıdır. Şu ayarı kullanın:

  • List<Value>.asPagingSourceFactory(): Veriler üzerinde map() ve insertSeparators() gibi statik dönüşümleri test etmek istiyorsanız.
  • Flow<List<Value>>.asPagingSourceFactory(): Verilerinizde yapılan güncellemelerin (ör. yedek veri kaynağına yazma) sayfalama ardışık düzeninizi nasıl etkilediğini test etmek istiyorsanız.

Bu uzantılardan birini kullanmak için aşağıdaki kalıbı uygulayın:

  • İhtiyaçlarınıza uygun uzantıyı kullanarak PagingSourceFactory oluşturun.
  • Döndürülen PagingSourceFactory değerini Repository için bir sahte içinde kullanın.
  • Bu Repository değerini ViewModel öğenize iletin.

Ardından ViewModel, önceki bölümde açıklandığı şekilde test edilebilir. Aşağıdakileri göz önünde bulundurun 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 içindeki dönüşümü test etmek için aşağıdaki snippet'te gösterildiği gibi, dönüştürülecek verileri temsil eden statik bir List öğesine yetki veren sahte bir MyRepository örneği sağlayın:

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()
}

Ardından, aşağıdaki snippet'te olduğu gibi ayırıcı mantığı için bir test yazabilirsiniz:

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

Compose yükleme durumlarını test etme

Oluşturma kullanıcı arayüzü bileşenlerinizin farklı yükleme durumlarına (ör. LoadState.Loading veya LoadState.Error) nasıl tepki verdiğini ayrı ayrı test etmek için PagingData.empty ve PagingData.from ile açık LoadStates içeren statik PagingData anlık görüntüleri MutableStateFlow aracılığıyla yayınlayın:

@get:Rule
val composeTestRule = createComposeRule()

@Test
fun userListScreen_displaysLoadingDataAndErrorStates() {
  val idle = LoadState.NotLoading(endOfPaginationReached = false)
  // Emit static PagingData snapshots to test UI state transitions without a
  // Pager or ViewModel.
  val pagingDataFlow = MutableStateFlow(PagingData.empty<User>())

  composeTestRule.setContent {
    val lazyPagingItems = pagingDataFlow.collectAsLazyPagingItems()
    UserListScreen(lazyPagingItems = lazyPagingItems)
  }

  // Verify the initial loading UI before any items arrive.
  pagingDataFlow.value = PagingData.empty(
    sourceLoadStates = LoadStates(
      refresh = LoadState.Loading,
      prepend = idle,
      append = idle
    )
  )
  composeTestRule.onNodeWithTag("LoadingSpinner").assertIsDisplayed()

  // Verify that loaded items replace the loading indicator.
  pagingDataFlow.value = PagingData.from(
    data = listOf(User(id = "1", label = "User A")),
    sourceLoadStates = LoadStates(
      refresh = idle,
      prepend = idle,
      append = idle
    )
  )
  composeTestRule.onNodeWithTag("LoadingSpinner").assertIsNotDisplayed()
  composeTestRule.onNodeWithText("User A").assertIsDisplayed()

  // Verify that a refresh failure surfaces the error message in the UI.
  pagingDataFlow.value = PagingData.empty(
    sourceLoadStates = LoadStates(
      refresh = LoadState.Error(IOException("Network unavailable")),
      prepend = idle,
      append = idle
    )
  )
  composeTestRule.onNodeWithText("Network unavailable").assertIsDisplayed()
}

Veri katmanı testleri

Veri katmanınızdaki bileşenler için birim testleri yazarak veri kaynaklarınızdaki verileri doğru şekilde yüklediklerinden emin olun. Test edilen bileşenlerin tek başına doğru şekilde çalıştığını doğrulamak için bağımlılıkların sahte sürümlerini sağlayın. Depo katmanında test etmeniz gereken ana bileşenler PagingSource ve RemoteMediator'dir.

PagingSource test

PagingSource uygulamanızın birim testleri, PagingSource örneğini ayarlamayı ve TestPager ile bu örnekten veri yüklemeyi içerir.

PagingSource örneğini test için ayarlamak üzere oluşturucuya sahte veriler sağlayın. Bu sayede testlerinizdeki veriler üzerinde kontrol sahibi olursunuz. Aşağıdaki örnekte, RedditApi parametresi, sunucu isteklerini ve yanıt sınıflarını tanımlayan bir Retrofit arayüzüdür. Sahte bir sürüm, arayüzü uygulayabilir, gerekli işlevleri geçersiz kılabilir ve sahte nesnenin testlerde nasıl tepki vermesi gerektiğini yapılandırmak için kolaylık yöntemleri sağlayabilir.

Sahte nesneler yerleştirildikten sonra bağımlılıkları ayarlayın ve testte PagingSource nesnesini ilk kullanıma hazırlayın. Aşağıdaki örnekte, FakeRedditApi nesnesinin test gönderileri listesiyle başlatılması ve RedditPagingSource örneğinin test edilmesi gösterilmektedir:

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 ile şunları da yapabilirsiniz:

  • PagingSource uygulamanızdan art arda yüklemeleri test edin:
    @Test
    fun test_consecutive_loads() = runTest {

      val page = with(pager) {
        refresh()
        append()
        append()
      } as LoadResult.Page

      assertThat(page.data)
      .containsExactlyElementsIn(testPosts)
      .inOrder()
    }
  • PagingSource uygulamanızdaki hata senaryolarını test edin:
    @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 test

RemoteMediator birim testlerinin amacı, load() işlevinin doğru MediatorResult değerini döndürdüğünü doğrulamaktır. Veritabanına veri eklenmesi gibi yan etkilerle ilgili testler, entegrasyon testleri için daha uygundur.

İlk adım, RemoteMediator uygulamanızın hangi bağımlılıklara ihtiyaç duyduğunu belirlemektir. Aşağıdaki örnekte, Room veritabanı, Retrofit arayüzü ve arama dizesi gerektiren bir RemoteMediator uygulaması gösterilmektedir:

@OptIn(ExperimentalPagingApi::class)
class PageKeyedRemoteMediator(
  private val db: RedditDb,
  private val redditApi: RedditApi,
  private val subredditName: String
) : RemoteMediator&lt;Int, RedditPost&gt;() {
  ...
}

PagingSource testleri bölümünde gösterildiği gibi Retrofit arayüzünü ve arama dizesini sağlayabilirsiniz. Room veritabanının sahte bir sürümünü sağlamak çok karmaşık olduğundan, tam bir sahte sürüm yerine veritabanının bellek içi uygulamasını sağlamak daha kolay olabilir. Room veritabanı oluşturmak için Context nesnesi gerektiğinden bu RemoteMediator testini androidTest dizinine yerleştirmeniz ve test uygulaması bağlamına erişebilmesi için AndroidJUnit4 test çalıştırıcısıyla yürütmeniz gerekir. Enstrümantasyon testleri hakkında daha fazla bilgi için Enstrümantasyon birim testleri oluşturma başlıklı makaleyi inceleyin.

Test işlevleri arasında durum sızıntısı olmaması için kaldırma işlevleri tanımlayın. Bu sayede, test çalıştırmaları arasında tutarlı sonuçlar elde edilir.

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

Bir sonraki adım, load() işlevini test etmektir. Bu örnekte test edilecek üç durum var:

  • İlk durumda mockApi geçerli veriler döndürür. load() işlevi MediatorResult.Success döndürmeli ve endOfPaginationReached özelliği false olmalıdır.
  • İkinci durum ise mockApi başarılı bir yanıt döndürdüğünde ancak döndürülen veriler boş olduğunda ortaya çıkar. load() işlevi MediatorResult.Success döndürmeli ve endOfPaginationReached özelliği true olmalıdır.
  • Üçüncü durum ise mockApi, verileri getirirken bir istisna oluşturduğunda ortaya çıkar. load() işlevi MediatorResult.Error döndürmelidir.

İlk senaryoyu test etmek için aşağıdaki adımları uygulayın:

  1. mockApi, döndürülecek yayın verileriyle ayarlayın.
  2. RemoteMediator nesnesini başlatın.
  3. load() işlevini test edin.
@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 }
}

İkinci testte mockApi boş bir sonuç döndürmelidir. Her test çalıştırmasından sonra mockApi verilerini temizlediğiniz için varsayılan olarak boş bir sonuç döndürülür.

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

Son testte, mockApi işlevinin bir istisna oluşturması gerekir. Böylece test, load() işlevinin MediatorResult.Error değerini doğru şekilde döndürdüğünü doğrulayabilir.

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

Uçtan uca testler

Birim testleri, bağımsız Sayfalama bileşenlerinin ayrı ayrı çalıştığına dair güvence verir ancak uçtan uca testler, uygulamanın bir bütün olarak çalıştığına dair daha fazla güven sağlar. Bu testler, veri katmanınızın (PagingSource veya RemoteMediator), ViewModel ve Compose kullanıcı arayüzünün beklenmedik yan etkiler olmadan sorunsuz bir şekilde entegre edildiğini doğrulamanıza yardımcı olur. Testler için yine de bazı sahte bağımlılıklar gerekir ancak genellikle uygulama kodunuzun çoğu test edilir.

Bu bölümdeki örnekte, testlerde ağ kullanılmaması için sahte bir API bağımlılığı kullanılmıştır. Sahte API, tutarlı bir test verisi kümesi döndürecek şekilde yapılandırılmıştır. Bu sayede testler tekrarlanabilir. Uçtan uca testler için genellikle gerçek ağ API'nizi sahte bir API ile değiştirirsiniz ancak testlerinizin doğruluğunu korumak için Paging kitaplığının gerçek getirme ve yerel veritabanı önbelleğe alma işlemlerini (RemoteMediator kullanıyorsanız) yapmasına izin verirsiniz.

Kodunuzu, bağımlılıklarınızın sahte sürümlerini kolayca değiştirebileceğiniz şekilde yazın. Aşağıdaki örnekte, temel bir hizmet bulucu uygulaması kullanılmış ve bir Compose ekranının, sayfalı verileri doğru şekilde kullanıp görüntülediğini doğrulamak için sahte bir API ile test oluşturulmuştur. Daha büyük uygulamalarda, Hilt gibi bir bağımlılık ekleme kitaplığı kullanmak daha karmaşık bağımlılık grafiklerini yönetmenize yardımcı olabilir.

Test yapısını oluşturduktan sonraki adım, Pager uygulamasının döndürdüğü verilerin doğru olduğunu doğrulamaktır. Testlerden biri, ekran ilk yüklendiğinde Compose kullanıcı arayüzünün doğru öğelerle doldurulduğunu doğrulamalı, diğeri ise kullanıcı arayüzünün kullanıcı etkileşimine göre ek verileri doğru şekilde yüklediğini doğrulamalıdır.

Aşağıdaki örnekte test, kullanıcı arayüzünün beklenen sayfalara ayrılmış verileri gösterdiğini doğrular.

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>, verileri eşzamansız olarak yüklediğinden, iddialarda bulunmadan önce Paging kitaplığına ilk yüklemeyi getirmesi ve collectAsLazyPagingItems'e göndermesi için zaman vermeniz gerekir. Bunun için, önceki örnekte gösterildiği gibi composeTestRule.waitUntil veya waitUntilExactlyOneExists kullanın.

Veriler yüklendikten sonra, öğelerin LazyColumn uygulamanızda gerçekten oluşturulduğunu doğrulamak için onNodeWithText kullanarak doğrudan Compose semantik ağacına karşı onaylama yapabilirsiniz.

Ek kaynaklar

İçerik görüntüleme