Paging-Implementierung testen

Die Implementierung der Paging-Bibliothek in Ihrer App sollte mit einer robusten Teststrategie einhergehen. Sie sollten Datenladekomponenten wie PagingSource und RemoteMediator testen, um sicherzustellen, dass sie wie erwartet funktionieren. Außerdem solltest du End-to-End-Tests schreiben, um zu prüfen, ob alle Komponenten deiner Paging-Implementierung ohne unerwartete Nebenwirkungen korrekt zusammenarbeiten.

In diesem Leitfaden wird erläutert, wie Sie die Paging-Bibliothek in den verschiedenen Architekturebenen Ihrer App testen und End-to-End-Tests für Ihre gesamte Paging-Implementierung schreiben.

Tests der UI-Ebene

Da Compose Paging-Daten deklarativ über collectAsLazyPagingItems nutzt, können sich Ihre Tests der UI-Ebene hauptsächlich auf die Flow<PagingData<Value>> konzentrieren, die von Ihrem ViewModel ausgegeben werden. Wenn Sie Tests schreiben möchten, um zu prüfen, ob die Daten in der Benutzeroberfläche Ihren Erwartungen entsprechen, fügen Sie die Abhängigkeit paging-testing hinzu. Sie enthält die Erweiterung asSnapshot für ein Flow<PagingData<Value>>. Sie bietet APIs in ihrem Lambda-Empfänger, mit denen Scrollinteraktionen simuliert werden können. Es wird ein Standard-List<Value> zurückgegeben, der durch die simulierten Scrollinteraktionen erzeugt wird. So können Sie prüfen, ob die Daten, durch die Sie blättern, die erwarteten Elemente enthalten, die durch diese Interaktionen generiert wurden. Dies wird im folgenden Snippet veranschaulicht:

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

Alternativ können Sie scrollen, bis ein bestimmtes Prädikat erfüllt ist, wie im folgenden Snippet gezeigt:

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

Transformationen testen

Sie sollten auch Unittests schreiben, die alle Transformationen abdecken, die Sie auf den PagingData-Stream anwenden. Verwenden Sie die Erweiterung asPagingSourceFactory. Diese Erweiterung ist für die folgenden Datentypen verfügbar:

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

Welche Erweiterung Sie verwenden, hängt davon ab, was Sie testen möchten. Verwendungszweck:

  • List<Value>.asPagingSourceFactory(): Wenn Sie statische Transformationen wie map() und insertSeparators() für Daten testen möchten.
  • Flow<List<Value>>.asPagingSourceFactory(): Wenn Sie testen möchten, wie sich Aktualisierungen Ihrer Daten, z. B. das Schreiben in die zugrunde liegende Datenquelle, auf Ihre Paging-Pipeline auswirken.

Wenn Sie eine dieser Erweiterungen verwenden möchten, gehen Sie so vor:

  • Erstellen Sie die PagingSourceFactory mit der entsprechenden Erweiterung für Ihre Anforderungen.
  • Verwenden Sie den zurückgegebenen PagingSourceFactory in einem Fake für Ihren Repository.
  • Übergeben Sie diesen Repository an Ihren ViewModel.

Der ViewModel kann dann wie im vorherigen Abschnitt beschrieben getestet werden. Beachten Sie Folgendes: 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
      }
  }
}

Um die Transformation in MyViewModel zu testen, stellen Sie eine künstliche Instanz von MyRepository bereit, die an ein statisches List delegiert, das die zu transformierenden Daten darstellt, wie im folgenden Snippet gezeigt:

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

Anschließend können Sie einen Test für die Trennzeichenlogik schreiben, wie im folgenden Snippet gezeigt:

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-Ladestatus testen

Wenn Sie isoliert testen möchten, wie Ihre Compose-UI-Komponenten auf verschiedene Ladestatus (z. B. LoadState.Loading oder LoadState.Error) reagieren, geben Sie statische PagingData-Snapshots mit expliziten LoadStates aus. Verwenden Sie dazu PagingData.empty und PagingData.from über einen MutableStateFlow:

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

Datenebenentests

