ScreenshotNeo

BlogHow-to

How to Test a Web App’s Pagination with Visual Regression Screenshots

Test first, middle, last, and empty pagination states with Playwright assertions and visual snapshots that catch layout regressions.

By the ScreenshotNeo team4 October 20269 min read

Use Playwright Test’s toHaveScreenshot() to compare named screenshots of representative pagination states, and assert the URL, active page, and visible records before each capture. The screenshot catches visual regressions; the behavioral assertions check that pagination actually works. A matching image alone cannot prove that the correct records or active page are shown.

1. Choose states that expose pagination bugs

Test boundaries and a representative interior state. A practical starting matrix is:

State Behavior to assert Visual concerns
First page Page one is active; previous is disabled or absent; first expected records appear. Control alignment, active styling, list spacing.
Middle page Navigation changes the page or cursor; expected records appear. Page-number window, long labels, list height.
Last page Last page is active; next is disabled or absent; final records appear. Boundary control styling and short final page.
Empty or filtered results Filter and page state agree; empty message or result count is correct. Empty-state layout, retained controls, reset-filter action.

Adapt the matrix to your design. Cursor pagination should assert the cursor transition and records; a “load more” interface should assert appended records and the button’s resulting state. Do not add every state to every visual suite if it duplicates the same layout. Prioritize boundaries, materially different content lengths, and states users rely on.

2. Install Playwright Test

In a Node.js project, install Playwright Test and its browser. The example assumes the app runs locally at http://127.0.0.1:3000 and exposes accessible pagination links plus a list test ID. Replace those selectors, routes, labels, and expected records with your app’s contract.

npm init -y
npm install --save-dev @playwright/test
npx playwright install chromium

Create playwright.config.ts:

import { defineConfig } from '@playwright/test';

export default defineConfig({
  testDir: './tests',
  use: {
    baseURL: 'http://127.0.0.1:3000',
    browserName: 'chromium',
    viewport: { width: 1280, height: 800 },
    locale: 'en-US',
    timezoneId: 'UTC',
  },
  expect: {
    timeout: 5_000,
    toHaveScreenshot: {
      animations: 'disabled',
      // Start strict; tune only after examining real diffs.
      maxDiffPixelRatio: 0.001,
    },
  },
  webServer: {
    command: 'npm run start',
    url: 'http://127.0.0.1:3000',
    reuseExistingServer: !process.env.CI,
  },
});

Your app must define a start script or you can replace the web server command with your project’s local server command. Use one fixed viewport and browser environment for baseline creation and comparison. If the app requires authentication, establish a deterministic test account and state before navigating; avoid depending on a shared account whose data changes.

3. Write a behavior-first visual test

This runnable example checks the first, middle, and last pages. It assumes page links are named “1”, “2”, and “3”, the active link has aria-current="page", and the list has data-testid="product-list". The test checks expected content before taking each screenshot.

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

test('pagination behavior and appearance stay correct', async ({ page }) => {
  await page.goto('/products');
  const list = page.getByTestId('product-list');

  // First page: boundary, active state, and representative data.
  await expect(page).toHaveURL(/\/products(?:\?.*)?$/);
  await expect(page.getByRole('link', { name: '1', exact: true }))
    .toHaveAttribute('aria-current', 'page');
  await expect(list).toContainText('Product 1');
  await expect(page.getByRole('button', { name: 'Previous page' }))
    .toBeDisabled();
  await expect(page).toHaveScreenshot('pagination-first-page-desktop.png');

  // Middle page.
  await page.getByRole('link', { name: 'Next page' }).click();
  await expect(page).toHaveURL(/page=2/);
  await expect(page.getByRole('link', { name: '2', exact: true }))
    .toHaveAttribute('aria-current', 'page');
  await expect(list).toContainText('Product 11');
  await expect(page).toHaveScreenshot('pagination-middle-page-desktop.png');

  // Last page. This app has three pages; use its actual total in your test.
  await page.getByRole('link', { name: '3', exact: true }).click();
  await expect(page).toHaveURL(/page=3/);
  await expect(page.getByRole('link', { name: '3', exact: true }))
    .toHaveAttribute('aria-current', 'page');
  await expect(list).toContainText('Product 21');
  await expect(page.getByRole('button', { name: 'Next page' }))
    .toBeDisabled();
  await expect(page).toHaveScreenshot('pagination-last-page-desktop.png');
});

