12 pagination test cases every API client should pass
Most pagination bugs I've seen weren't in the happy path. Page 1 works, page 2 works, and then someone asks for page 0, or the last page has exactly limit items, or a cursor gets cut off in a URL, and the client either l
Most pagination bugs I've seen weren't in the happy path. Page 1 works, page 2 works, and then someone asks for page 0, or the last page has exactly limit items, or a cursor gets cut off in a URL, and the client either loops forever or quietly drops data.
I build testingapis.com, a free fake API for testing clients, and while adding pagination endpoints to it I wrote down every edge case I could think of. Here's the list. The requests point at my API so you can see a real response, but the point is to run the same cases against your own API.
The five styles
Before the test cases, a quick reminder that "pagination" means five different things:
| Style | Request | Client stops when |
|---|---|---|
| Offset | ?offset=20&limit=10 |
hasMore is false or offset >= total
|
| Page number | ?page=2&size=10 |
last is true |
| Cursor | ?cursor=eyJsYXN0SWQiOjJ9 |
next_cursor is null |
| Keyset | ?after_id=20&limit=10 |
next_after_id is null |
| Link header | Link: <...page=3>; rel="next" |
No rel="next"
|
A cursor response looks like this:
curl "https://api.testingapis.com/api/pagination/cursor?limit=2"
{
"object": "list",
"data": [
{ "id": 1, "name": "Smart Headset", "price": 42.01 },
{ "id": 2, "name": "Compact Speaker", "price": 79.02 }
],
"has_more": true,
"next_cursor": "eyJsYXN0SWQiOjJ9"
}
The 12 cases
| # | Test case | Request | What a good API does |
|---|---|---|---|
| 1 | First page | /offset?offset=0&limit=10 |
Items 1β10, hasMore: true
|
| 2 | Last page | /offset?offset=90&limit=10 |
Items 91β100, hasMore: false
|
| 3 | Past the end | /offset?offset=100&limit=10 |
200 with empty data, not a 404 |
| 4 | Page size 0 | /offset?limit=0 |
Clamped to 1 (or a 400, but decide) |
| 5 | Page size above max | /offset?limit=101 |
Clamped to the max |
| 6 | Negative offset | /offset?offset=-1 |
Treated as 0 or rejected |
| 7 | Non-numeric page | /page?page=abc |
400 naming the bad parameter |
| 8 | Last page flag | /page?page=9&size=10 |
last: true, client stops |
| 9 | Tampered cursor | /cursor?cursor=xyz |
400 Invalid cursor, not a 500 |
| 10 | Keyset after the last id | /keyset?after_id=100 |
Empty data, next_after_id: null
|
| 11 | Page 0 on a 1-based API | /link?page=0&per_page=10 |
Treated as page 1 |
| 12 | Walk every page | fetch-all loop below | Every item once, no gaps, no duplicates |
The ones that catch real bugs:
#3, past the end. Some APIs return 404 here. Clients that treat 404 as "something broke" then retry forever. Returning an empty list is the friendlier contract.
#9, tampered cursor. Cursors are often base64 JSON. Decoding garbage throws, and if nobody catches it you return a 500 with a stack trace. Try it:
curl -i "https://api.testingapis.com/api/pagination/cursor?cursor=garbage"
# HTTP/1.1 400
#12, walk every page. This is the test I'd keep if I could only keep one:
const seen = new Set();
let cursor = null;
do {
const url = new URL("https://api.testingapis.com/api/pagination/cursor");
url.searchParams.set("limit", "10");
if (cursor) url.searchParams.set("cursor", cursor);
const page = await (await fetch(url)).json();
for (const item of page.data) {
if (seen.has(item.id)) throw new Error(`duplicate id ${item.id}`);
seen.add(item.id);
}
cursor = page.next_cursor;
} while (cursor);
console.log(`${seen.size} items`); // expect 100
It catches off-by-one boundaries, a missing stop condition and duplicates in one go.
Two cases you can only test on your own data
These need data that changes between requests, so no public API can show them to you:
- Delete a row on page 1 while a client is on page 2. Offset paging now skips an item. Cursor and keyset paging don't. If your feed uses offsets, this is a real bug, not a theory.
-
Sort by a column with duplicate values and no tie-breaker.
ORDER BY created_atwith two rows at the same timestamp can put the same row on two pages, or on neither. Always add the id as a tie-breaker.
Wrapping up
If your paging code passes all 12, it'll survive most real APIs. The full guide, with all five styles side by side and the responses for each case, is on testingapis.com. The endpoints are free and need no sign-up, so you can point your test suite at them.
What edge cases have bitten you? I'd like to add more to the list.
Originally published by Dev.to WebDev. Aggregated on AIWithGhost for educational purposes β full credit and traffic to the original publisher.