Schreiben Sie Unit-Tests für die Komponenten in Ihrer Datenschicht, um sicherzustellen, dass die Daten aus Ihren Datenquellen ordnungsgemäß geladen werden. Stellen Sie falsche Versionen von Abhängigkeiten bereit, um zu prüfen, ob die getesteten Komponenten isoliert korrekt funktionieren. Die Hauptkomponenten, die Sie in der Repository-Ebene testen müssen, sind PagingSource und RemoteMediator.

PagingSource Tests

Unittests für Ihre PagingSource-Implementierung umfassen das Einrichten der PagingSource-Instanz und das Laden von Daten aus ihr mit einem TestPager.

Wenn Sie die PagingSource-Instanz zum Testen einrichten möchten, geben Sie dem Konstruktor Testdaten an. So haben Sie die Kontrolle über die Daten in Ihren Tests. Im folgenden Beispiel ist der Parameter RedditApi eine Retrofit-Schnittstelle, die die Serveranfragen und die Antwortklassen definiert. Eine Fake-Version kann die Schnittstelle implementieren, alle erforderlichen Funktionen überschreiben und praktische Methoden bereitstellen, um zu konfigurieren, wie das Fake-Objekt in Tests reagieren soll.

Nachdem die Fakes eingerichtet sind, richten Sie die Abhängigkeiten ein und initialisieren das PagingSource-Objekt im Test. Im folgenden Beispiel wird das FakeRedditApi-Objekt mit einer Liste von Testbeiträgen initialisiert und die RedditPagingSource-Instanz getestet:

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

Mit dem TestPager können Sie außerdem Folgendes tun:

  • Testen Sie aufeinanderfolgende Ladevorgänge von Ihrem PagingSource:
    @Test
    fun test_consecutive_loads() = runTest {

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

      assertThat(page.data)
      .containsExactlyElementsIn(testPosts)
      .inOrder()
    }
  • Testen Sie Fehlerszenarien in Ihrem 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 Tests

Mit den RemoteMediator-Unittests soll geprüft werden, ob die Funktion load() den richtigen MediatorResult zurückgibt. Tests auf Nebenwirkungen, z. B. das Einfügen von Daten in die Datenbank, eignen sich besser für Integrationstests.

Im ersten Schritt müssen Sie feststellen, welche Abhängigkeiten Ihre RemoteMediator-Implementierung benötigt. Das folgende Beispiel zeigt eine RemoteMediator-Implementierung, die eine Room-Datenbank, eine Retrofit-Schnittstelle und einen Suchstring erfordert:

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

Sie können die Retrofit-Schnittstelle und den Suchstring wie im Abschnitt PagingSource-Tests beschrieben angeben. Die Bereitstellung einer Mock-Version der Room-Datenbank ist sehr aufwendig. Daher kann es einfacher sein, eine In-Memory-Implementierung der Datenbank anstelle einer vollständigen Mock-Version bereitzustellen. Da zum Erstellen einer Room-Datenbank ein Context-Objekt erforderlich ist, müssen Sie diesen RemoteMediator-Test im Verzeichnis androidTest platzieren und mit dem AndroidJUnit4-Test-Runner ausführen, damit er Zugriff auf einen Testanwendungskontext hat. Weitere Informationen zu Instrumentierungstests finden Sie unter Instrumentierte Unittests erstellen.

Definieren Sie Tear-down-Funktionen, um zu verhindern, dass der Status zwischen Testfunktionen verloren geht. So erhalten Sie konsistente Ergebnisse zwischen den Testläufen.

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

Im nächsten Schritt testen Sie die Funktion load(). In diesem Beispiel gibt es drei Fälle, die getestet werden müssen:

  • Im ersten Fall gibt mockApi gültige Daten zurück. Die Funktion load() sollte MediatorResult.Success zurückgeben und das Attribut endOfPaginationReached sollte false sein.
  • Der zweite Fall ist, wenn mockApi eine erfolgreiche Antwort zurückgibt, die zurückgegebenen Daten jedoch leer sind. Die Funktion load() sollte MediatorResult.Success zurückgeben und das Attribut endOfPaginationReached sollte true sein.
  • Der dritte Fall ist, wenn mockApi beim Abrufen der Daten eine Ausnahme auslöst. Die Funktion load() sollte MediatorResult.Error zurückgeben.

