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 wiemap()undinsertSeparators()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
PagingSourceFactorymit der entsprechenden Erweiterung für Ihre Anforderungen. - Verwenden Sie die zurückgegebene
PagingSourceFactoryin einem Fake für IhrRepository. - Übergeben Sie dieses
Repositoryan IhrViewModel.
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
PagingSourcetesten:
@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
PagingSourcetesten:
@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<Int, RedditPost>() {
...
}
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
mockApigültige Daten zurückgibt. Die Funktionload()sollteMediatorResult.Successzurückgeben und die EigenschaftendOfPaginationReachedsolltefalsesein. - Der zweite Fall liegt vor, wenn
mockApieine erfolgreiche Antwort zurückgibt, die zurückgegebenen Daten jedoch leer sind. Die Funktionload()sollteMediatorResult.Successzurückgeben und die EigenschaftendOfPaginationReachedsolltetruesein. - Der dritte Fall liegt vor, wenn
mockApibeim Abrufen der Daten eine Ausnahme auslöst. Die Funktionload()sollteMediatorResult.Errorzurückgeben.
Führen Sie die folgenden Schritte aus, um den ersten Fall zu testen:
- Richten Sie die
mockApimit den zurückzugebenden Beitragsdaten ein. - Initialisieren Sie das
RemoteMediator-Objekt. - 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<Int, RedditPost>(
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<Int, RedditPost>(
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<Int, RedditPost>(
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
Empfehlungen für Sie
- Hinweis: Linktext wird angezeigt, wenn JavaScript deaktiviert ist
- Paging aus Netzwerk und Datenbank
- Zu Paging 3 migrieren
- Ausgelagerte Daten laden und anzeigen