Test your Paging implementation

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 like map() and insertSeparators() 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 PagingSourceFactory using the appropriate extension for your needs.
  • Use the returned PagingSourceFactory in a fake for your Repository.
  • Pass that Repository to your ViewModel.

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&lt;Int, RedditPost&gt;() {
  ...
}

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 mockApi returns valid data. The load() function should return MediatorResult.Success, and the endOfPaginationReached property should be false.
  • The second case is when mockApi returns a successful response, but the returned data is empty. The load() function should return MediatorResult.Success, and the endOfPaginationReached property should be true.
  • The third case is when mockApi throws an exception when fetching the data. The load() function should return MediatorResult.Error.

Follow these steps to test the first case:

  1. Set up the mockApi with the post data to return.
  2. Initialize the RemoteMediator object.
  3. 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&lt;Int, RedditPost&gt;(
    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&lt;Int, RedditPost&gt;(
    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&lt;Int, RedditPost&gt;(
    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