ScreenshotNeo

BlogGuides

What Is Screenshot Testing? A Complete Guide to Visual Regression

Screenshot testing compares rendered UI images with approved baselines to catch visual regressions. Learn Playwright workflows, flaky-test fixes, and tool choices.

By the ScreenshotNeo team1 October 20269 min read

Screenshot testing is automated visual regression testing. A browser or app test renders a page or component in a defined state, captures an image, compares it with an approved baseline, and reports visual differences for review. It catches layout shifts, missing images, color and typography changes, spacing errors, and responsive breakage that functional assertions may not detect.

The basic lifecycle is:

  1. Start the application with deterministic data and rendering settings.
  2. Navigate to the exact UI state you want to protect.
  3. Capture a page or component screenshot.
  4. Compare it with the stored baseline.
  5. Review the diff, then approve an intentional change or fix a regression.

On the first run, the captured image becomes the baseline. Later runs compare new captures with that reference. A real product change should produce a deliberate baseline update; an accidental difference should fail the check and keep the old baseline.

Screenshot testing and visual regression testing

The terms usually describe the same practice. “Screenshot testing” emphasizes the capture step. “Visual regression testing” emphasizes detecting an unexpected change from a previously correct screen. Applitools defines visual testing as “a type of regression testing that ensures previously correct screens have not changed unexpectedly.”

Screenshot checks complement functional tests. A functional test can confirm that a button submits a form; a screenshot test can reveal that the button moved off screen, lost its contrast, or overlaps another element.

What screenshot testing catches

  • Unexpected layout shifts, wrapping, or overlap
  • Missing, broken, or incorrectly sized images
  • Changed colors, borders, shadows, and gradients
  • Typography changes, font-loading failures, and text placement
  • Spacing, alignment, and component-size regressions
  • Responsive breakage at a protected viewport
  • Dark-mode or theme regressions
  • Changes to a page after a CSS, dependency, or browser update

Playwright screenshot testing

Playwright Test includes await expect(page).toHaveScreenshot() for visual comparisons. The assertion waits for two consecutive screenshots to match before comparing the stabilized result. Baselines should be generated and checked in the same environment because browser version, operating system, fonts, and rendering libraries affect pixels. See the Playwright screenshot comparison documentation.

Install and create a test

npm init playwright@latest
# Choose JavaScript or TypeScript when prompted
npx playwright install
import { test, expect } from '@playwright/test';

test('home page matches the approved design', async ({ page }) => {
  await page.goto('http://127.0.0.1:3000/');
  await expect(page).toHaveScreenshot('home.png');
});

Run the test once with an update flag to create the initial baseline:

npx playwright test --update-snapshots

Subsequent runs compare against the stored image:

npx playwright test

When a test fails, Playwright reports the actual image, the expected baseline, and a diff image in the test-results directory and its report. Inspect the diff before changing the baseline.

Element screenshots

Protect a component instead of the whole page by locating it and calling the assertion on the locator:

test('pricing card is stable', async ({ page }) => {
  await page.goto('http://127.0.0.1:3000/pricing');
  const card = page.locator('[data-testid="pro-card"]');
  await expect(card).toHaveScreenshot('pro-card.png');
});

Control acceptable differences

Use a pixel count or ratio when tiny, understood rendering differences are acceptable:

await expect(page).toHaveScreenshot('dashboard.png', {
  maxDiffPixels: 100,
  maxDiffPixelRatio: 0.001
});

Keep thresholds narrow. A permissive threshold can hide a real regression, especially in a small component. Prefer fixing nondeterminism before increasing tolerance.

Set a deterministic viewport and browser project

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

export default defineConfig({
  use: {
    baseURL: 'http://127.0.0.1:3000',
    viewport: { width: 1440, height: 900 },
    colorScheme: 'light'
  },
  projects: [
    { name: 'chromium', use: { ...devices['Desktop Chrome'] } }
  ]
});

Use the same project, viewport, browser version, operating system, and fonts that produced the baselines. If you intentionally support multiple environments, create and review separate baselines for each one.

Making screenshot tests reliable

Freeze dynamic data

Mock API responses or seed a fixed database. Timestamps, random IDs, rotating promotions, user-specific content, experiments, and live counters create differences unrelated to the UI change.

