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 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, gehen Sie so vor:
- Erstellen Sie die
PagingSourceFactorymit der entsprechenden Erweiterung für Ihre Anforderungen. - Verwenden Sie den zurückgegebenen
PagingSourceFactoryin einem Fake für IhrenRepository. - Übergeben Sie diesen
Repositoryan IhrenViewModel.
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<Int, RedditPost>() {
...
}
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
mockApigültige Daten zurück. Die Funktionload()sollteMediatorResult.Successzurückgeben und das AttributendOfPaginationReachedsolltefalsesein. - Der zweite Fall ist, wenn
mockApieine erfolgreiche Antwort zurückgibt, die zurückgegebenen Daten jedoch leer sind. Die Funktionload()sollteMediatorResult.Successzurückgeben und das AttributendOfPaginationReachedsolltetruesein. - Der dritte Fall ist, wenn
mockApibeim Abrufen der Daten eine Ausnahme auslöst. Die Funktionload()sollteMediatorResult.Errorzurückgeben.
So testen Sie den ersten Fall:
- Richten Sie die
mockApimit den zurückzugebenden POST-Daten 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 }
}
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<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 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
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
Empfehlungen für Sie
- Hinweis: Der Linktext wird angezeigt, wenn JavaScript deaktiviert ist.
- Seite aus Netzwerk und Datenbank
- Zu Paging 3 migrieren
- Seitenweise Daten laden und anzeigen