Quick answer: Pagination is where mock-based tests quietly get flaky, because most real endpoints don't return a fixed set of rows, and a mock that hands back the same generic page for every cursor lets test data leak between pages. The fix is to make the mock cursor-aware: it should return a deterministic slice of a fixed, seeded dataset, echo the exact cursor the client sent, and produce a stable next_cursor that walks the same data every run. A pagination mock that ignores the cursor is not modeling pagination at all; it is just returning a list, and the tests built on it will pass against behavior the real API never exhibits.

The temptation is strong to stub a paginated endpoint with a single canned response and move on. That works for the first render, but it breaks the moment a test follows the cursor to page two, asserts that pages do not overlap, or checks the "no more rows" condition. Because the mock ignores the incoming cursor, it returns the same rows forever, and any test that counts unique items or detects the end of the list either fails or, worse, gets written to match the broken behavior.

What a trustworthy pagination mock must model

A real paginated endpoint makes a few guarantees, and the mock should reproduce each one rather than approximate the happy response.

BehaviorReal APINaive static mock
Cursor handlingReturns the slice after the cursorIgnores the cursor
Page sizeAt most the requested limitAlways the same full list
Stable orderingDeterministic sort, no reorderingUndefined across runs
End conditionNull cursor when exhaustedNever terminates
No overlapRows never repeat across pagesSame rows every page

The end condition and the no-overlap guarantee are the ones teams miss most often. A client that keeps fetching while next_cursor is truthiness will loop forever against a mock that always returns a cursor, and a test that collects unique ids across pages will find duplicates the instant the mock ignores the cursor.

Build a cursor-aware mock from a seeded dataset

The clean approach is to give the mock one fixed, sorted dataset and have it interpret the cursor as an offset or a key into that dataset. Seeding the data once makes every run deterministic, and slicing by the cursor means pages actually advance.

// deterministic, cursor-aware pagination mock
const ROWS = buildSeedRows(47).sort((a, b) => a.id.localeCompare(b.id));

function listOrders({ cursor, limit = 20 }) {
  const start = cursor ? ROWS.findIndex((r) => r.id === cursor) + 1 : 0;
  const slice = ROWS.slice(start, start + limit);
  const next = slice.length === limit ? slice[slice.length - 1].id : null;
  return {
    data: slice,
    page_info: { next_cursor: next, has_more: next !== null },
  };
}

// MSW handler reads the cursor from the query string
http.get('https://api.example.com/v1/orders', ({ request }) => {
  const params = new URL(request.url).searchParams;
  const body = listOrders({
    cursor: params.get('cursor'),
    limit: Number(params.get('limit') ?? 20),
  });
  return HttpResponse.json(body);
});

Because the cursor is interpreted as the last seen id and the rows are sorted, page two starts exactly where page one ended. When fewer than limit rows remain, the cursor becomes null, so a client that terminates on a null cursor actually reaches the end.

Assert the contract, not just the first page

With an honest mock in place, the tests can assert the properties that matter: pages advance, ids never repeat, the page size is respected, and the walk terminates. These assertions have meaning because the mock models the same mechanics as the real endpoint.

test('walks every page without overlap and terminates', async () => {
  const seen = new Set();
  let cursor = null;
  let pages = 0;

  do {
    const res = await client.listOrders({ cursor, limit: 20 });
    for (const row of res.data) {
      expect(seen.has(row.id)).toBe(false);   // no overlap
      seen.add(row.id);
    }
    cursor = res.page_info.next_cursor;
    pages += 1;
  } while (cursor);

  expect(seen.size).toBe(47);                  // all seeded rows, once
  expect(pages).toBe(3);                       // 20 + 20 + 7
});

The same test run against a static mock would loop or double-count, which is exactly the point: a cursor-aware mock lets the test catch real client bugs, such as an off-by-one in cursor handling or a loop that ignores the null terminator.

Keep the mock aligned with the spec

Pagination parameters and the response envelope are defined in the OpenAPI description, and a local-first workflow can generate the mock from that contract so the cursor field, page size, and end-of-list shape cannot drift from what the API actually returns. When the spec changes the cursor name or caps the limit, the generated mock and client change together. Hand-rolled static stubs have no such link and are the usual place where pagination behavior diverges.

The practical takeaway is that mocking a paginated endpoint means mocking the state machine behind it, not the shape of one response. Seed the data, honor the cursor, terminate cleanly, and assert the full walk; then the tests prove real behavior and stay stable run after run.

Frequently asked questions

Can I just use a static response for a simple first-page test?

For a single render assertion that never follows the cursor, a static response is acceptable. But anything that loads more pages, deduplicates, or checks the end state needs a cursor-aware mock, or it does not test pagination.

Should the cursor be an offset or an opaque key?

Model whatever the real API uses. Key-based cursors (the last row id) are more robust to inserts and deletes; offset cursors are simpler but can skip or repeat rows if the data changes. The mock should match the production mechanism rather than invent one.

How do I keep test data deterministic across runs?

Build a fixed, sorted dataset from a seed function or fixture and reset the mock state before each test. Avoid pulling random or live data into the mock, which reintroduces nondeterminism.

What if the real API has a maximum page size?

Enforce that cap in the mock too, clamping or rejecting a requested limit beyond the maximum. If the mock silently accepts huge limits, tests will pass against a request the production API rejects.

Does this apply to offset-based pagination as well?

Yes. The mock should read the offset and limit, slice the seeded rows accordingly, and return the correct total. The same principles of deterministic data, honoring the input, and a clean end condition apply.

This is part of the API Mocking Guide — start there for the full discipline of building mocks that stay faithful to the real contract.