Implementacja biblioteki Paging w aplikacji powinna być połączona z solidną strategią testowania. Musisz przetestować komponenty wczytywania danych, takie jak
PagingSource i
RemoteMediator
, aby mieć pewność, że działają zgodnie z oczekiwaniami. Powinieneś też napisać testy kompleksowe, aby sprawdzić, czy wszystkie komponenty w implementacji biblioteki Paging działają prawidłowo i nie powodują nieoczekiwanych skutków ubocznych.
Z tego przewodnika dowiesz się, jak testować bibliotekę Paging w różnych warstwach architektury aplikacji oraz jak pisać testy kompleksowe dla całej implementacji biblioteki Paging.
Testy warstwy interfejsu
Ponieważ Compose deklaratywnie wykorzystuje dane biblioteki Paging za pomocą
collectAsLazyPagingItems, testy warstwy interfejsu mogą się skupić wyłącznie na
Flow<PagingData<Value>> emitowanej przez ViewModel. Aby napisać testy sprawdzające, czy dane w interfejsie są zgodne z oczekiwaniami, dodaj zależność paging-testing. Zawiera ona rozszerzenie asSnapshot w funkcji Flow<PagingData<Value>>. Oferuje interfejsy API w odbiorniku lambda, które umożliwiają symulowanie interakcji przewijania. Zwraca standardowy List<Value> utworzony przez symulowane interakcje przewijania.
Dzięki temu możesz sprawdzić, czy dane, które są podzielone na strony, zawierają oczekiwane elementy wygenerowane przez te interakcje. Ilustruje to ten fragment 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 predykat, jak w poniższym 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 transformacji
Powinieneś też napisać testy jednostkowe, które obejmują wszystkie transformacje 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:
List<Value>.asPagingSourceFactory(): jeśli chcesz przetestować statyczne transformacje danych, takie jakmap()iinsertSeparators().Flow<List<Value>>.asPagingSourceFactory(): jeśli chcesz przetestować, jak aktualizacje danych, np. zapisywanie w źródle danych, wpływają na potok paginacji.
Aby użyć dowolnego z tych rozszerzeń, wykonaj te czynności:
- Utwórz
PagingSourceFactory, używając odpowiedniego rozszerzenia. - Użyj zwróconej funkcji
PagingSourceFactoryw fałszywej wersji funkcjiRepository. - Przekaż tę funkcję
Repositorydo funkcjiViewModel.
Funkcję ViewModel można następnie przetestować zgodnie z opisem w poprzedniej sekcji.
Rozważmy tę funkcję 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ć transformację w funkcji MyViewModel, podaj fałszywą instancję funkcji MyRepository, która przekazuje dane do statycznej listy List reprezentującej 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.
}
Testy warstwy danych
Napisz testy jednostkowe dla komponentów w warstwie danych, aby mieć pewność, że prawidłowo wczytują dane ze źródeł danych. Podaj
fałszywe wersje
zależności, aby sprawdzić, czy testowane komponenty działają prawidłowo w
izolacji. Główne komponenty, które musisz przetestować w warstwie repozytorium, to PagingSource i RemoteMediator.
Testy funkcji PagingSource
Testy jednostkowe implementacji funkcji PagingSource obejmują skonfigurowanie instancji PagingSource i wczytanie z niej danych za pomocą funkcji TestPager.
Aby skonfigurować instancję PagingSource do testowania, podaj fałszywe dane w konstruktorze. Dzięki temu masz kontrolę nad danymi w testach.
W tym przykładzie parametr to interfejs Retrofit, który definiuje żądania serwera i klasy odpowiedzi.RedditApi
Fałszywa wersja może implementować interfejs, zastępować wymagane funkcje i udostępniać metody ułatwiające konfigurowanie sposobu, w jaki fałszywy obiekt powinien reagować w testach.
Gdy fałszywe wersje są gotowe, skonfiguruj zależności i zainicjuj obiekt PagingSource w teście. Ten przykład pokazuje inicjowanie obiektu FakeRedditApi za pomocą listy 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()
}
}
Funkcja TestPager umożliwia też wykonanie tych czynności:
- Testowanie kolejnych wczytań z funkcji
PagingSource:
@Test
fun test_consecutive_loads() = runTest {
val page = with(pager) {
refresh()
append()
append()
} as LoadResult.Page
assertThat(page.data)
.containsExactlyElementsIn(testPosts)
.inOrder()
}
- Testowanie scenariuszy błędów w funkcji
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()
}
}
Testy funkcji RemoteMediator
Celem testów jednostkowych funkcji RemoteMediator jest sprawdzenie, czy funkcja load()
zwraca prawidłowy
MediatorResult.
Testy skutków ubocznych, takich jak wstawianie danych do bazy danych, są
lepiej nadają się do testów integracyjnych.
Pierwszym krokiem jest określenie, jakich zależności potrzebuje implementacja funkcji RemoteMediator. Ten przykład pokazuje implementację funkcji 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>() {
...
}
Interfejs Retrofit i ciąg wyszukiwania możesz podać tak jak w
sekcji Testy funkcji PagingSource. Podanie fałszywej wersji
bazy danych Room jest bardzo skomplikowane, dlatego łatwiej jest podać
implementację w pamięci bazy danych zamiast pełnej fałszywej wersji. Ponieważ utworzenie bazy danych Room
wymaga obiektu Context, musisz
umieścić ten test funkcji RemoteMediator w katalogu androidTest i uruchomić go
za pomocą narzędzia do uruchamiania testów AndroidJUnit4, aby miał dostęp do kontekstu aplikacji testowej. Więcej informacji o testach instrumentowanych znajdziesz w artykule Tworzenie instrumentowanych
testów jednostkowych.
Zdefiniuj funkcje czyszczenia, aby mieć pewność, że stan nie będzie się przenosić 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 funkcja
mockApizwraca prawidłowe dane. Funkcjaload()powinna zwrócić wartośćMediatorResult.Success, a właściwośćendOfPaginationReachedpowinna mieć wartośćfalse. - Drugi przypadek to sytuacja, gdy funkcja
mockApizwraca odpowiedź z informacją o powodzeniu, ale zwrócone dane są puste. Funkcjaload()powinna zwrócić wartośćMediatorResult.Success, a właściwośćendOfPaginationReachedpowinna mieć wartośćtrue. - Trzeci przypadek to sytuacja, gdy funkcja
mockApizgłasza wyjątek podczas pobierania danych. Funkcjaload()powinna zwrócić wartośćMediatorResult.Error.
Aby przetestować pierwszy przypadek, wykonaj te czynności:
- Skonfiguruj funkcję
mockApiz danymi postów do zwrócenia. - 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 funkcja mockApi zwróciła pusty wynik. Ponieważ po każdym uruchomieniu testu czyścisz dane z funkcji mockApi, domyślnie zwraca ona 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 funkcja mockApi zgłosiła wyjątek, aby test mógł sprawdzić, czy funkcja load() prawidłowo zwraca wartość 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 zapewniają, że poszczególne komponenty biblioteki Paging 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 nie powodują nieoczekiwanych skutków ubocznych. Testy nadal będą wymagać niektórych fałszywych zależności, ale ogólnie obejmą większość kodu aplikacji.
W przykładzie w tej sekcji używamy fałszywej zależności interfejsu API, aby uniknąć korzystania z sieci w testach. Fałszywy interfejs API jest skonfigurowany tak, aby zwracać spójny zestaw danych testowych, co umożliwia powtarzanie testów. W przypadku testów kompleksowych zwykle zastępujesz prawdziwy interfejs API sieci fałszywym, ale nadal pozwalasz bibliotece Paging obsługiwać rzeczywiste pobieranie i buforowanie w lokalnej bazie danych (jeśli używasz funkcji RemoteMediator), aby zachować wierność testów.
Napisz kod w taki sposób, aby można było łatwo zastąpić zależności fałszywymi wersjami. Ten przykład używa podstawowej implementacji lokalizatora usług i konfiguruje test z fałszywym interfejsem API, aby sprawdzić, czy ekran Compose prawidłowo wykorzystuje i wyświetla dane podzielone na strony. W większych aplikacjach użycie a biblioteki wstrzykiwania zależności, takiej jak Hilt, może pomóc w zarządzaniu bardziej złożonymi grafami zależności.
Po skonfigurowaniu struktury testu następnym krokiem jest sprawdzenie, czy dane zwracane przez implementację funkcji Pager są prawidłowe. Jeden test powinien sprawdzać, czy interfejs Compose UI jest wypełniany prawidłowymi elementami po pierwszym wczytaniu ekranu, a drugi test powinien sprawdzać, czy interfejs prawidłowo wczytuje dodatkowe dane na podstawie interakcji użytkownika.
W tym 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, przed wykonaniem asercji musisz dać bibliotece Paging
czas na pobranie początkowego wczytania i wyemitowanie go do
collectAsLazyPagingItems. Aby to zrobić, użyj funkcji composeTestRule.waitUntil lub waitUntilExactlyOneExists, jak pokazano w poprzednim przykładzie.
Po wczytaniu danych możesz bezpośrednio sprawdzić drzewo semantyczne Compose za pomocą funkcji onNodeWithText, aby sprawdzić, czy elementy są rzeczywiście renderowane w funkcji LazyColumn.
Dodatkowe materiały
Wyświetlanie 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 biblioteki Paging 3
- Wczytywanie i wyświetlanie danych podzielonych na strony