{"title":"Verify Pagination, Filtering, and Sorting Contracts","excerpt":"Asserting page lengths and spot-checking page one is true of an endpoint that loses records. This guide builds a reference set whose total order you control, walks it by following the Link header instead of assembling URLs, and asserts membership, uniqueness and sequence across the union — then inserts and deletes rows mid-traversal, which is exactly where offset pagination skips and duplicates.","canonicalUrl":"https://automationtester.in/blog/automation-tutorials/api-pagination-filtering-sorting-contract-tests","category":{"name":"Automation Tutorials","slug":"automation-tutorials"},"tags":["api-contracts","pagination","sorting","filtering","playwright","openapi"],"author":{"name":"Shashank Rawlani","url":"https://shashank.rawlani.com","linkedin":"https://www.linkedin.com/in/shashankrawlani/"},"datePublished":"2026-09-16T13:30:00.000Z","dateModified":"2026-09-07T11:52:05.697Z","coverImage":{"url":"https://ik.imagekit.io/automationtester/automationtester-in/blog/covers/v3/api-pagination-filtering-sorting-contract-tests.webp","alt":"Dark technical illustration reading left to right. A green panel on the left holds seven stacked pills of slightly varying length. A green arrow leads to the first of three dashed vertical windows, each containing three pills, joined left to right by green arrows. Above the second window an orange pill sits alone, and a solid orange line curves down from it into the top of that window, whose middle pill is orange rather than green. A dashed orange line falls from the bottom of the same window and ends at a crossed-out circle near the bottom of the frame. A green arrow leaves the third window for a second panel on the right holding the same stack of pills, except that the fourth position is an empty dashed orange outline instead of a filled pill."},"contentHtml":"<div class=\"callout callout-info\"><strong>Quick answer:</strong> Seed a reference set whose total order you control, walk every page by following the <code>Link</code> header rather than constructing URLs, and assert three things about the union: it equals the reference set, it contains no duplicate identifiers, and its order matches the reference sequence. Page lengths and a spot-check of page one cannot detect a hidden record. Then repeat the traversal with an insert and a delete landing between requests, because that is when offset pagination skips and duplicates rows.</div>\n\n<p>Nothing in an ordinary pagination suite can detect a record that was never returned. Page one has the right length. Page two starts with a different item. An out-of-range page comes back empty. All three assertions hold on an endpoint that quietly drops rows out of a nightly reconciliation walk.</p>\n\n<p>The records lost that way are not corrupt, not filtered, and not on a boundary. They were pushed across one by rows inserted ahead of them while the client sat between two requests — and no assertion about a single response can watch that happen.</p>\n\n<h2 id=\"start-from-a-reference-set\">Start from a reference set, not from a response</h2>\n\n<p>Pagination is a property of a traversal, not of a response, so no assertion about one response can establish it. What you need is a set whose membership and order you decided in advance, and three claims about the union of every page.</p>\n\n<pre class=\"language-ts\"><code class=\"language-ts\">import { test, expect } from '@playwright/test';\nimport { seedOrders, allPages } from './support/orders';\n\ntest('a full traversal returns every order exactly once, in order', async ({ request }) =&gt; {\n  // 11 records with a deliberate tie: three share a createdAt value, and the\n  // page size does not divide the total. Both are where traversals break.\n  const reference = await seedOrders(request, {\n    count: 11,\n    duplicateTimestampsAt: [4, 5, 6],\n  });\n\n  const pages = await allPages(request, '/v1/orders?sort=-createdAt,-id&amp;limit=3');\n  const seen = pages.flatMap((page) =&gt; page.items.map((o: { id: string }) =&gt; o.id));\n\n  // 1. Completeness: nothing hidden, nothing extra.\n  expect(new Set(seen)).toEqual(new Set(reference.ids));\n  // 2. Uniqueness: a record that appears on two pages is as wrong as a missing one.\n  expect(seen.length).toBe(reference.ids.length);\n  // 3. Order: the sequence, not just the membership.\n  expect(seen).toEqual(reference.idsInSortOrder);\n  // 4. Shape: only the last page may be short.\n  expect(pages.slice(0, -1).map((p) =&gt; p.items.length)).toEqual([3, 3, 3]);\n  expect(pages.at(-1)!.items.length).toBe(2);\n});</code></pre>\n\n<p>Assertions one and two look redundant and are not. Comparing sets catches a missing record; comparing lengths catches a duplicated one, because a set of eleven distinct IDs can come from twelve responses. Run both, and keep the sorted sequence separate from the membership check so a failure tells you whether data went missing or merely moved.</p>\n\n<p>Deliberately choosing a count the page size does not divide is worth the small effort. A total of twelve with a limit of three hides every off-by-one in the final page, and a final page is where the cursor either terminates or loops.</p>\n\n<h2 id=\"a-sort-key-is-not-an-order\">A sort key without a unique tie-breaker is not an order</h2>\n\n<p>PostgreSQL's documentation is blunt about the underlying issue: without an explicit sort step, \"the rows will be returned in an unspecified order\", which \"will depend on the scan and join plan types and the order on disk, but it must not be relied on\". Adding <code>ORDER BY created_at DESC</code> constrains rows with different timestamps and leaves rows with equal ones free to come back in any order — and free to come back in a <em>different</em> order on the next request, because a subsequent query may use a different plan.</p>\n\n<p>That is enough to lose a record. If three orders share a timestamp and a page boundary falls between the second and the third, request two can return the same row again and never return the one it displaced.</p>\n\n<p>The fix is a total order: every sort must end in a column that is unique, in the same direction.</p>\n\n<pre class=\"language-sql\"><code class=\"language-sql\">-- Rows sharing created_at may appear in any order, and a different order\n-- each time the query runs.\nORDER BY created_at DESC\nLIMIT 3 OFFSET 3;\n\n-- A total order. id breaks every tie, so the sequence is reproducible.\nORDER BY created_at DESC, id DESC\nLIMIT 3;</code></pre>\n\n<p>The test that discriminates needs more tied rows than fit on one page. Three records with an identical timestamp and a page size of three cannot detect the bug, because the tie never straddles a boundary. Seed at least <code>pageSize + 1</code> tied rows, then assert the full sequence twice:</p>\n\n<pre class=\"language-ts\"><code class=\"language-ts\">test('a tie straddling a page boundary traverses identically twice', async ({ request }) =&gt; {\n  await seedOrders(request, { count: 9, sameTimestampForAll: true });\n\n  const first = await allPages(request, '/v1/orders?sort=-createdAt,-id&amp;limit=4');\n  const second = await allPages(request, '/v1/orders?sort=-createdAt,-id&amp;limit=4');\n\n  const ids = (pages: typeof first) =&gt; pages.flatMap((p) =&gt; p.items.map((o: { id: string }) =&gt; o.id));\n  expect(ids(first)).toEqual(ids(second));\n  expect(new Set(ids(first)).size).toBe(9);\n});</code></pre>\n\n<p>Repeating the traversal is what makes this a test rather than a coin flip. A single walk over an unstable sort passes most of the time; two walks compared against each other fail as soon as the plan differs, and they fail with output that names the rows that moved.</p>\n\n<h2 id=\"nulls-sort-in-opposite-directions\">Nulls sort in opposite directions on the two engines you might be running</h2>\n\n<p>Sorting on a nullable column is where a correct-looking API behaves differently in staging and production, and the reason is documented rather than mysterious. The two most common engines have opposite defaults.</p>\n\n<table>\n<thead><tr><th>Engine</th><th><code>ORDER BY col ASC</code></th><th><code>ORDER BY col DESC</code></th></tr></thead>\n<tbody>\n<tr><td>PostgreSQL</td><td>Nulls last</td><td>Nulls first</td></tr>\n<tr><td>MySQL</td><td>Nulls first</td><td>Nulls last</td></tr>\n</tbody>\n</table>\n\n<p>PostgreSQL documents that null values sort as if larger than any non-null value, which makes <code>NULLS FIRST</code> the default for descending order and <code>NULLS LAST</code> otherwise. MySQL documents the reverse: nulls are presented first for ascending order and last for descending. Neither is wrong, and an API that sorts by <code>shippedAt</code> without an explicit null clause has a placement that comes from its connection string.</p>\n\n<p>Two consequences for the suite. Where nulls appear is part of the API's contract and belongs in the description, so the test asserts a position rather than tolerating either. And a nullable sort column cannot be the tie-breaker, because null values are equal to each other for ordering purposes and put you straight back into an unstable sort.</p>\n\n<pre class=\"language-ts\"><code class=\"language-ts\">test('unshipped orders sit at the end of a descending shippedAt sort', async ({ request }) =&gt; {\n  const reference = await seedOrders(request, { shipped: 4, unshipped: 3 });\n\n  const pages = await allPages(request, '/v1/orders?sort=-shippedAt,-id&amp;limit=3');\n  const seen = pages.flatMap((p) =&gt; p.items);\n\n  // The declared contract: nulls last regardless of engine. If the API has not\n  // declared one, this test is the forcing function to make it decide.\n  expect(seen.slice(0, 4).map((o) =&gt; o.id)).toEqual(reference.shippedIdsDesc);\n  expect(new Set(seen.slice(4).map((o) =&gt; o.id))).toEqual(new Set(reference.unshippedIds));\n});</code></pre>\n\n<h2 id=\"follow-the-link-header\">Follow the <code>Link</code> header rather than building the next URL</h2>\n\n<p>If the API paginates with a cursor, the cursor is an opaque token and the client's job is to follow a link, not to assemble one. A test that reads <code>response.body.nextCursor</code> and appends it to a base path is asserting an implementation detail, and it will keep passing after the server moves the cursor into the header where clients look for it.</p>\n\n<p>RFC 8288 defines the serialisation, and four of its rules trip up hand-written assertions. The target IRI sits inside angle brackets and may be a relative reference, which parsers MUST resolve against the request URI — and explicitly not against any base IRI in the message content. The <code>rel</code> parameter MUST be present and MUST NOT appear more than once in a link-value, with later occurrences ignored. Parameter values may be given as a token or a quoted string and recipients MUST be able to parse both, so <code>rel=next</code> and <code>rel=\"next\"</code> are the same link. And <code>rel</code> may carry several space-separated relation types, which are compared case-insensitively.</p>\n\n<pre class=\"language-http\"><code class=\"language-http\">; All four of these advertise the same next page.\nLink: &lt;/v1/orders?cursor=b3JkXzk5MQ&amp;limit=3&gt;; rel=\"next\"\nLink: &lt;/v1/orders?cursor=b3JkXzk5MQ&amp;limit=3&gt;; rel=next\nLink: &lt;https://api.example.com/v1/orders?cursor=b3JkXzk5MQ&gt;; rel=\"next last\"\nLink: &lt;/v1/orders?cursor=b3JkXzk5MQ&gt;; rel=\"NEXT\"; title=\"page 2\"</code></pre>\n\n<p>A traversal helper that handles those cases is short, and writing it once removes a whole class of false failures:</p>\n\n<pre class=\"language-ts\"><code class=\"language-ts\">/** Returns the resolved next-page URL, or null when the chain ends. */\nexport function nextLink(headerValue: string | undefined, requestUrl: string): string | null {\n  if (!headerValue) return null;\n\n  for (const value of headerValue.split(/,(?=\\s*&lt;)/)) {\n    const target = value.match(/&lt;([^&gt;]*)&gt;/)?.[1];\n    if (!target) continue;\n\n    // rel may be a token or quoted, and may list several types.\n    const rel = value.match(/;\\s*rel\\s*=\\s*(?:\"([^\"]*)\"|([^;,\\s]+))/i);\n    const types = (rel?.[1] ?? rel?.[2] ?? '').toLowerCase().split(/\\s+/);\n    // Relation types compare case-insensitively; only the first rel counts.\n    if (types.includes('next')) return new URL(target, requestUrl).toString();\n  }\n  return null;\n}</code></pre>\n\n<p>Then the termination condition is the absence of a next link, not a page shorter than the limit. Those are different conditions: a server may legitimately return a full page and no next link when the last record happens to fill it, and it may return a short page with a next link when a filter is applied after the fetch. Assert on the link, and add a hard iteration cap so a cursor that returns itself fails as a test rather than as a hung run.</p>\n\n<h2 id=\"mutate-between-pages\">Mutate between pages, then say which guarantee you expect</h2>\n\n<p>This is the failure the opening describes, and it is the test almost nobody writes because it requires doing something between two requests.</p>\n\n<p>The mechanism is worth stating precisely, because it decides the expectation rather than being background. Offset pagination addresses rows by position in the result set. Insert a row that sorts ahead of the current offset and every later row shifts down by one, so the row that was about to be returned moves behind the offset and is never seen. Delete such a row and everything shifts up, so a row already returned is returned again. Keyset pagination addresses rows by value — \"the next three rows after <code>(created_at, id)</code>\" — and inserting ahead of the cursor changes nothing about which rows follow it.</p>\n\n<p>That asymmetry is not a preference. For any traversal a client walks to completion — an export, a reconciliation, a sync — keyset is the correct mechanism and offset is a known-lossy one. Offset remains reasonable for a paginated screen a person clicks through, where a shifted row is a cosmetic surprise rather than a missing payment.</p>\n\n<pre class=\"language-ts\"><code class=\"language-ts\">test('an order inserted mid-traversal does not displace an unread one', async ({ request }) =&gt; {\n  const reference = await seedOrders(request, { count: 7 });\n\n  const page1 = await getPage(request, '/v1/orders?sort=-createdAt,-id&amp;limit=3');\n\n  // Land a record that sorts ahead of everything already returned.\n  const intruder = await seedOrders(request, { count: 1, newest: true });\n\n  const rest = await followFrom(request, page1);\n  const seen = [...page1.items, ...rest.flatMap((p) =&gt; p.items)].map((o) =&gt; o.id);\n\n  // Every pre-existing order appears exactly once. The late arrival may or may\n  // not appear — a cursor taken before it existed is not required to see it.\n  expect(new Set(seen)).toEqual(\n    new Set(seen.includes(intruder.ids[0]) ? [...reference.ids, ...intruder.ids] : reference.ids),\n  );\n  expect(new Set(seen).size).toBe(seen.length);\n});</code></pre>\n\n<p>Note what that test does not assert. Whether the newly inserted record appears is a snapshot question, and requiring either answer would be inventing a guarantee. What is not negotiable is that no pre-existing record vanishes and none arrives twice. Write the deletion variant too, with the deleted row sorting ahead of the cursor, because it fails differently: offset duplicates rather than skips, and a suite that only tests inserts will report the endpoint as sound.</p>\n\n<h2 id=\"filters-and-sort-keys-are-serialised\">Filters and sort keys are serialised by rules the description already fixed</h2>\n\n<p>Before writing filter cases, check how the description says the parameters are spelled, because the OpenAPI defaults are not what most people assume. For a query parameter, <code>style</code> defaults to <code>form</code>, and when <code>style</code> is <code>form</code>, <code>explode</code> defaults to <strong>true</strong>. An array parameter left at the defaults is therefore serialised as repeated parameters, not as a comma-joined list.</p>\n\n<pre class=\"language-yaml\"><code class=\"language-yaml\">- name: sort\n  in: query\n  schema: { type: array, items: { type: string } }\n  # Defaults: style=form, explode=true  -&gt;  ?sort=-createdAt&amp;sort=-id\n- name: status\n  in: query\n  explode: false\n  schema: { type: array, items: { type: string, enum: [PLACED, SHIPPED] } }\n  # explode: false  -&gt;  ?status=PLACED,SHIPPED\n- name: filter\n  in: query\n  style: deepObject\n  explode: true          # required in practice: deepObject with false is undefined\n  schema: { type: object, properties: { status: { type: string } } }\n  # ?filter[status]=SHIPPED</code></pre>\n\n<p>The <code>deepObject</code> note is in the specification itself: despite <code>false</code> being the documented default for <code>explode</code> outside <code>form</code>, the combination of <code>false</code> with <code>deepObject</code> is undefined. An API relying on that pairing has no defined wire format, and two clients generated from the same description can disagree — which surfaces as a filter that silently matches everything.</p>\n\n<p>With the spelling settled, filter composition needs one case per combination rule rather than one per filter. Two filters that must both hold, a filter combined with a sort, a filter whose result is empty, and a filter that eliminates everything after the first page. That last one is the interesting case: it distinguishes an API that filters before paginating from one that fetches a page and then filters it, and only the first can keep its page sizes honest.</p>\n\n<p>Boundary sizes are cheap and worth one case each: <code>limit=1</code>, <code>limit</code> at the documented maximum, <code>limit</code> one above it — which should be refused or clamped, and the description must say which — and a traversal whose total is an exact multiple of the limit, where the final request returns an empty page or no next link.</p>\n\n<h2 id=\"three-ways-a-suite-reports-success\">Three ways a pagination suite reports success on a lossy endpoint</h2>\n\n<h3 id=\"asserting-counts-not-membership\">The assertions are about counts, not membership</h3>\n\n<p>A suite asserting <code>items.length</code> on each page and a total in the response envelope will pass on an endpoint that returns one row twice and drops another, because both numbers stay correct. The diagnosis is a grep: if no test in the file compares a set of identifiers against a set you seeded, completeness is untested no matter how many cases there are. This is the same reason the total in the envelope is not evidence — it usually comes from a separate <code>COUNT</code> query that does not share the paginated query's plan.</p>\n\n<h3 id=\"fixture-too-small\">The fixture is smaller than the interesting behaviour</h3>\n\n<p>Five records with a page size of ten produce one page, and one page exercises no boundary, no cursor and no tie. The symptom is a suite that never reaches a second request. Size the fixture from the behaviour: at least three pages, a tie wider than one page, a total that is not a multiple of the limit, and at least one null in every nullable sort column. That is around a dozen records, which is still fast.</p>\n\n<h3 id=\"shared-mutable-data\">The traversal is racing another test</h3>\n\n<p>Completeness assertions are the most order-dependent tests you will write: any other worker inserting into the same collection breaks them, and it breaks them intermittently. Scope every traversal to data only it can see — a per-test tenant, account or tag included in the filter — and assert that scoping in the test rather than assuming it. A completeness test against a shared table is not flaky; it is measuring the wrong set.</p>\n\n<h2 id=\"questions-after-the-first-failure\">Questions that follow the first completeness failure</h2>\n\n<h3 id=\"faq-offset-legacy\">The API only supports offset and we cannot change it. What is worth testing?</h3>\n\n<p>Test the guarantee it can actually make, and write down the one it cannot. A stable total order and correct behaviour at every boundary are achievable on offset pagination and worth covering. Completeness under concurrent writes is not, so rather than a test that passes when the race does not happen, record the limitation where consumers will see it and give the reconciliation-style callers a different route: a snapshot endpoint, an as-of timestamp filter, or a change feed. A skipped test with a comment is more honest than a passing one that only fails in production.</p>\n\n<h3 id=\"faq-decode-cursor\">Should a test ever decode the cursor?</h3>\n\n<p>Not in the suite that gates merges. Decoding couples the test to an encoding the API is free to change, and it will produce a failure that looks like a pagination bug when a field name changed inside a base64 blob. There is one exception worth making deliberately: a single test asserting that the cursor is opaque in the way you intended — that it is not a plain offset, and that a tampered cursor is rejected rather than silently treated as page one. Write that test against the property, not against the contents.</p>\n\n<h3 id=\"faq-total-count\">Is an inaccurate total count a bug?</h3>\n\n<p>It depends on what the description promises, and this is worth deciding rather than inheriting. An exact total requires a second query whose answer can already be stale by the time the response is serialised, so a total that disagrees with the number of records a traversal yields is expected on a busy collection. If the API documents the total as exact, the disagreement is a defect; if it documents it as approximate, the test asserts it is within a stated tolerance and the traversal is the only source of truth for completeness. Silence in the description is the actual bug.</p>\n\n<h3 id=\"faq-generated-filter-cases\">Can a model generate the filter combinations?</h3>\n\n<p>Combinatorial expansion is exactly what it is good for, and it will produce a fuller cross product of filters, sorts and page sizes than anyone writes by hand. What it cannot supply is the reference data. The expected result for every one of those combinations comes from a set you seeded and ordered yourself, and a model asked to predict which records should match will produce an expectation derived from the same assumptions the implementation might have got wrong. Take the combinations; compute every expectation from the reference set.</p>\n\n<h2 id=\"primary-references\">Primary references</h2>\n\n<ul>\n<li><a href=\"https://www.rfc-editor.org/rfc/rfc8288.html#section-3\" target=\"_blank\" rel=\"noopener noreferrer\">RFC 8288, Web Linking §3</a>: the <code>Link</code> field ABNF, the target IRI in angle brackets and the requirement to resolve relative references against the request rather than any base IRI in the content (§3.1), and the <code>rel</code> parameter rules — present exactly once, later occurrences ignored, multiple space-separated relation types permitted (§3.3)</li>\n<li><a href=\"https://www.rfc-editor.org/rfc/rfc8288.html#section-2.1.1\" target=\"_blank\" rel=\"noopener noreferrer\">RFC 8288 §2.1.1</a>: registered relation types compared character by character case-insensitively, and required to be lowercase when registered — with §3 establishing that token and quoted-string parameter forms are equivalent and recipients MUST parse both</li>\n<li><a href=\"https://www.postgresql.org/docs/current/queries-order.html\" target=\"_blank\" rel=\"noopener noreferrer\">PostgreSQL 18 — Sorting Rows (ORDER BY)</a>: that unsorted output order is unspecified and must not be relied on, that later sort expressions break ties in earlier ones, and that null values sort as if larger than any non-null value, making <code>NULLS FIRST</code> the default for <code>DESC</code> and <code>NULLS LAST</code> otherwise</li>\n<li><a href=\"https://dev.mysql.com/doc/refman/8.4/en/working-with-null.html\" target=\"_blank\" rel=\"noopener noreferrer\">MySQL 8.4 — Working with NULL Values</a>: nulls presented first for <code>ORDER BY ... ASC</code> and last for <code>DESC</code>, the opposite of PostgreSQL's default</li>\n<li><a href=\"https://spec.openapis.org/oas/v3.1.1.html\" target=\"_blank\" rel=\"noopener noreferrer\">OpenAPI Specification 3.1.1 §4.8.12</a>: <code>style</code> defaulting to <code>form</code> for query parameters, <code>explode</code> defaulting to true when <code>style</code> is <code>form</code> and false otherwise, the statement that <code>deepObject</code> combined with <code>explode: false</code> is undefined, and <code>allowReserved</code> for reserved characters in query values</li>\n<li><a href=\"https://playwright.dev/docs/api/class-apiresponse\" target=\"_blank\" rel=\"noopener noreferrer\">Playwright — APIResponse</a>: <code>headers()</code> for reading the <code>Link</code> field, and the response accessors used throughout the examples</li>\n</ul>\n\n<h2 id=\"apply-this-now\">Apply this now</h2>\n\n<p>Pick the collection endpoint a machine walks to completion — the export, the sync, the nightly reconciliation — and read its sort clause. If it does not end in a unique column in the same direction, you have found a defect without writing a test, and it is the one most likely to be losing records already.</p>\n\n<p>Then write the traversal test: seed a dozen records with a tie wider than one page and a null in each nullable sort column, walk the pages by following the <code>Link</code> header, and assert set equality, length equality and the full sequence against your reference. Run it twice in the same test. Add the insert-between-pages variant once it passes. Save the reference sequence, the cursor or offset behind each request, and the union that came back, then compare records seeded against records seen. Those two numbers have to match, and they have to come from different places — one from your fixture, one from the traversal. Read them both out of the same response envelope and they will agree while being wrong.</p>\n"}