Android — How to test Paging 3 (PagingSource)?

Mohamed Gamal
4 min readJul 19, 2021

--

Paging 3 is one of the best libraries for pagination — how not if it gives you all that you need to implement paging with the lowest efforts not only, but also gets your data from different data sources (API/local database)?

But it’s a so tricky one as well for some reasons:

  • If you have a listing view without pagination, it’s not easy to migrate your old codebase to Paging 3 — some architectural modifications are needed!
  • Which parts of the code will get affected? mmmm here’s a good question, for sure data layer won’t be, but Repository will change a little bit with having a new class PagingSource
  • How to test the PagingSource ?!!

Here I get reviews from some Api -> getReviews()

api.getReviews(
limit = params.loadSize,
offset = offset
)

Here’s an example for this Api response ->

val reviewsResponse = ReviewsResponse(
reviews = listOf(
ReviewItemResponse(id = "id")
),
totalCount = 100, // total number of items.
pagination = PaginationResponse(limit = 10, offset = 0)
)

This class is the paging source used to deal with pagination using the paging 3 library:

class ReviewsPagingSource(private val api: Api) : PagingSource<Int, Review>() {  override suspend fun load(params: LoadParams<Int>) = try {
// offset always starts with 0
val offset = params.key ?: 0
val response =
api.getReviews(
limit = params.loadSize,
offset = offset
)
LoadResult.Page(
data = response.reviews.map { Review(it) },
prevKey =
if (response.pagination.offset - response.reviews.size <= 0) null
else response.pagination.offset - response.reviews.size,
nextKey =
if (response.pagination.offset + response.reviews.size >= response.totalCount) null
else response.pagination.offset + response.reviews.size
)
} catch (exception: Exception) {
LoadResult.Error(exception)
}
}

As you notice here:

  • No need to have your ownResult class for handling responses and helping in making a generic error handling approach as Paging 3 already handles this by having LoadResult sealed class that takes care of this.
  • Pass null value to nextKey if there’s no next page to be loaded, and to prevKey if there’s no previous page.
  • So 3 interesting things here are tricky to be tested, Error Handling, offset and returned data.. and one thing to keep in mind, load function is a suspend one ;)

To call this source, I prefer to call it throw a repo instead of calling it directly from the view model, so I created this repo:

class ReviewsRepository(private val api: Api) {fun getReviews() = Pager(
config = PagingConfig(
pageSize = REVIEWS_PAGE_SIZE,
enablePlaceholders = false
),
pagingSourceFactory = {
ReviewsPagingSource(api)
}
).flow
companion object {
private const val REVIEWS_PAGE_SIZE = 15
}
}

So, how can you test this PagingSource ? The key here to identify expectedResult is LoadResult as mentioned above, and the key to fire the event is LoadParams
LoadParams is a sealed class that’s used for the loading request, it has 3 types:

1- Refresh which’s used for the initial load or refresh.

2- Append to load a page of data and append it to the end of the list.

3- Prepend to load a page of data and prepend it to the start of the list.

Using this info, here’s an example of how can you test ReviewsPagingSource:

  • It starts with the class initialization, I stubbed the response here with a limit of only 1 item per load, just don’t need to use more for testing logic.
@ExperimentalCoroutinesApi
class ReviewsPagingSourceTest {
@get:Rule
val instantExecutorRule = InstantTaskExecutorRule()
@get:Rule
var coroutineTestRule = CoroutineTestRule()
@Mock lateinit var api: Apilateinit var reviewsPagingSource: ReviewsPagingSourcecompanion object { val reviewsResponse = ReviewsResponse(
reviews = listOf(
ReviewItemResponse(id = "id")
),
totalCount = 10,
pagination = PaginationResponse(limit = 1, offset = 0)
)
val nextReviewsResponse = ReviewsResponse(
reviews = listOf(
ReviewItemResponse(id = "id")
),
totalCount = 10,
pagination = PaginationResponse(limit = 1, offset = 1)
)
}@Before
fun setup() {
MockitoAnnotations.initMocks(this)
reviewsPagingSource = ReviewsPagingSource(api)
}
  • Test failures/errors:

The expected result here should be of type Error with the same exception thrown while calling the API

@Test
fun `reviews paging source load - failure - http error`() = runBlockingTest {
val error = RuntimeException("404", Throwable())
given(api.getReviews(any(), any())).willThrow(error)
val expectedResult = PagingSource.LoadResult.Error<Int, Review>(error) assertEquals(
expectedResult, reviewsPagingSource.load(
PagingSource.LoadParams.Refresh(
key = 0,
loadSize = 1,
placeholdersEnabled = false
)
)
)
}
@Test
fun `reviews paging source load - failure - received null`() = runBlockingTest {
given(api.getReviews(any(), any())).willReturn(null) val expectedResult = PagingSource.LoadResult.Error<Int, Review>(NullPointerException()) assertEquals(
expectedResult.toString(), reviewsPagingSource.load(
PagingSource.LoadParams.Refresh(
key = 0,
loadSize = 1,
placeholdersEnabled = false
)
).toString()
)
}
  • Test refresh functionality:

The expected result should be the mapped reviews + nextKey value should be 1 and prevKey value should be null as it’s just the first page is loaded

@Test 
fun `reviews paging source refresh - success`() = runBlockingTest {
given(api.getReviews(any(), any())).willReturn(reviewsResponse) val expectedResult = PagingSource.LoadResult.Page(
data = reviewsResponse.reviews.map { Review(it) },
prevKey = null,
nextKey = 1
)
assertEquals(
expectedResult, reviewsPagingSource.load(
PagingSource.LoadParams.Refresh(
key = 0,
loadSize = 1,
placeholdersEnabled = false
)
)
)
}
  • Test append functionality:

The expected result should be the mapped reviews + nextKey value should be 2 and prevKey value should be 1 as it’s the 2nd page is loaded

@Test
fun `reviews paging source append - success`() = runBlockingTest {
given(api.getReviews(any(), any())).willReturn(nextReviewsResponse) val expectedResult = PagingSource.LoadResult.Page(
data = reviewsResponse.reviews.map { Review(it) },
prevKey = 0,
nextKey = 2
)
assertEquals(
expectedResult, reviewsPagingSource.load(
PagingSource.LoadParams.Append(
key = 1,
loadSize = 1,
placeholdersEnabled = false
)
)
)
}
  • Test prepend functionality:

The expected result should be the mapped reviews + nextKey value should be 1 and prevKey value should be null as it’s just the first page is loaded again

@Test
fun `reviews paging source prepend - success`() = runBlockingTest {
given(api.getReviews(any(), any())).willReturn(reviewsResponse) val expectedResult = PagingSource.LoadResult.Page(
data = reviewsResponse.reviews.map { Review(it) },
prevKey = null,
nextKey = 1
)
assertEquals(
expectedResult, reviewsPagingSource.load(
PagingSource.LoadParams.Prepend(
key = 0,
loadSize = 1,
placeholdersEnabled = false
)
)
)
}

Enjoy analyzing the code, and for any questions don’t hesitate to ask me 🙌🏼

Credits: Thank you Wasim Reza for reviewing the article and suggesting some changes 🫡

--

--

Mohamed Gamal

Android Engineer who is passionate about building scalable architecture.