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 sollten Sie End-to-End-Tests schreiben, um zu prüfen, ob alle Komponenten in Ihrer Paging-Implementierung korrekt zusammenarbeiten, ohne unerwartete Nebeneffekte zu verursachen.

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

Tests der UI-Ebene

Da Compose Paging-Daten deklarativ über collectAsLazyPagingItems verwendet, können sich Ihre Tests der UI-Ebene ganz auf den Flow<PagingData<Value>> konzentrieren, der von Ihrem ViewModel ausgegeben wird. Wenn Sie Tests schreiben möchten, um zu prüfen, ob die Daten in der UI wie erwartet sind, fügen Sie die Abhängigkeit paging-testing hinzu. Sie enthält die asSnapshot Erweiterung für einen Flow<PagingData<Value>>. Sie bietet APIs im Lambda-Empfänger, mit denen Scroll-Interaktionen simuliert werden können. Sie gibt eine Standard-List<Value> zurück, die durch die simulierten Scroll-Interaktionen erzeugt wurde. So können Sie prüfen, ob die Daten, die durchgeblättert werden, die erwarteten Elemente enthalten, die durch diese Interaktionen generiert wurden. Dies wird im folgenden Codebeispiel 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 Codebeispiel zu sehen ist:

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

Die Wahl der Erweiterung 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, folgen Sie diesem Muster:

  • Erstellen Sie die PagingSourceFactory mit der entsprechenden Erweiterung für Ihre Anforderungen.
  • Verwenden Sie die zurückgegebene PagingSourceFactory in einem Fake für Ihr Repository.
  • Übergeben Sie dieses Repository an Ihr ViewModel.

Das ViewModel kann dann wie im vorherigen Abschnitt beschrieben getestet werden. Betrachten Sie das folgende 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
      }
  }
}

Wenn Sie die Transformation in MyViewModel testen möchten, stellen Sie eine Fake-Instanz von MyRepository bereit, die an eine statische List delegiert, die die zu transformierenden Daten darstellt, wie im folgenden Codebeispiel 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 Trennlogik schreiben, wie im folgenden Snippet:

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

Tests der Datenebene

Schreiben Sie Komponententests für die Komponenten in Ihrer Datenebene, um sicherzustellen, dass sie die Daten ordnungsgemäß aus Ihren Datenquellen laden. Stellen Sie Fake 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

Bei Komponententests für Ihre PagingSource-Implementierung müssen Sie die PagingSource-Instanz einrichten und Daten mit einem TestPager daraus laden.

Um die PagingSource-Instanz für Tests einzurichten, stellen Sie Fake-Daten für den Konstruktor bereit. So haben Sie die Kontrolle über die Daten in Ihren Tests. Im folgenden Beispiel ist der RedditApi Parameter eine Retrofit Schnittstelle, die die Serveranfragen und die Antwortklassen definiert. Eine Fake-Version kann die Schnittstelle implementieren, alle erforderlichen Funktionen überschreiben und Hilfsmethoden 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 Sie das PagingSource-Objekt im Test. Im folgenden Beispiel wird gezeigt, wie das FakeRedditApi-Objekt mit einer Liste von Testbeiträgen initialisiert und die RedditPagingSource-Instanz getestet wird:

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 TestPager können Sie auch Folgendes tun:

  • Aufeinanderfolgende Ladevorgänge aus Ihrer PagingSource testen:
    @Test
    fun test_consecutive_loads() = runTest {

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

      assertThat(page.data)
      .containsExactlyElementsIn(testPosts)
      .inOrder()
    }
  • Fehlerszenarien in Ihrer PagingSource testen:
    @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

Ziel der RemoteMediator Komponententests ist es, zu prüfen, ob die load() Funktion das richtige MediatorResult zurückgibt. Tests auf Nebeneffekte wie das Einfügen von Daten in die Datenbank sind besser für Integrationstests geeignet.

Der erste Schritt besteht darin, zu ermitteln, welche Abhängigkeiten Ihre RemoteMediator-Implementierung benötigt. Im folgenden Beispiel wird eine RemoteMediator-Implementierung gezeigt, die eine Room-Datenbank, eine Retrofit-Schnittstelle und eine Suchanfrage 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 die Suchanfrage wie in dem PagingSource-Tests Abschnitt gezeigt bereitstellen. Das Bereitstellen einer Mock-Version der Room-Datenbank ist sehr aufwendig. Daher ist es möglicherweise einfacher, stattdessen eine In-Memory-Implementierung der Datenbank 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-Testrunner ausführen, damit er Zugriff auf einen Testanwendungskontext hat. Weitere Informationen zu instrumentierten Tests finden Sie unter Instrumentierte Komponententests erstellen.

Definieren Sie Tear-down-Funktionen, um zu verhindern, dass der Status zwischen Testfunktionen weitergegeben wird. So werden konsistente Ergebnisse zwischen Testläufen gewährleistet.

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

Der nächste Schritt besteht darin, die Funktion load() zu testen. In diesem Beispiel gibt es drei zu testende Fälle:

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

Führen Sie die folgenden Schritte aus, um den ersten Fall zu testen:

  1. Richten Sie die mockApi mit den zurückzugebenden Beitragsdaten 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 }
}

Für den 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 die 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

Komponententests bieten die Gewissheit, dass einzelne Paging-Komponenten isoliert funktionieren. End-to-End-Tests geben jedoch mehr Sicherheit, dass die Anwendung als Ganzes funktioniert. Mit diesen Tests können Sie prüfen, ob Ihre Datenebene (PagingSource oder RemoteMediator), Ihr ViewModel und Ihre Compose-UI nahtlos integriert sind, ohne unerwartete Nebeneffekte zu verursachen. Für die Tests sind weiterhin einige Mock-Abhängigkeiten erforderlich, aber im Allgemeinen decken sie den größten Teil Ihres App-Codes ab.

Im Beispiel in diesem Abschnitt wird eine Mock-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 ersetzen Sie in der Regel Ihre echte Netzwerk-API durch eine Fake-API. Die Paging-Bibliothek übernimmt jedoch weiterhin das eigentliche Abrufen und das lokale Datenbank-Caching (wenn Sie einen RemoteMediator verwenden), um die Genauigkeit Ihrer 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 Mock-API eingerichtet, um zu prüfen, ob ein Compose -Bildschirm die ausgelagerten Daten ordnungsgemäß verwendet und anzeigt. In größeren Apps kann eine Bibliothek für die Abhängigkeitsinjektion wie Hilt helfen, komplexere Abhängigkeitsgraphen zu verwalten.

Nachdem Sie die Teststruktur eingerichtet haben, müssen Sie 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 basierend auf der Nutzerinteraktion zusätzliche Daten korrekt lädt.

Im folgenden Beispiel wird geprüft, ob die UI die erwarteten ausgelagerten Daten anzeigt.

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 Zusicherungen machen. Verwenden Sie dazu composeTestRule.waitUntil oder waitUntilExactlyOneExists, wie im vorherigen Beispiel gezeigt.

Nachdem die Daten geladen wurden, können Sie mit onNodeWithText direkt Zusicherungen für die semantische Struktur von Compose machen, um zu prüfen, ob die Elemente tatsächlich in Ihrer LazyColumn gerendert werden.

Zusätzliche Ressourcen

Inhalte ansehen