Testowanie implementacji stronicowania

Implementacja biblioteki Paging w aplikacji powinna być połączona z solidną strategią testowania. Musisz przetestować komponenty wczytywania danych, takie jak PagingSource i RemoteMediator , aby mieć pewność, że działają zgodnie z oczekiwaniami. Powinieneś też napisać testy kompleksowe, aby sprawdzić, czy wszystkie komponenty w implementacji biblioteki Paging działają prawidłowo i nie powodują nieoczekiwanych skutków ubocznych.

Z tego przewodnika dowiesz się, jak testować bibliotekę Paging w różnych warstwach architektury aplikacji oraz jak pisać testy kompleksowe dla całej implementacji biblioteki Paging.

Testy warstwy interfejsu

Ponieważ Compose deklaratywnie wykorzystuje dane biblioteki Paging za pomocą collectAsLazyPagingItems, testy warstwy interfejsu mogą się skupić wyłącznie na Flow<PagingData<Value>> emitowanej przez ViewModel. Aby napisać testy sprawdzające, czy dane w interfejsie są zgodne z oczekiwaniami, dodaj zależność paging-testing. Zawiera ona rozszerzenie asSnapshot w funkcji Flow<PagingData<Value>>. Oferuje interfejsy API w odbiorniku lambda, które umożliwiają symulowanie interakcji przewijania. Zwraca standardowy List<Value> utworzony przez symulowane interakcje przewijania. Dzięki temu możesz sprawdzić, czy dane, które są podzielone na strony, zawierają oczekiwane elementy wygenerowane przez te interakcje. Ilustruje to ten fragment kodu:

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

Możesz też przewijać, aż zostanie spełniony dany predykat, jak w poniższym fragmencie kodu:

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

Testowanie transformacji

Powinieneś też napisać testy jednostkowe, które obejmują wszystkie transformacje stosowane do strumienia PagingData. Użyj rozszerzenia asPagingSourceFactory. To rozszerzenie jest dostępne w przypadku tych typów danych:

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

Wybór rozszerzenia zależy od tego, co chcesz przetestować. Użyj:

  • List<Value>.asPagingSourceFactory(): jeśli chcesz przetestować statyczne transformacje danych, takie jak map() i insertSeparators().
  • Flow<List<Value>>.asPagingSourceFactory(): jeśli chcesz przetestować, jak aktualizacje danych, np. zapisywanie w źródle danych, wpływają na potok paginacji.

Aby użyć dowolnego z tych rozszerzeń, wykonaj te czynności:

  • Utwórz PagingSourceFactory, używając odpowiedniego rozszerzenia.
  • Użyj zwróconej funkcji PagingSourceFactory w fałszywej wersji funkcji Repository.
  • Przekaż tę funkcję Repository do funkcji ViewModel.

Funkcję ViewModel można następnie przetestować zgodnie z opisem w poprzedniej sekcji. Rozważmy tę funkcję 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
      }
  }
}

Aby przetestować transformację w funkcji MyViewModel, podaj fałszywą instancję funkcji MyRepository, która przekazuje dane do statycznej listy List reprezentującej dane do przekształcenia, jak pokazano w tym fragmencie kodu:

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

Następnie możesz napisać test logiki separatora, jak w tym fragmencie kodu:

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

Testy warstwy danych

Napisz testy jednostkowe dla komponentów w warstwie danych, aby mieć pewność, że prawidłowo wczytują dane ze źródeł danych. Podaj fałszywe wersje zależności, aby sprawdzić, czy testowane komponenty działają prawidłowo w izolacji. Główne komponenty, które musisz przetestować w warstwie repozytorium, to PagingSource i RemoteMediator.

Testy funkcji PagingSource

Testy jednostkowe implementacji funkcji PagingSource obejmują skonfigurowanie instancji PagingSource i wczytanie z niej danych za pomocą funkcji TestPager.

Aby skonfigurować instancję PagingSource do testowania, podaj fałszywe dane w konstruktorze. Dzięki temu masz kontrolę nad danymi w testach. W tym przykładzie parametr to interfejs Retrofit, który definiuje żądania serwera i klasy odpowiedzi.RedditApi Fałszywa wersja może implementować interfejs, zastępować wymagane funkcje i udostępniać metody ułatwiające konfigurowanie sposobu, w jaki fałszywy obiekt powinien reagować w testach.

Gdy fałszywe wersje są gotowe, skonfiguruj zależności i zainicjuj obiekt PagingSource w teście. Ten przykład pokazuje inicjowanie obiektu FakeRedditApi za pomocą listy postów testowych i testowanie instancji 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()
  }
}

Funkcja TestPager umożliwia też wykonanie tych czynności:

  • Testowanie kolejnych wczytań z funkcji PagingSource:
    @Test
    fun test_consecutive_loads() = runTest {

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

      assertThat(page.data)
      .containsExactlyElementsIn(testPosts)
      .inOrder()
    }
  • Testowanie scenariuszy błędów w funkcji 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()
        }
    }

Testy funkcji RemoteMediator

Celem testów jednostkowych funkcji RemoteMediator jest sprawdzenie, czy funkcja load() zwraca prawidłowy MediatorResult. Testy skutków ubocznych, takich jak wstawianie danych do bazy danych, są lepiej nadają się do testów integracyjnych.

Pierwszym krokiem jest określenie, jakich zależności potrzebuje implementacja funkcji RemoteMediator. Ten przykład pokazuje implementację funkcji RemoteMediator, która wymaga bazy danych Room, interfejsu Retrofit i ciągu wyszukiwania:

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