If your controls are links rather than buttons, assert their boundary behavior with the correct locator, such as toHaveAttribute('aria-disabled', 'true'), or assert that the control is absent. Do not assert a disabled button if the product intentionally renders a non-interactive span at the boundary.

Retrying Playwright web assertions re-check until the condition succeeds or times out. That makes them more suitable for asynchronous UI updates than reading a value once. Avoid fixed sleeps as a readiness strategy: they slow passing runs and still can be too short on a slow run. See Playwright’s assertion guidance.

4. Add filtered and empty-state coverage

Include an empty state when it is a supported product behavior, and ensure filtering resets or preserves pagination according to the app’s intended rules. For example:

test('empty filter state is stable', async ({ page }) => {
  await page.goto('/products?search=no-such-product');
  await expect(page.getByTestId('product-list')).toBeEmpty();
  await expect(page.getByRole('status')).toContainText('No products found');
  await expect(page).toHaveScreenshot('pagination-empty-filter-desktop.png');
});

Replace the message and selectors with your application’s actual accessible UI. If a filter should reset to page one, assert that in the URL or active-page indicator. If it should retain the current page, verify that the retained page is valid for the filtered result set.

5. Create and maintain screenshot references

  1. Run npx playwright test. On the first run, the screenshot assertions create reference images and report that the snapshots did not exist.
  2. Inspect the generated files in the test’s snapshot directory. Confirm the images show the expected records, active page, and boundary controls.
  3. Commit reviewed reference images alongside the test. They are the expected state for later runs.
  4. Run the same command in CI and investigate any image diff before deciding whether the change is a bug or an intended redesign.
  5. When a visual change is intentional, inspect the diff, then update references with npx playwright test --update-snapshots and include those changed files in code review.

Playwright’s screenshot assertion captures until two consecutive screenshots match before saving or comparing. This can help with transient rendering, but it does not replace application-specific assertions. The browser version, operating system, fonts, hardware, settings, and headless mode can affect pixels; Playwright recommends comparing in the same environment used to produce the references. See its visual comparison documentation.

6. Control noise without hiding regressions

  • Fix inputs: use stable seeded data, fixed locale and timezone, a consistent viewport, and an isolated test account. Keep the same browser project for baseline and comparison.
  • Wait for the real state: assert the URL, active page, and expected records before the screenshot. If data is fetched asynchronously, wait for the relevant UI condition.
  • Animations: screenshot assertions disable CSS and Web Animations by default; the config above states this explicitly. If an animation is itself under test, use a separate functional test.
  • Mask narrowly: locator masks can cover truly volatile content such as a timestamp. Do not mask page numbers, pagination controls, or records under test. A broad mask can conceal the regression you want to catch.
  • Style volatile regions: Playwright supports a screenshot stylesheet through stylePath to hide or stabilize specific content during capture. Keep this CSS narrowly scoped and review it like test code.
  • Choose the capture area: page screenshots check the overall layout; locator screenshots focus on a table, list, or pagination bar. Full-page capture is useful when below-the-fold content matters, but produces larger and potentially noisier comparisons.
  • Name each image: encode state and viewport, such as pagination-first-page-desktop.png and pagination-last-page-mobile.png. Browser or project names are also included in snapshot paths by Playwright when applicable.

Screenshot comparison supports controls such as maxDiffPixels and a color difference threshold. Start with a strict comparison and stable inputs. Relax a threshold only after inspecting recurring harmless noise; permissive settings can hide a real regression. Playwright documents these options and screenshot styling in its visual comparison options.

7. Cover viewport and browser differences deliberately

A desktop screenshot does not establish that mobile pagination wraps, scrolls, or stays usable. Add a small mobile project or set a mobile viewport for the particular states with distinct responsive behavior. Keep desktop and mobile references separate in their names. Likewise, browser engines can render fonts and controls differently; if cross-browser appearance matters, run separate Playwright projects and maintain their separate references instead of comparing one browser’s baseline to another.

