|

Testing Pagination in Playwright: Pages, Cursors, Infinite

Pagination looks trivial until you automate it: a “Next” button that goes stale, a cursor token that never repeats, and infinite scroll that fires three network calls before your assertion runs. Flaky pagination tests are one of the most common reasons end-to-end suites turn red for no real reason. In this guide you will learn practical, production-ready Playwright pagination testing patterns for the three flavours you will meet in the wild: classic numbered pages, cursor (token) based APIs, and infinite scroll.

🎭 Want to master this with real projects? Join the Playwright Automation Mastery course at The Testing Academy.

Every example below is real, runnable Playwright with TypeScript. We lean on Playwright’s auto-waiting, web-first assertions, and page.route network interception so the tests stay fast and deterministic instead of sprinkling arbitrary waitForTimeout calls everywhere.

Contents

The three pagination models you actually test

Before writing a single locator, decide which model the UI uses, because the failure modes differ. Offset pagination is easy to assert but can skip or duplicate rows when data shifts under you. Cursor pagination is stable but you cannot jump to “page 7” directly. Infinite scroll has no page numbers at all, so you assert on accumulated item counts and scroll position instead.

ModelHow it worksWhat you assertMain flake risk
Offset / numbered pages?page=2&limit=20Active page, row count, URL stateDuplicate rows when data changes mid-run
Cursor / token?after=eyJpZCI6NDB9New items appended, no repeatsAsserting on opaque cursor values
Infinite scrollScroll triggers fetchGrowing item count, end-of-listRace between scroll and network

Testing classic numbered pagination

Numbered pagination is the friendliest case. The key habit is to anchor assertions to the URL and the active-page indicator rather than to row order, because the rows themselves can change between runs. Use page.getByRole for navigation controls so your test reads like a user story and survives CSS refactors.

import { test, expect } from '@playwright/test';

test('navigates numbered pages and tracks the active page', async ({ page }) => {
  await page.goto('https://demo.example.com/products?page=1');

  const rows = page.getByRole('row');
  // Header row + 20 data rows on a full page.
  await expect(rows).toHaveCount(21);

  // Go to the next page using an accessible name, not a brittle CSS class.
  await page.getByRole('button', { name: 'Next page' }).click();

  // Web-first assertion: Playwright retries until the URL updates.
  await expect(page).toHaveURL(/[?&]page=2\b/);

  // The active page control should now read "2".
  await expect(page.getByRole('button', { name: 'page 2', exact: false }))
    .toHaveAttribute('aria-current', 'page');
});

A subtle bug numbered pagination hides is duplicate or skipped rows when the backend re-sorts data between requests. Catch it by collecting a stable identifier (an SKU, an id) from each page and asserting the global set has no repeats. This is far more valuable than counting rows.

test('no duplicate items across the first three pages', async ({ page }) => {
  await page.goto('https://demo.example.com/products?page=1');
  const seen = new Set<string>();

  for (let pageNum = 1; pageNum <= 3; pageNum++) {
    // Wait for the grid to settle before reading.
    await expect(page.getByTestId('product-card').first()).toBeVisible();

    const ids = await page.getByTestId('product-card')
      .evaluateAll((cards) => cards.map((c) => c.getAttribute('data-sku') ?? ''));

    for (const id of ids) {
      expect(seen.has(id), `Duplicate SKU ${id} on page ${pageNum}`).toBe(false);
      seen.add(id);
    }

    if (pageNum < 3) {
      await page.getByRole('button', { name: 'Next page' }).click();
      await expect(page).toHaveURL(new RegExp(`[?&]page=${pageNum + 1}\\b`));
    }
  }

  expect(seen.size).toBeGreaterThanOrEqual(60);
});

Notice there is not a single waitForTimeout. Each loop iteration waits on a real, observable condition (a visible card, an updated URL), which is the foundation of every reliable pagination test.

Testing cursor-based pagination

