Внедрение библиотеки Paging в приложение должно сопровождаться надежной стратегией тестирования. Проверьте, правильно ли работают компоненты загрузки данных, такие как PagingSource и RemoteMediator. Вы также должны написать сквозные тесты, чтобы убедиться, что все компоненты в вашей реализации Paging работают правильно вместе без неожиданных побочных эффектов.
В этом руководстве рассказывается, как протестировать библиотеку Paging на разных уровнях архитектуры приложения, а также как написать сквозные тесты для всей реализации Paging.
Тесты уровня пользовательского интерфейса
Поскольку Compose использует данные Paging декларативно через collectAsLazyPagingItems, тесты уровня пользовательского интерфейса могут быть сосредоточены в основном на Flow<PagingData<Value>>, генерируемом вашей ViewModel. Чтобы написать тесты для проверки данных в пользовательском интерфейсе, включите зависимость paging-testing. Он содержит расширение asSnapshot на Flow<PagingData<Value>>. В его лямбда-приемнике есть API, которые позволяют имитировать прокрутку. Он возвращает стандартный объект List<Value>, созданный в результате имитации прокрутки.
Это позволяет убедиться, что данные, по которым выполняется навигация, содержат ожидаемые элементы, сгенерированные этими взаимодействиями. Это показано в следующем фрагменте:
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
)
}
Вы также можете прокручивать страницу до тех пор, пока не будет выполнено заданное условие, как показано в следующем фрагменте кода:
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" }
}
Тестирование преобразований
Также следует написать модульные тесты, охватывающие все преобразования, которые вы применяете к потоку PagingData. Используйте расширение asPagingSourceFactory. Это расширение доступно для следующих типов данных:
List<Value>.Flow<List<Value>>.
Выбор расширения зависит от того, что вы хотите протестировать. Используйте следующее:
List<Value>.asPagingSourceFactory(): если вы хотите протестировать статические преобразования данных, напримерmap()иinsertSeparators().Flow<List<Value>>.asPagingSourceFactory(): если вы хотите проверить, как обновления ваших данных, например запись в резервный источник данных, влияют на конвейер разбиения на страницы.
Чтобы использовать любое из этих расширений, следуйте следующему шаблону:
- Создайте файл
PagingSourceFactoryс нужным расширением. - Используйте возвращенный объект
PagingSourceFactoryв поддельном объекте дляRepository. - Передайте
RepositoryвViewModel.
После этого вы можете протестировать ViewModel, как описано в предыдущем разделе.
Рассмотрим следующий пример (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
}
}
}
Чтобы протестировать преобразование в MyViewModel, предоставьте поддельный экземпляр MyRepository, который делегирует статическому List, представляющему данные, которые необходимо преобразовать, как показано в следующем фрагменте:
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()
}
Затем вы можете написать тест для логики разделителя, как в следующем фрагменте:
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
Чтобы проверить, как компоненты интерфейса Compose реагируют на разные состояния загрузки (например, LoadState.Loading или LoadState.Error), создайте статические снимки PagingData с явным LoadStates, используя PagingData.empty и PagingData.from через 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()
}
Тестирование уровня данных
Напишите модульные тесты для компонентов на уровне данных, чтобы убедиться, что они правильно загружают данные из источников. Предоставьте поддельные версии зависимостей, чтобы убедиться, что тестируемые компоненты правильно функционируют в изоляции. Основные компоненты, которые необходимо протестировать на уровне репозитория, – это PagingSource и RemoteMediator.
PagingSource тест
Модульные тесты для реализации PagingSource включают настройку экземпляра PagingSource и загрузку данных из него с помощью TestPager.
Чтобы настроить экземпляр PagingSource для тестирования, передайте конструктору фиктивные данные. Это позволяет вам контролировать данные в тестах. В примере ниже параметр RedditApi – это интерфейс Retrofit, который определяет запросы к серверу и классы ответов. Поддельная версия может реализовывать интерфейс, переопределять любые необходимые функции и предоставлять удобные методы для настройки того, как поддельный объект должен реагировать в тестах.
После того как вы добавите поддельные объекты, настройте зависимости и инициализируйте объект PagingSource в тесте. В следующем примере показано, как инициализировать объект FakeRedditApi со списком тестовых записей и протестировать экземпляр 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 также позволяет:
- Проверьте последовательные загрузки из
PagingSource:
@Test
fun test_consecutive_loads() = runTest {
val page = with(pager) {
refresh()
append()
append()
} as LoadResult.Page
assertThat(page.data)
.containsExactlyElementsIn(testPosts)
.inOrder()
}
- Протестируйте сценарии ошибок в
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 тест
Цель модульных тестов RemoteMediator – проверить, что функция load() возвращает правильный MediatorResult.
Тесты на побочные эффекты, такие как вставка данных в базу данных, лучше подходят для интеграционных тестов.
Сначала определите, какие зависимости нужны для вашей реализации RemoteMediator. В примере ниже показана реализация RemoteMediator, для которой требуется база данных Room, интерфейс Retrofit и строка поиска.
@OptIn(ExperimentalPagingApi::class)
class PageKeyedRemoteMediator(
private val db: RedditDb,
private val redditApi: RedditApi,
private val subredditName: String
) : RemoteMediator<Int, RedditPost>() {
...
}
Вы можете предоставить интерфейс Retrofit и строку поиска, как показано в разделе Тесты PagingSource. Предоставить макет базы данных Room очень сложно, поэтому проще предоставить реализацию в памяти базы данных вместо полной макетной версии. Поскольку для создания базы данных Room требуется объект Context, необходимо поместить этот тест RemoteMediator в каталог androidTest и выполнить его с помощью средства запуска тестов AndroidJUnit4, чтобы у него был доступ к контексту тестового приложения. Подробнее о создании инструментальных модульных тестов…
Определите функции завершения, чтобы состояние не переносилось между тестовыми функциями. Это позволяет получать согласованные результаты при разных запусках тестов.
@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()
}
}
Следующий шаг – протестировать функцию load(). В этом примере есть три
случая для тестирования:
- Первый случай – когда
mockApiвозвращает действительные данные. Функцияload()должна возвращатьMediatorResult.Success, а свойствоendOfPaginationReachedдолжно иметь значениеfalse. - Второй случай – когда
mockApiвозвращает успешный ответ, но возвращенные данные пусты. Функцияload()должна возвращатьMediatorResult.Success, а свойствоendOfPaginationReachedдолжно бытьtrue. - Третий случай – когда
mockApiвыдает исключение при получении данных. Функцияload()должна возвращатьMediatorResult.Error.
Чтобы протестировать первый случай, выполните следующие действия:
- Настройте
mockApiс данными, которые нужно вернуть. - Инициализируйте объект
RemoteMediator. - Протестируйте функцию
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 }
}
Второй тест требует, чтобы mockApi возвращал пустой результат. Поскольку вы очищаете данные из mockApi после каждого запуска теста, по умолчанию он будет возвращать пустой результат.
@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 }
}
В последнем тесте mockApi должен выдать исключение, чтобы тест мог проверить, что функция load() правильно возвращает 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 }
}
Сквозные тесты
Модульное тестирование гарантирует, что отдельные компоненты Paging работают изолированно, но сквозные тесты дают больше уверенности в том, что приложение работает как единое целое. Эти тесты помогают убедиться, что слой данных (PagingSource или RemoteMediator), ViewModel и интерфейс Compose интегрируются без проблем и неожиданных побочных эффектов. Для тестов по-прежнему потребуются некоторые макеты зависимостей, но в целом они будут охватывать большую часть кода приложения.
В примере в этом разделе используется зависимость mock API, чтобы избежать использования сети в тестах. Имитированный API настроен так, чтобы возвращать согласованный набор тестовых данных, что позволяет проводить повторяемые тесты. Для сквозных тестов вы обычно заменяете реальный сетевой API на поддельный, но по-прежнему позволяете библиотеке Paging обрабатывать фактическую выборку и локальное кэширование базы данных (если используется RemoteMediator), чтобы поддерживать точность тестов.
Пишите код так, чтобы вы могли легко заменять зависимости на их имитации. В следующем примере используется базовая реализация локатора сервисов и настраивается тест с макетом API, чтобы убедиться, что экран Compose правильно использует и отображает данные с разбивкой на страницы. В более крупных приложениях для управления сложными графами зависимостей можно использовать библиотеку внедрения зависимостей, например Hilt.
После настройки тестовой структуры необходимо убедиться, что данные, возвращаемые реализацией Pager, верны. Один тест должен проверять, что интерфейс Compose заполняется правильными элементами при первой загрузке экрана, а другой – что интерфейс правильно загружает дополнительные данные на основе взаимодействия с пользователем.
В следующем примере тест проверяет, что в пользовательском интерфейсе отображаются ожидаемые данные с разбивкой на страницы.
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()
}
}
Поскольку Flow<PagingData> загружает данные асинхронно, вам необходимо дать библиотеке Paging время на получение начальной загрузки и ее передачу в collectAsLazyPagingItems, прежде чем делать утверждения. Для этого используйте composeTestRule.waitUntil или waitUntilExactlyOneExists, как показано в примере выше.
После загрузки данных вы можете выполнить утверждение непосредственно в отношении семантического дерева Compose с помощью onNodeWithText, чтобы убедиться, что элементы действительно отображаются в LazyColumn.
Дополнительные ресурсы
Просмотр контента
Рекомендуем
- Примечание. Текст ссылки показывается, когда JavaScript отключен.
- Страница из сети и базы данных
- Переход на Paging 3
- Загрузка и отображение данных с разбивкой по страницам