Balance breadth with review cost: a first, middle, and last page at one desktop viewport is a useful core. Add mobile, empty, filtered, and alternate-browser captures where the layout or behavior differs. Avoid multiplying identical screenshots across every record page.

8. Diagnose common failures

Symptom Likely cause What to do
Snapshot missing on the first run No reference exists yet. Inspect the generated image and commit it if it represents the intended state.
URL or active-page assertion times out The click did not navigate, the locator is wrong, or the app updates via a different route/state model. Check the actual accessible name and URL behavior. Assert the app’s real page indicator or cursor state.
Expected records are absent Test data changed, the wrong page size is assumed, the request failed, or content has not loaded. Use deterministic fixtures or seed data; check the list’s loading/error state and correct the expected records.
Screenshots differ on every run Unstable content, animation, timing, locale, font, or a different browser environment. Stabilize inputs and runner environment, assert readiness, and mask only irrelevant dynamic regions.
Many snapshots fail after a runner update Browser, OS, font, or rendering environment changed. Run in the baseline environment or review intentional differences and update snapshots as a reviewed change.
Diff is too sensitive or too permissive Thresholds do not fit the rendering noise and visual risk. First reduce noise; then tune maxDiffPixels or color threshold against reviewed diffs. Avoid blanket permissive tolerances.
Full-page image is unexpectedly large The page is long or contains unrelated sections. Use a locator screenshot or focused viewport when below-the-fold content is not part of the check.
Baseline update hides a defect Snapshot update ran before the diff was understood. Revert the baseline change, reproduce the issue, and update only after confirming the intended UI.

9. When hosted visual review helps

Playwright’s local snapshot files are enough for teams that review diffs in their existing code workflow. Percy documents a Playwright integration for teams that want a hosted visual review workflow. Choose the workflow based on how your team reviews and approves image changes; retain the behavioral assertions either way, since pixel comparisons do not validate pagination data or navigation.

10. Or skip the browser setup

For a captured reference image without installing and maintaining a browser runner, ScreenshotNeo is a website screenshot API and MCP server. The API can capture a URL as PNG, JPEG, WebP, or PDF. One call captures a URL; use your Playwright test for transitions and assertions, and use API captures for repeatable URL states or review artifacts.

curl -G "https://api.screenshotneo.com/v1/shot" -d access_key=YOUR_API_KEY --data-urlencode url=https://your-app.example/products?page=2 -o page-2.webp
import requests

r = requests.get(
    "https://api.screenshotneo.com/v1/shot",
    params={"access_key": "YOUR_API_KEY", "url": "https://your-app.example/products?page=2"},
    timeout=90,
)
r.raise_for_status()
open("page-2.webp", "wb").write(r.content)
const q = new URLSearchParams({
  access_key: 'YOUR_API_KEY',
  url: 'https://your-app.example/products?page=2'
});
const res = await fetch(`https://api.screenshotneo.com/v1/shot?${q}`);
if (!res.ok) throw new Error(`Screenshot request failed: ${res.status}`);
await Bun.write('page-2.webp', res);

See the ScreenshotNeo API documentation for request options. Cookie banners, newsletter popups, and chat widgets are removed before capture; these cleanup steps can be turned off. Bot checks, blank pages, failed loads, timeouts, and cache hits are not billed, with page verdict and billing details in response headers. ScreenshotNeo also has an MCP server with take_screenshot, get_page_info, and capture_pdf tools for AI agents. The free plan includes 1,000 screenshots per month with no card; paid plans start at $5 for 3,000 screenshots. Use fixed URLs and stable page data when keeping reference captures.

Sign up for ScreenshotNeo’s free 1,000 screenshots a month, with no card.

FAQ

Can screenshots prove that page two contains the correct records?

No. Assert the page state and expected records in the test; the screenshot checks their appearance.

Should I update snapshots whenever CI reports a difference?

No. First inspect the diff and determine whether it reflects an intended UI change or a defect, unstable input, or environment change.

Do I need a separate baseline for mobile?

If responsive layout is part of the behavior you need to protect, capture mobile states with their own references and viewport.