Внедрение библиотеки Paging в ваше приложение должно сопровождаться надежной стратегией тестирования. Вам следует протестировать компоненты загрузки данных, такие как PagingSource и RemoteMediator чтобы убедиться, что они работают должным образом. Вам также следует написать сквозные тесты, чтобы проверить, что все компоненты вашей реализации Paging корректно взаимодействуют друг с другом без неожиданных побочных эффектов.
В этом руководстве объясняется, как тестировать библиотеку Paging на разных уровнях архитектуры вашего приложения, а также как писать сквозные тесты для всей реализации Paging.
Тесты уровня пользовательского интерфейса
Поскольку Compose обрабатывает данные постраничной навигации декларативно с помощью 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.
}
Тесты уровня данных
Напишите модульные тесты для компонентов в слое данных, чтобы убедиться, что они корректно загружают данные из ваших источников данных. Предоставьте фиктивные версии зависимостей, чтобы проверить корректность работы тестируемых компонентов в изолированном режиме. Основные компоненты, которые необходимо протестировать в слое репозитория, — это 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, указав данные POST-запроса, которые должны быть возвращены. - Инициализируйте объект
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 UI интегрируются без неожиданных побочных эффектов. Тесты по-прежнему будут нуждаться в некоторых фиктивных зависимостях, но, как правило, они охватывают большую часть кода вашего приложения.
В примере в этом разделе используется фиктивная зависимость 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 .
Дополнительные ресурсы
Просмотры контента
{% verbatim %}Рекомендуем вам
- Примечание: текст ссылки отображается, когда JavaScript отключен.
- Страница из сети и базы данных
- Перейти к пейджингу 3
- Загрузка и отображение постраничных данных