Cursor APIs hand you an opaque token (nextCursor, after, endCursor) that you must echo back to fetch the next batch. The cardinal rule: never assert on the cursor value itself. It is implementation detail and may be a base64-encoded blob that changes whenever the backend evolves. Instead, assert on behaviour: each “Load more” appends fresh items, the cursor advances, and the control disappears at the end.

To make the test hermetic and fast, mock the paginated endpoint with page.route so you control exactly how many pages exist and when the cursor runs out.

import { test, expect } from '@playwright/test';

function range(from: number, to: number) {
  return Array.from({ length: to - from + 1 }, (_, i) => ({ id: from + i, name: `Item ${from + i}` }));
}

// Build three fixed pages of fake data keyed by cursor.
const PAGES: Record<string, { items: { id: number; name: string }[]; nextCursor: string | null }> = {
  start:  { items: range(1, 20),  nextCursor: 'cur_20' },
  cur_20: { items: range(21, 40), nextCursor: 'cur_40' },
  cur_40: { items: range(41, 50), nextCursor: null },
};

test('cursor pagination appends items until the cursor is null', async ({ page }) => {
  await page.route('**/api/items*', async (route) => {
    const url = new URL(route.request().url());
    const cursor = url.searchParams.get('after') ?? 'start';
    await route.fulfill({ json: PAGES[cursor] });
  });

  await page.goto('https://demo.example.com/feed');

  const items = page.getByTestId('feed-item');
  await expect(items).toHaveCount(20);

  // Click through every page the mock defines.
  await page.getByRole('button', { name: 'Load more' }).click();
  await expect(items).toHaveCount(40);

  await page.getByRole('button', { name: 'Load more' }).click();
  await expect(items).toHaveCount(50);

  // End of data: the button must be gone, never just disabled silently.
  await expect(page.getByRole('button', { name: 'Load more' })).toBeHidden();
});

Because the mock is the single source of truth, this test runs in milliseconds and never depends on real data volume. You can also flip nextCursor to null earlier to assert the empty-state and end-of-list handling without seeding a database.

Testing infinite scroll

Infinite scroll is where most suites get flaky. The trap is scrolling and immediately asserting before the lazy fetch resolves. The robust approach is a helper that scrolls, then waits for the item count to actually grow, with a guard against an infinite loop when no new content arrives.

import { test, expect, type Locator } from '@playwright/test';

async function scrollUntilCount(items: Locator, target: number, maxScrolls = 15) {
  for (let i = 0; i < maxScrolls; i++) {
    const current = await items.count();
    if (current >= target) return;

    // Scroll the last visible item into view to trigger the next fetch.
    await items.last().scrollIntoViewIfNeeded();

    // Wait for the count to increase past the value we just read.
    await expect
      .poll(async () => items.count(), { timeout: 5000 })
      .toBeGreaterThan(current);
  }
  throw new Error(`Did not reach ${target} items after ${maxScrolls} scrolls`);
}

test('infinite scroll loads at least 80 items', async ({ page }) => {
  await page.goto('https://demo.example.com/timeline');
  const items = page.getByTestId('timeline-item');

  await expect(items.first()).toBeVisible();
  await scrollUntilCount(items, 80);

  expect(await items.count()).toBeGreaterThanOrEqual(80);
});

The expect.poll call is the hero here. It retries the count callback until it grows or the timeout fires, so you never need a magic sleep. Pairing scrollIntoViewIfNeeded with a count assertion makes the test mirror how a real user reaches the bottom of a feed.

Asserting the end of an infinite list

A list that scrolls forever in a test is usually a bug, not a feature. Stub the network so the feed terminates, then assert that the loading spinner disappears and an end-of-list marker appears. Combining page.route with page.waitForLoadState lets you confirm fetching settled before asserting.

