Wdrożenie biblioteki Paging w aplikacji powinno być połączone z solidną strategią testowania. Przetestuj komponenty wczytywania danych, takie jak PagingSource i RemoteMediator, aby upewnić się, że działają zgodnie z oczekiwaniami. Należy też napisać testy kompleksowe, aby sprawdzić, czy wszystkie komponenty implementacji stronicowania działają prawidłowo i nie powodują nieoczekiwanych efektów ubocznych.
Z tego przewodnika dowiesz się, jak przetestować bibliotekę Paging w różnych warstwach architektury aplikacji oraz jak napisać kompleksowe testy całej implementacji stronicowania.
Testy warstwy interfejsu
Ponieważ Compose wykorzystuje dane stronicowania deklaratywnie za pomocą collectAsLazyPagingItems, testy warstwy interfejsu mogą skupiać się głównie na Flow<PagingData<Value>> emitowanym przez ViewModel. Aby napisać testy sprawdzające, czy dane w interfejsie są zgodne z oczekiwaniami, uwzględnij zależność paging-testing. Zawiera rozszerzenie asSnapshot na Flow<PagingData<Value>>. W odbiorniku lambda udostępnia interfejsy API, które umożliwiają symulowanie interakcji przewijania. Zwraca standardowy obiekt List<Value> wygenerowany przez symulowane interakcje przewijania.
Dzięki temu możesz sprawdzić, czy dane, które są stronicowane, zawierają oczekiwane elementy wygenerowane przez te interakcje. Zostało to przedstawione w tym fragmencie 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 warunek, jak w tym 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 przekształceń
Należy też napisać testy jednostkowe, które obejmują wszystkie przekształcenia 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 tej funkcji:
List<Value>.asPagingSourceFactory(): jeśli chcesz przetestować przekształcenia statyczne, takie jakmap()iinsertSeparators(), na danych.Flow<List<Value>>.asPagingSourceFactory(): jeśli chcesz sprawdzić, jak aktualizacje danych, np. zapisywanie w źródle danych, wpływają na potok stronicowania.
Aby użyć jednego z tych rozszerzeń, postępuj zgodnie z tym wzorcem:
- Utwórz
PagingSourceFactory, używając rozszerzenia odpowiedniego do Twoich potrzeb. - Użyj zwróconego elementu
PagingSourceFactoryw fałszywym elemencie dlaRepository. - Przekaż ten
RepositorydoViewModel.
Następnie możesz przetestować ViewModel zgodnie z opisem w poprzedniej sekcji.
Weź pod uwagę te kwestie: 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ć przekształcenie w MyViewModel, podaj fałszywą instancję MyRepository, która przekazuje dane do statycznego List reprezentującego 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.
}
Testowanie stanów wczytywania w Compose
Aby przetestować, jak komponenty interfejsu Compose reagują na różne stany wczytywania (takie jak LoadState.Loading lub LoadState.Error) w izolacji, wyemituj statyczne migawki PagingData z wyraźnym LoadStates, używając PagingData.empty i PagingData.from za pomocą 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()
}
Testy warstwy danych
Napisz testy jednostkowe dla komponentów w warstwie danych, aby upewnić się, że prawidłowo wczytują dane ze źródeł danych. Używaj fałszywych wersji zależności, aby sprawdzać, czy testowane komponenty działają prawidłowo w izolacji. Główne komponenty, które musisz przetestować w warstwie repozytorium, to PagingSource i RemoteMediator.
PagingSource test
Testy jednostkowe implementacji PagingSource obejmują skonfigurowanie instancji PagingSource i wczytanie z niej danych za pomocą TestPager.
Aby skonfigurować instancję PagingSource do testowania, podaj konstruktorowi fałszywe dane. Dzięki temu masz kontrolę nad danymi w testach. W tym przykładzie parametr RedditApi to interfejs Retrofit, który definiuje żądania serwera i klasy odpowiedzi. Fałszywa wersja może implementować interfejs, zastępować wymagane funkcje i udostępniać metody ułatwiające konfigurowanie reakcji fałszywego obiektu w testach.
Po utworzeniu obiektów testowych skonfiguruj zależności i zainicjuj obiekt PagingSource w teście. Poniższy przykład pokazuje inicjowanie obiektu FakeRedditApi z listą 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()
}
}
TestPager umożliwia też:
- Przetestuj kolejne wczytywania z
PagingSource:
@Test
fun test_consecutive_loads() = runTest {
val page = with(pager) {
refresh()
append()
append()
} as LoadResult.Page
assertThat(page.data)
.containsExactlyElementsIn(testPosts)
.inOrder()
}
- Przetestuj scenariusze błędów w
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 test
Celem testów jednostkowych RemoteMediator jest sprawdzenie, czy funkcja load() zwraca prawidłowy obiekt MediatorResult.
Testy efektów ubocznych, takich jak wstawianie danych do bazy danych, lepiej nadają się do testów integracji.
Najpierw musisz określić, jakich zależności wymaga Twoja implementacja RemoteMediator. Poniższy przykład pokazuje implementację 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<Int, RedditPost>() {
...
}
Możesz podać interfejs Retrofit i ciąg wyszukiwania, jak pokazano w sekcji Testy PagingSource. Dostarczenie próbnej wersji bazy danych Room jest bardzo skomplikowane, dlatego łatwiej jest dostarczyć implementację bazy danych w pamięci zamiast pełnej wersji próbnej. Tworzenie bazy danych Room wymaga obiektu Context, dlatego musisz umieścić ten test RemoteMediator w katalogu androidTest i wykonać go za pomocą modułu uruchamiającego testy AndroidJUnit4, aby miał dostęp do kontekstu aplikacji testowej. Więcej informacji o testach z instrumentacją znajdziesz w artykule o tworzeniu testów jednostkowych z instrumentacją.
Zdefiniuj funkcje usuwania, aby stan nie wyciekał 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
mockApizwraca prawidłowe dane. Funkcjaload()powinna zwracaćMediatorResult.Success, a właściwośćendOfPaginationReachedpowinna mieć wartośćfalse. - Drugi przypadek występuje, gdy
mockApizwraca odpowiedź o powodzeniu, ale zwrócone dane są puste. Funkcjaload()powinna zwracaćMediatorResult.Success, a właściwośćendOfPaginationReachedpowinna mieć wartośćtrue. - Trzeci przypadek to sytuacja, gdy
mockApizgłasza wyjątek podczas pobierania danych. Funkcjaload()powinna zwracać wartośćMediatorResult.Error.
Aby przetestować pierwszy przypadek, wykonaj te czynności:
- Skonfiguruj
mockApiz danymi postu, które mają zostać zwrócone. - Zainicjuj obiekt
RemoteMediator. - 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<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 }
}
Drugi test wymaga, aby mockApi zwrócił pusty wynik. Ponieważ po każdym uruchomieniu testu usuwasz dane z mockApi, domyślnie zwracany jest 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<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 }
}
Ostatni test wymaga, aby mockApi zgłosił wyjątek, dzięki czemu test może sprawdzić, czy funkcja load() prawidłowo zwraca 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<Int, RedditPost>(
listOf(),
null,
PagingConfig(10),
10
)
val result = remoteMediator.load(LoadType.REFRESH, pagingState)
assertTrue {result is MediatorResult.Error }
}
Testy kompleksowe
Testy jednostkowe dają pewność, że poszczególne komponenty stronicowania 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 bez nieoczekiwanych efektów ubocznych. Testy będą nadal wymagać pewnych zależności pozorowanych, ale ogólnie będą obejmować większość kodu aplikacji.
W przykładzie w tej sekcji użyto pozorowanej zależności interfejsu API, aby uniknąć korzystania z sieci w testach. Interfejs API do testowania jest skonfigurowany tak, aby zwracał spójny zestaw danych testowych, co umożliwia powtarzalne testy. W przypadku testów kompleksowych zwykle zamienia się prawdziwy interfejs API sieci na fałszywy, ale nadal pozwala się bibliotece Paging na obsługę rzeczywistego pobierania i lokalnego buforowania bazy danych (jeśli używasz RemoteMediator), aby zachować wierność testów.
Napisz kod w taki sposób, aby można było łatwo zamieniać makiety zależności. W poniższym przykładzie użyto podstawowej implementacji lokalizatora usługi i skonfigurowano test z interfejsem API, aby sprawdzić, czy ekran Compose prawidłowo wykorzystuje i wyświetla dane podzielone na strony. W większych aplikacjach użycie biblioteki wstrzykiwania zależności, takiej jak Hilt, może pomóc w zarządzaniu bardziej złożonymi wykresami zależności.
Po skonfigurowaniu struktury testowej należy sprawdzić, czy dane zwracane przez implementację Pager są prawidłowe. Jeden test powinien sprawdzać, czy interfejs Compose wypełnia się prawidłowymi elementami po pierwszym załadowaniu ekranu, a drugi – czy interfejs prawidłowo wczytuje dodatkowe dane na podstawie interakcji użytkownika.
W poniższym 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, musisz dać bibliotece Paging czas na pobranie początkowego wczytania i wyemitowanie go do collectAsLazyPagingItems, zanim zaczniesz tworzyć asercje. Aby to zrobić, użyj composeTestRule.waitUntil lub waitUntilExactlyOneExists, jak pokazano w poprzednim przykładzie.
Po wczytaniu danych możesz bezpośrednio sprawdzić drzewo semantyczne Compose za pomocą onNodeWithText, aby zweryfikować, czy elementy są rzeczywiście renderowane w LazyColumn.
Dodatkowe materiały
Wyświetla treści
Polecane dla Ciebie
- Uwaga: tekst linku jest wyświetlany, gdy język JavaScript jest wyłączony.
- Strona z sieci i bazy danych
- Migracja do Paging 3
- Wczytywanie i wyświetlanie danych podzielonych na strony