So testen Sie den ersten Fall:

  1. Richten Sie die mockApi mit den zurückzugebenden POST-Daten ein.
  2. Initialisieren Sie das RemoteMediator-Objekt.
  3. Testen Sie die Funktion 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 }
}

Beim zweiten Test muss die mockApi ein leeres Ergebnis zurückgeben. Da Sie die Daten nach jedem Testlauf aus der mockApi löschen, wird standardmäßig ein leeres Ergebnis zurückgegeben.

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

Für den letzten Test muss mockApi eine Ausnahme auslösen, damit der Test prüfen kann, ob die Funktion load() korrekt MediatorResult.Error zurückgibt.

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

End-to-End-Tests

Unit-Tests bieten die Gewissheit, dass einzelne Paging-Komponenten isoliert funktionieren. End-to-End-Tests bieten jedoch mehr Sicherheit, dass die Anwendung als Ganzes funktioniert. Mit diesen Tests können Sie prüfen, ob die Datenebene (PagingSource oder RemoteMediator), ViewModel und die Compose-UI nahtlos und ohne unerwartete Nebenwirkungen integriert werden. Für die Tests sind zwar immer noch einige Mock-Abhängigkeiten erforderlich, aber im Allgemeinen decken sie den größten Teil Ihres Anwendungscodes ab.

Im Beispiel in diesem Abschnitt wird eine simulierte API-Abhängigkeit verwendet, um die Verwendung des Netzwerks in Tests zu vermeiden. Die Mock-API ist so konfiguriert, dass sie einen konsistenten Satz von Testdaten zurückgibt, was zu wiederholbaren Tests führt. Bei End-to-End-Tests wird die echte Netzwerk-API in der Regel durch eine Fake-API ersetzt. Die Paging-Bibliothek übernimmt jedoch weiterhin das eigentliche Abrufen und das lokale Datenbank-Caching (wenn ein RemoteMediator verwendet wird), um die Genauigkeit der Tests zu gewährleisten.

Schreiben Sie Ihren Code so, dass Sie Mock-Versionen Ihrer Abhängigkeiten einfach austauschen können. Im folgenden Beispiel wird eine einfache Service Locator-Implementierung verwendet und ein Test mit einer simulierten API eingerichtet, um zu prüfen, ob ein Compose-Bildschirm die ausgelagerten Daten richtig verarbeitet und anzeigt. In größeren Apps kann die Verwendung einer Bibliothek für die Abhängigkeitsinjektion wie Hilt helfen, komplexere Abhängigkeitsgraphen zu verwalten.

Nachdem Sie die Teststruktur eingerichtet haben, müssen Sie im nächsten Schritt prüfen, ob die von der Pager-Implementierung zurückgegebenen Daten korrekt sind. In einem Test sollte geprüft werden, ob die Compose-UI beim ersten Laden des Bildschirms mit den richtigen Elementen gefüllt wird. In einem anderen Test sollte geprüft werden, ob die UI zusätzliche Daten basierend auf der Nutzerinteraktion korrekt lädt.

Im folgenden Beispiel wird mit dem Test geprüft, ob auf der Benutzeroberfläche die erwarteten seitenbasierten Daten angezeigt werden.

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

Da Flow<PagingData> Daten asynchron lädt, müssen Sie der Paging-Bibliothek Zeit geben, die erste Ladung abzurufen und an collectAsLazyPagingItems zu senden, bevor Sie Assertions erstellen. Verwenden Sie dazu composeTestRule.waitUntil oder waitUntilExactlyOneExists, wie im vorherigen Beispiel gezeigt.

Nachdem die Daten geladen wurden, können Sie mit onNodeWithText direkt im semantischen Baum von Compose prüfen, ob die Elemente tatsächlich in LazyColumn gerendert werden.

Zusätzliche Ressourcen

Inhalte ansehen