test('infinite scroll shows end-of-list and stops fetching', async ({ page }) => {
  let pageIndex = 0;

  await page.route('**/api/timeline*', async (route) => {
    pageIndex++;
    const isLast = pageIndex >= 3;
    await route.fulfill({
      json: {
        items: Array.from({ length: isLast ? 4 : 20 }, (_, i) => ({ id: pageIndex * 100 + i })),
        hasMore: !isLast,
      },
    });
  });

  await page.goto('https://demo.example.com/timeline');
  const items = page.getByTestId('timeline-item');

  // Scroll to exhaust all three mocked pages.
  for (let i = 0; i < 3; i++) {
    await items.last().scrollIntoViewIfNeeded();
    await page.waitForLoadState('networkidle');
  }

  await expect(page.getByText('You have reached the end')).toBeVisible();
  await expect(page.getByTestId('loading-spinner')).toBeHidden();
  expect(pageIndex).toBe(3);
});

🚀 Level Up Your Playwright

From locators to CI pipelines — build a production-grade Playwright + TypeScript framework step by step.

Making Playwright pagination testing fast and stable

A few cross-cutting habits keep all three models reliable:

  • Mock the data layer with page.route when you are testing the UI’s pagination logic, not the backend. It removes data drift and makes edge cases (empty results, single page, last page) trivial to reproduce.
  • Assert on counts and identity, not on exact row text that real data may change between runs.
  • Use expect.poll and web-first assertions instead of waitForTimeout; they retry under the hood and shave seconds off every test.
  • Verify the URL for offset pagination so deep links and browser back/forward keep working.
  • Use expect.soft when you want to record several pagination problems in one run instead of stopping at the first failure.

Here is how soft assertions let one test report multiple page-level issues at once, which is handy in a smoke test across many pages.

test('soft-check every page has a full grid', async ({ page }) => {
  await page.goto('https://demo.example.com/products?page=1');

  for (let p = 1; p <= 3; p++) {
    await expect(page.getByTestId('product-card').first()).toBeVisible();
    const count = await page.getByTestId('product-card').count();

    // Soft: keep going even if one page is short, then surface all failures.
    expect.soft(count, `page ${p} should be full`).toBe(20);

    if (p < 3) {
      await page.getByRole('button', { name: 'Next page' }).click();
      await expect(page).toHaveURL(new RegExp(`page=${p + 1}`));
    }
  }
});

Master these three models and the supporting habits and your Playwright pagination testing will stop being a flake factory. Mock when you control the UI logic, assert on identity and counts rather than fragile text, and always wait on an observable condition instead of the clock. The result is a suite that catches real regressions in offset, cursor, and infinite-scroll flows while staying green when the data underneath quietly changes.

FAQ

How do I avoid flaky infinite scroll tests in Playwright?

Never scroll and assert in the same breath. Scroll the last item into view with scrollIntoViewIfNeeded, then use expect.poll to wait until the item count actually grows before continuing. Adding a maximum-scroll guard prevents an endless loop when no new data arrives, and mocking the endpoint with page.route lets you force a clean end-of-list so the test terminates deterministically.

Should I assert on the cursor token value in cursor pagination?

No. The cursor is opaque implementation detail and often a base64 blob that changes when the backend evolves, so asserting on its exact value makes the test brittle. Assert on behaviour instead: each “Load more” appends new, non-duplicate items, the total count increases by the expected page size, and the control hides once the API returns a null cursor.

When should I mock pagination requests versus hit the real API?

Mock with page.route when you are testing the UI’s pagination logic, edge cases like empty or single-page results, or end-of-list handling, because it removes data drift and runs in milliseconds. Hit the real API only in a small set of integration tests where you genuinely want to validate the backend contract, and even there assert on counts and identity rather than exact row content.

🎓 Master Playwright End to End

Join hundreds of SDETs building real automation frameworks. Lifetime access, hands-on projects, and a job-ready portfolio.

Similar Posts

Leave a Reply

Your email address will not be published. Required fields are marked *

This site uses Akismet to reduce spam. Learn how your comment data is processed.