Implementing the Paging library in your app should be paired with a robust
testing strategy. You should test data loading components such as
PagingSource and
RemoteMediator
to ensure that they work as expected. You should also write end-to-end tests to
verify that all of the components in your Paging implementation work correctly
together without unexpected side effects.
This guide explains how to test the Paging library in the different architecture layers of your app as well as how to write end-to-end tests for your entire Paging implementation.
UI layer tests
Because Compose consumes Paging data declaratively through
collectAsLazyPagingItems, your UI layer tests can focus entirely on the
Flow<PagingData<Value>> emitted by your ViewModel. To write tests to verify
the data in the UI is as you expect, include the paging-testing dependency. It
contains the asSnapshot extension on a Flow<PagingData<Value>>. It offers
APIs in its lambda receiver that allow for mocking scrolling interactions. It
returns a standard List<Value> produced by the scrolling interactions mocked.
This lets you assert that the data being paged through contains the expected
elements generated by those interactions. This is illustrated in the following
snippet:
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
)
}
Alternatively, you can scroll until a given predicate is met as seen in the snippet below:
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" }
}
Testing transformations
You should also write unit tests that cover any transformations you apply to
the PagingData stream. Use the asPagingSourceFactory
extension. This extension is available on the following data types:
List<Value>.Flow<List<Value>>.
The choice of which extension to use depends on what you're trying to test. Use:
List<Value>.asPagingSourceFactory(): If you want to test static transformations likemap()andinsertSeparators()on data.Flow<List<Value>>.asPagingSourceFactory(): If you want to test how updates to your data, like writing to the backing data source, affects your paging pipeline.
To use either of these extensions follow the following pattern:
- Create the
PagingSourceFactoryusing the appropriate extension for your needs. - Use the returned
PagingSourceFactoryin a fake for yourRepository. - Pass that
Repositoryto yourViewModel.
The ViewModel can then be tested as covered in the previous section.
Consider the following 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
}
}
}
To test the transformation in MyViewModel, supply a fake instance of
MyRepository that delegates to a static List representing the data to be
transformed as shown in the following snippet:
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()
}
You can then write a test for the separator logic as in the following snippet:
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.
}
Data layer tests
Write unit tests for the components in your data layer to ensure that they
load the data from your data sources appropriately. Provide
fake versions of
dependencies to verify that the components being tested function correctly in
isolation. The main components that you need to test in the repository layer are
the PagingSource and the RemoteMediator.
PagingSource tests
Unit tests for your PagingSource implementation involve setting up the
PagingSource instance and loading data from it with a TestPager.
To set up the PagingSource instance for testing, provide fake data to the
constructor. This gives you control over the data in your tests.
In the following example, the RedditApi
parameter is a Retrofit
interface that defines the server requests and the response classes.
A fake version can implement the interface, override any required functions,
and provide convenience methods to configure how the fake object should react
in tests.
After the fakes are in place, set up the dependencies and initialize the
PagingSource object in the test. The following example demonstrates
initializing the FakeRedditApi object with a list of test posts, and testing
the RedditPagingSource instance:
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()
}
}
The TestPager also lets you do the following:
- Test consecutive loads from your
PagingSource:
@Test
fun test_consecutive_loads() = runTest {
val page = with(pager) {
refresh()
append()
append()
} as LoadResult.Page
assertThat(page.data)
.containsExactlyElementsIn(testPosts)
.inOrder()
}
- Test error scenarios in your
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
The goal of the RemoteMediator unit tests is to verify that the load()
function returns the correct
MediatorResult.
Tests for side effects, such as data being inserted into the database, are
better suited for integration tests.
The first step is to determine what dependencies your RemoteMediator
implementation needs. The following example demonstrates a RemoteMediator
implementation that requires a Room database, a Retrofit interface, and a search
string:
@OptIn(ExperimentalPagingApi::class)
class PageKeyedRemoteMediator(
private val db: RedditDb,
private val redditApi: RedditApi,
private val subredditName: String
) : RemoteMediator<Int, RedditPost>() {
...
}
You can provide the Retrofit interface and the search string as demonstrated in
the PagingSource tests section. Providing a mock version
of the Room database is very involved, so it can be easier to provide an
in-memory implementation of
the database instead of a full mock version. Because creating a Room database
requires a Context object, you must
place this RemoteMediator test in the androidTest directory and execute it
with the AndroidJUnit4 test runner so that it has access to a test application
context. For more information about instrumented tests, see Build instrumented
unit tests.
Define tear-down functions to ensure that state does not leak between test functions. This ensures consistent results between test runs.
@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()
}
}
The next step is to test the load() function. In this example, there are three
cases to test:
- The first case is when
mockApireturns valid data. Theload()function should returnMediatorResult.Success, and theendOfPaginationReachedproperty should befalse. - The second case is when
mockApireturns a successful response, but the returned data is empty. Theload()function should returnMediatorResult.Success, and theendOfPaginationReachedproperty should betrue. - The third case is when
mockApithrows an exception when fetching the data. Theload()function should returnMediatorResult.Error.
Follow these steps to test the first case:
- Set up the
mockApiwith the post data to return. - Initialize the
RemoteMediatorobject. - Test the
load()function.
@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 }
}
The second test requires the mockApi to return an empty result. Because you
clear the data from the mockApi after each test run, it will return an empty
result by default.
@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 }
}
The final test requires the mockApi to throw an exception so that the test can
verify that the load() function correctly returns 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 }
}
End-to-end tests
Unit tests provide assurance that individual Paging components work in
isolation, but end-to-end tests provide more confidence that the application
works as a whole. These tests help with verifying that your data layer
(PagingSource or RemoteMediator), ViewModel, and Compose UI integrate
seamlessly without unexpected side effects. The tests will still need some mock
dependencies, but generally they will cover most of your app code.
The example in this section uses a mock API dependency to avoid using the
network in tests. The mock API is configured to return a consistent set of test
data, resulting in repeatable tests. For end-to-end tests, you typically swap
out your real network API for a fake one, but you still let the Paging library
handle the actual fetching and local database caching (if using a
RemoteMediator) to maintain the fidelity of your tests.
Write your code in a way that lets you easily swap in mock versions of your dependencies. The following example uses a basic service locator implementation and sets up a test with a mock API to verify that a Compose screen properly consumes and displays the paged data. In larger apps, using a dependency injection library like Hilt can help manage more-complex dependency graphs.
After you set up the test structure, the next step is to verify that the data
returned by the Pager implementation is correct. One test should verify that
the Compose UI populates with the correct items when the screen first loads, and
another test should verify that the UI correctly loads additional data based on
user interaction.
In the following example, the test verifies that the UI displays the expected paged data.
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()
}
}
Because Flow<PagingData> loads data asynchronously, you must give the Paging
library time to fetch the initial load and emit it to
collectAsLazyPagingItems before making assertions. To do this, use
composeTestRule.waitUntil or waitUntilExactlyOneExists, as shown in the
preceding example.
After the data is loaded, you can assert directly against the Compose semantic
tree using onNodeWithText to verify that the items are actually rendered in
your LazyColumn.
Additional resources
Views content
Recommended for you
- Note: link text is displayed when JavaScript is off
- Page from network and database
- Migrate to Paging 3
- Load and display paged data