Interfejs Retrofit i ciąg wyszukiwania możesz podać tak jak w sekcji Testy funkcji PagingSource. Podanie fałszywej wersji bazy danych Room jest bardzo skomplikowane, dlatego łatwiej jest podać implementację w pamięci bazy danych zamiast pełnej fałszywej wersji. Ponieważ utworzenie bazy danych Room wymaga obiektu Context, musisz umieścić ten test funkcji RemoteMediator w katalogu androidTest i uruchomić go za pomocą narzędzia do uruchamiania testów AndroidJUnit4, aby miał dostęp do kontekstu aplikacji testowej. Więcej informacji o testach instrumentowanych znajdziesz w artykule Tworzenie instrumentowanych testów jednostkowych.

Zdefiniuj funkcje czyszczenia, aby mieć pewność, że stan nie będzie się przenosić między funkcjami testowymi. Zapewnia to spójne wyniki między uruchomieniami testów.

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

Następnym krokiem jest przetestowanie funkcji load(). W tym przykładzie są 3 przypadki do przetestowania:

  • Pierwszy przypadek to sytuacja, gdy funkcja mockApi zwraca prawidłowe dane. Funkcja load() powinna zwrócić wartość MediatorResult.Success, a właściwość endOfPaginationReached powinna mieć wartość false.
  • Drugi przypadek to sytuacja, gdy funkcja mockApi zwraca odpowiedź z informacją o powodzeniu, ale zwrócone dane są puste. Funkcja load() powinna zwrócić wartość MediatorResult.Success, a właściwość endOfPaginationReached powinna mieć wartość true.
  • Trzeci przypadek to sytuacja, gdy funkcja mockApi zgłasza wyjątek podczas pobierania danych. Funkcja load() powinna zwrócić wartość MediatorResult.Error.

Aby przetestować pierwszy przypadek, wykonaj te czynności:

  1. Skonfiguruj funkcję mockApi z danymi postów do zwrócenia.
  2. Zainicjuj obiekt RemoteMediator.
  3. Przetestuj funkcję 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 }
}

Drugi test wymaga, aby funkcja mockApi zwróciła pusty wynik. Ponieważ po każdym uruchomieniu testu czyścisz dane z funkcji mockApi, domyślnie zwraca ona pusty wynik.

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

Ostatni test wymaga, aby funkcja mockApi zgłosiła wyjątek, aby test mógł sprawdzić, czy funkcja load() prawidłowo zwraca wartość 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 }
}

Testy kompleksowe

Testy jednostkowe zapewniają, że poszczególne komponenty biblioteki Paging działają w izolacji, ale testy kompleksowe dają większą pewność, że aplikacja działa jako całość. Te testy pomagają sprawdzić, czy warstwa danych (PagingSource lub RemoteMediator), ViewModel i interfejs Compose UI integrują się bezproblemowo i nie powodują nieoczekiwanych skutków ubocznych. Testy nadal będą wymagać niektórych fałszywych zależności, ale ogólnie obejmą większość kodu aplikacji.

W przykładzie w tej sekcji używamy fałszywej zależności interfejsu API, aby uniknąć korzystania z sieci w testach. Fałszywy interfejs API jest skonfigurowany tak, aby zwracać spójny zestaw danych testowych, co umożliwia powtarzanie testów. W przypadku testów kompleksowych zwykle zastępujesz prawdziwy interfejs API sieci fałszywym, ale nadal pozwalasz bibliotece Paging obsługiwać rzeczywiste pobieranie i buforowanie w lokalnej bazie danych (jeśli używasz funkcji RemoteMediator), aby zachować wierność testów.

Napisz kod w taki sposób, aby można było łatwo zastąpić zależności fałszywymi wersjami. Ten przykład używa podstawowej implementacji lokalizatora usług i konfiguruje test z fałszywym interfejsem API, aby sprawdzić, czy ekran Compose prawidłowo wykorzystuje i wyświetla dane podzielone na strony. W większych aplikacjach użycie a biblioteki wstrzykiwania zależności, takiej jak Hilt, może pomóc w zarządzaniu bardziej złożonymi grafami zależności.

Po skonfigurowaniu struktury testu następnym krokiem jest sprawdzenie, czy dane zwracane przez implementację funkcji Pager są prawidłowe. Jeden test powinien sprawdzać, czy interfejs Compose UI jest wypełniany prawidłowymi elementami po pierwszym wczytaniu ekranu, a drugi test powinien sprawdzać, czy interfejs prawidłowo wczytuje dodatkowe dane na podstawie interakcji użytkownika.

W tym przykładzie test sprawdza, czy interfejs wyświetla oczekiwane dane podzielone na strony.

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

Ponieważ Flow<PagingData> wczytuje dane asynchronicznie, przed wykonaniem asercji musisz dać bibliotece Paging czas na pobranie początkowego wczytania i wyemitowanie go do collectAsLazyPagingItems. Aby to zrobić, użyj funkcji composeTestRule.waitUntil lub waitUntilExactlyOneExists, jak pokazano w poprzednim przykładzie.

Po wczytaniu danych możesz bezpośrednio sprawdzić drzewo semantyczne Compose za pomocą funkcji onNodeWithText, aby sprawdzić, czy elementy są rzeczywiście renderowane w funkcji LazyColumn.

Dodatkowe materiały

Wyświetlanie treści