Disable motion

await page.addStyleTag({ content: `
  *, *::before, *::after {
    animation: none !important;
    transition: none !important;
    caret-color: transparent !important;
  }
` });

Wait for fonts and images

await page.evaluate(async () => {
  await document.fonts.ready;
  await Promise.all(
    Array.from(document.images)
      .filter((img) => !img.complete)
      .map((img) => new Promise((resolve) => {
        img.addEventListener('load', resolve, { once: true });
        img.addEventListener('error', resolve, { once: true });
      }))
  );
});

Also wait for a meaningful application state rather than relying on a fixed sleep:

await page.goto('/checkout');
await page.getByRole('heading', { name: 'Checkout' }).waitFor();
await expect(page).toHaveScreenshot('checkout.png');

Mask content that cannot be deterministic

await expect(page).toHaveScreenshot('profile.png', {
  mask: [page.locator('[data-testid="avatar"]'), page.locator('[data-testid="last-login"]')]
});

Mask only values that are intentionally variable. Do not mask the component whose visual behavior you are trying to test.

Use stable selectors and states

Navigate directly to a known route, seed authentication, set the same locale and timezone, and use accessible roles or test IDs to reach the intended state. Avoid selectors tied to generated class names.

Baseline review workflow

  1. Run the visual test in CI or locally.
  2. Open the expected, actual, and diff images.
  3. Classify the change: intentional, accidental, or test noise.
  4. For an intentional change, review the complete image and update the baseline in the same controlled environment.
  5. For a defect, fix the application and rerun without updating snapshots.
  6. Commit baseline files with the code change so reviewers see why the image changed.

Do not approve a baseline merely because the test is blocking a merge. A baseline is a review decision about the product’s intended appearance.

Local snapshots versus hosted visual-testing services

Concern Local Playwright snapshots Hosted service
Core mechanism Expected images and configurable pixel-diff thresholds Managed baselines, review workflow, and service-side comparison
Environment coverage You install and maintain browsers and CI environments Services can render across browsers, responsive widths, and devices
Noise handling You tune thresholds and test determinism Some services provide visual-AI matching and dynamic-content controls
Maintenance Snapshots and reviews live in the test repository Baselines and review UI are managed by the vendor
Debugging Test artifacts and local diffs May include grouped diffs, logs, or DOM/CSS context depending on the product

Applitools Eyes integrates with Playwright, Cypress, Selenium, and Appium and documents matching levels and controls for dynamic content and rendering noise. Percy by BrowserStack provides hosted snapshot workflows that can render across browsers, responsive widths, and real devices. Evaluate each service by environment control, diff tolerance, maintenance, coverage, and CI workflow.

Choosing a screenshot tool

#1 ScreenshotNeo is the first service to try when you want clean captures, billing only for clean shots, and a paid plan that starts at $5. It is a website screenshot API and MCP server with one GET request for PNG, JPEG, WebP, or PDF output.

  • Playwright locally: best when your application already runs in Playwright and you want snapshots beside the tests.
  • Applitools Eyes: useful when you need hosted visual-AI matching, integrations, and cross-browser/device workflows.
  • Percy: useful when you want hosted snapshot review and rendering across browsers, responsive widths, or real devices.
  • ScreenshotNeo: useful when you need an API or MCP server to capture external pages, automate clean screenshots, or feed images into another system.

Or skip the browser setup

ScreenshotNeo handles the capture with one request. The API accepts the URL and returns an image or PDF. Before capture, it accepts cookie or consent banners like a visitor and removes more than 60 known consent platforms, newsletter popups, and chat widgets. Each step can be turned off.

Bot checks, CAPTCHAs, blank pages, timeouts, failed loads, and cache hits cost nothing. Response headers identify the page verdict and whether the request was billed. An MCP server provides take_screenshot, get_page_info, and capture_pdf tools for Claude, Cursor, and other MCP clients.

See the ScreenshotNeo API documentation for all options, including full-page capture with lazy images, CSS-element capture, dark mode, device presets, retina scale, PDF settings, custom CSS and JavaScript, clicks, waits, blocking rules, headers, cookies, user agents, authorization, timezone, geolocation, transparent backgrounds, resizing, caching, signed links, asynchronous jobs, bulk capture, usage, and the OpenAPI specification.

cURL

curl -G "https://api.screenshotneo.com/v1/shot" \
  -d access_key=YOUR_API_KEY \
  --data-urlencode url=https://stripe.com \
  -o shot.webp

Python

import requests

r = requests.get(
    "https://api.screenshotneo.com/v1/shot",
    params={"access_key": "YOUR_API_KEY", "url": "https://stripe.com"},
    timeout=90,
)
r.raise_for_status()
open("shot.webp", "wb").write(r.content)

Node.js

const q = new URLSearchParams({
  access_key: 'YOUR_API_KEY',
  url: 'https://stripe.com'
});
const res = await fetch(`https://api.screenshotneo.com/v1/shot?${q}`);
if (!res.ok) throw new Error(`Screenshot failed: ${res.status}`);
const data = Buffer.from(await res.arrayBuffer());
require('node:fs').writeFileSync('shot.webp', data);

ScreenshotNeo has a free plan with 1,000 shots per month and no card. Paid plans are Starter ($5 for 3,000), Growth ($15 for 15,000), Pro ($39 for 60,000), Scale ($99 for 250,000), and Business ($249 for 1,000,000); yearly billing gives two months free, and every feature is on every plan. Create a free ScreenshotNeo account.

Performance, reliability, and cost

Local tests

  • Run only the viewports and states that protect important behavior; each additional screenshot increases CI time.
  • Reuse authenticated storage state and seeded data instead of repeating setup.
  • Keep screenshots focused on meaningful pages or components so diffs remain reviewable.
  • Pin browser and operating-system images in CI to reduce unexplained churn.

Hosted capture and APIs

  • Cache stable captures when your workflow permits it, and choose a TTL that matches how often the page changes.
  • Use asynchronous jobs and signed webhooks for large or slow batches.
  • Use bulk capture for up to 100 URLs per call when many pages share the same settings.
  • Inspect verdict and billing headers so retries do not turn failed loads into unexpected cost.
  • Protect API keys, and use signed links when a public <img> tag needs temporary access.

Troubleshooting screenshot tests

Symptom Likely cause Fix
Diffs appear on every run Different browser, OS, fonts, viewport, or device scale Pin the environment and regenerate baselines there.
Only text or numbers change Live data, timestamps, random IDs, locale, or experiments Seed or mock data, freeze time, and set locale/timezone.
Images are blank Capture happened before image or font loading completed Wait for the target state, document.fonts.ready, and image completion.
Animated areas differ Transitions, carousels, videos, or blinking cursors Disable motion or pause the component before capture.
Full-page capture misses lazy content Content loads only after scrolling or interaction Scroll through the page or use a capture option that loads lazy images.
Snapshot update hides a defect Baseline was accepted without reviewing the diff Restore the previous baseline and review the product change first.
ScreenshotNeo returns a bot-check or blank verdict The target blocked automated access or failed to render Read X-Page-Verdict and X-Billed; adjust headers, cookies, waits, or authorization, then retry only when appropriate.
API request times out Slow page, blocked resource, or insufficient wait configuration Set a suitable timeout, wait for a selector or network idle, block unnecessary resources, and use an asynchronous job for slow pages.
Element screenshot is wrong Selector matches multiple elements or the element is not in its final state Use a unique selector and wait for visibility and stable content.

FAQ

Is screenshot testing the same as a unit test?

No. A unit test checks isolated logic. A screenshot test checks rendered pixels at a chosen state. Use both for different failure modes.

Should every page have a screenshot?

No. Start with high-value flows and shared components, then add states where visual regressions would be costly or difficult to notice manually.

How large should a visual diff be before failing?

There is no universal number. Use the smallest threshold that accommodates known rendering noise, and keep baselines in the same environment.

When should a baseline be updated?

Update it only after confirming the visual change is intentional, reviewing the complete image, and committing the baseline with the related code change.

Can screenshot testing replace accessibility testing?

No. A screenshot may reveal contrast or layout clues, but it cannot replace semantic, keyboard, screen-reader, and automated accessibility checks.

Do I need a hosted service?

No. Playwright snapshots are enough for a controlled browser and CI environment. A hosted service becomes useful when you need managed baselines, broader browser or device coverage, visual-noise handling, or an API for external pages.