ScreenshotNeo

BlogHow-to

How to Test a Web App’s Loading, Empty, and Error States with Screenshots

Control each response, assert the intended UI has rendered, then capture it. This guide shows repeatable Playwright and Cypress workflows for loading, empty, and error states.

By the ScreenshotNeo team4 October 202610 min read

To test loading, empty, and error states with screenshots, make each state deterministic, assert that the intended user-visible content has rendered, and only then capture or compare the screenshot. Control network responses with fixtures or request interception; use a pending response for loading, an empty result for the empty state, and a controlled failure for the error state. Screenshots catch visual regressions, while semantic and accessibility assertions check meaning and behavior that pixels cannot establish.

This guide uses Playwright with TypeScript for a complete runnable example, then shows Cypress patterns and command-line/API options. The same sequence applies across frameworks: arrange the response, trigger the UI, assert the state, capture it, and review changes against an approved baseline.

1. Decide what each state should prove

Write down the expected content and action before writing a screenshot test. A loading state should expose a progress indicator or message while work is pending. An empty state should explain that there is no content and provide any expected next action. An error state should explain what failed and show a useful recovery path.

State Controlled condition Assertions before capture
Loading Request remains pending Progress indicator or loading message is visible
Empty Successful response with an empty collection Empty-state message and expected action are visible
Error Failure response or failed request Error message and recovery control are visible

Do not treat an empty container as a valid empty state unless that is the product’s intended design. Also test form-level and in-flow failures where users need to understand and recover from an error; an initial page load and a successful final state do not cover these cases.

2. Playwright: runnable loading, empty, and error tests

Install Playwright and its test browsers in an existing Node.js project:

npm init playwright@latest

The example below assumes the app is available at http://127.0.0.1:3000, renders a main landmark, fetches /api/orders, and has the accessible UI described by the assertions. Adjust the route and accessible names to match your app. Save it as tests/orders-states.spec.ts:

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

const appUrl = 'http://127.0.0.1:3000/orders';

 test('shows loading while orders are pending, then shows results', async ({ page }) => {
  let finishRequest!: () => void;
  const pending = new Promise<void>((resolve) => { finishRequest = resolve; });

  await page.route('**/api/orders', async (route) => {
    await pending;
    await route.fulfill({
      status: 200,
      contentType: 'application/json',
      body: JSON.stringify([{ id: 'order-1042', name: 'Sample order' }]),
    });
  });

  await page.goto(appUrl);
  await expect(page.getByRole('status')).toContainText('Loading orders');
  await expect(page.getByRole('main')).toHaveScreenshot('orders-loading.png');

  finishRequest();
  await expect(page.getByText('Sample order')).toBeVisible();
});

test('shows the designed empty state for an empty response', async ({ page }) => {
  await page.route('**/api/orders', (route) => route.fulfill({
    status: 200,
    contentType: 'application/json',
    body: JSON.stringify([]),
  }));

  await page.goto(appUrl);
  await expect(page.getByRole('heading', { name: 'No orders yet' })).toBeVisible();
  await expect(page.getByRole('link', { name: 'Create an order' })).toBeVisible();
  await expect(page.getByRole('main')).toHaveScreenshot('orders-empty.png');
});

test('shows an error and recovery action when the API fails', async ({ page }) => {
  await page.route('**/api/orders', (route) => route.fulfill({
    status: 503,
    contentType: 'application/json',
    body: JSON.stringify({ message: 'Service unavailable' }),
  }));

  await page.goto(appUrl);
  await expect(page.getByRole('alert')).toContainText('Orders could not be loaded');
  await expect(page.getByRole('button', { name: 'Try again' })).toBeVisible();
  await expect(page.getByRole('main')).toHaveScreenshot('orders-error.png');
});

Run the tests with npx playwright test. On the first run, Playwright creates screenshot baselines for screenshot assertions; inspect those images and commit approved baselines with the test. Later runs compare against them and report differences for review. Update a baseline only when the visual change is intended.

The loading test holds the request open until after the loading assertion and capture. This makes the intermediate state reproducible instead of relying on a short response timing window. It then releases the request and verifies that the resulting content appears, so the test also checks that the pending state can resolve.

Capture a component or a whole page

toHaveScreenshot() can target a page or a locator. The example captures main, which limits unrelated page changes. Use await expect(page).toHaveScreenshot('orders-page.png') when the whole viewport is the intended checkpoint. Playwright’s Page API also supports direct screenshots with options such as a path, full-page capture, and clipping; use screenshot assertions when you want a baseline comparison as part of the test.

For long pages, choose deliberately between a full-page baseline and a focused component. Full-page images catch page layout changes but can include unrelated content; component screenshots reduce noise but cannot show how the component fits into the complete page.

3. Cypress: intercept, assert, and capture

Cypress can make network outcomes repeatable with cy.intercept(), then capture the result with cy.screenshot(). Add patterns like these to a Cypress spec, adapting the endpoint, selectors, and visible copy for your app:

describe('orders states', () => {
  it('captures loading while the request is pending', () => {
    let release;
    cy.intercept('GET', '/api/orders', (req) => {
      return new Promise((resolve) => {
        release = () => resolve({
          statusCode: 200,
          body: [{ id: 'order-1042', name: 'Sample order' }],
        });
      });
    }).as('orders');

    cy.visit('/orders');
    cy.findByRole('status').should('contain.text', 'Loading orders');
    cy.get('main').screenshot('orders-loading');
    cy.then(() => release());
    cy.wait('@orders');
    cy.contains('Sample order').should('be.visible');
  });

  it('captures the empty state', () => {
    cy.intercept('GET', '/api/orders', { statusCode: 200, body: [] }).as('orders');
    cy.visit('/orders');
    cy.wait('@orders');
    cy.findByRole('heading', { name: 'No orders yet' }).should('be.visible');
    cy.findByRole('link', { name: 'Create an order' }).should('be.visible');
    cy.get('main').screenshot('orders-empty');
  });

  it('captures the error state', () => {
    cy.intercept('GET', '/api/orders', {
      statusCode: 503,
      body: { message: 'Service unavailable' },
    }).as('orders');
    cy.visit('/orders');
    cy.wait('@orders');
    cy.findByRole('alert').should('contain.text', 'Orders could not be loaded');
    cy.findByRole('button', { name: 'Try again' }).should('be.visible');
    cy.get('main').screenshot('orders-error');
  });
});

This example uses Cypress Testing Library role queries; if that library is not installed, use equivalent Cypress selectors and assertions. Cypress’s built-in screenshot command captures an image but does not compare it with a baseline. Add a visual comparison integration if automated diffing is required; otherwise, inspect captured artifacts as part of review.

4. Make screenshots stable and useful

  1. Use fixed inputs. Stub success, empty, delayed, and failure outcomes with fixtures or intercepted responses. Keep response bodies, IDs, dates, and other displayed values consistent.
  2. Wait on meaning, not guessed time. Assert a visible message, role, or control before capturing. Avoid arbitrary sleeps where an assertion can wait for the intended UI.
  3. Fix the rendering environment. Use the same browser and operating-system image in local runs and CI where possible. Set a consistent viewport and control the clock if dates or relative-time labels appear.
  4. Remove motion noise. Disable animations in the test environment or wait until transitions finish. Animated loaders can otherwise produce inconsistent pixels.
  5. Choose the capture boundary. Capture the component when it is the subject; use full-page capture when page-level layout is part of the requirement.
  6. Review every baseline change. A diff can be an intentional redesign or a regression. Inspect it and approve a new baseline only when the visible change is expected.

Playwright supports screenshot assertions for pages and elements. Cypress documents screenshot capture, while comparison is provided by an integration. In either framework, keep the number of checkpoints focused on meaningful designs: every maintained baseline creates review work.

5. Pair visual checks with semantic and behavior assertions

A screenshot can show that an error banner looks correct, but it cannot prove the message is exposed to assistive technology, that a button works, or that a user can complete recovery. Alongside screenshots, assert accessible names and roles, keyboard-reachable actions, and the result of retry or recovery behavior. ARIA snapshot assertions can check accessible structure in Playwright; automated accessibility scans are useful checks but do not prove full accessibility or usability.

For example, after checking the error screenshot, click Try again, change the mocked response to success, and assert that the expected content appears. For an empty state, verify that its primary action leads to the right flow. For a loading state, verify both the pending indicator and the resolved result.

6. Troubleshooting

Symptom Likely cause Fix
Loading screenshot sometimes shows loaded content The response completed before capture Hold the intercepted request pending, assert the loading UI, capture, then fulfill it.
Screenshot captures a spinner or old content instead of empty state The test navigated before the request-driven UI updated Wait for the explicit empty-state heading or message before capture.
Error UI is not shown for a non-2xx response The app may parse only successful responses or the test used the wrong route/method Match the actual request precisely and assert the app’s error handling behavior before snapshotting.
Visual diff changes between identical runs Viewport, browser, fonts, time, data, animation, or environment differs Pin the environment and inputs, set viewport/time, and disable or settle motion.
Playwright reports a missing baseline No approved expected image exists for that test configuration Review the generated image, then create/update the baseline using Playwright’s snapshot update option in the same environment.
Cypress saves screenshots but reports no visual diff cy.screenshot() captures images; comparison requires another tool or integration Add a comparison integration or make capture review an explicit CI/review step.
Element screenshot has unexpected clipping The element has overflow constraints or is outside the intended viewport Check the component’s dimensions and overflow; use a page screenshot if surrounding layout is part of the checkpoint.

7. Performance, reliability, and maintenance

Network interception usually makes state setup faster and more predictable than waiting on live services. Keep fixtures small and avoid redundant screenshots of states whose appearance is already covered by a focused component test. A stable browser environment matters more than taking many screenshots: inconsistent fonts, timing, or viewport settings can create noisy diffs that cost time to review.

Visual tests are most reliable when they check one meaningful state at a time and pair pixels with assertions about what the UI means. If a remote service or live backend is used, outages and changing data can make the test flaky; stubbing the relevant outcome isolates the interface’s rendering. Treat snapshot updates as code review decisions, since automatically accepting every changed image can hide regressions.

Test cost is mainly the browser/CI runtime and the human time spent reviewing and maintaining baselines. Cypress’s screenshot command itself does not compare; comparison requires an integration. Playwright provides screenshot assertions in its test assertions. Neither pixel comparison nor automated accessibility scans replace checks of application-specific behavior and usability.

8. Or skip the browser setup

If you need a screenshot of a publicly reachable rendered page rather than a test that controls an in-app network response, ScreenshotNeo can return an image or PDF from one GET request. It is a screenshot API and MCP server for developers. This does not replace deterministic state setup inside your test suite, but it can skip maintaining a browser capture script for pages that are already reachable.

See the ScreenshotNeo API documentation. 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,
)
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 request failed: ${res.status}`);
await Bun.write('shot.webp', res);

For a repeatable visual regression test of your application’s loading, empty, or error UI, keep using the controlled-response browser workflow above. ScreenshotNeo can capture a URL, but the API call alone does not set up your app’s test data or force an internal request into a chosen state.

With ScreenshotNeo, cookie and consent banners are accepted and more than 60 known consent platforms, newsletter popups, and chat widgets are removed before capture; each step can be turned off. Bot checks, blank pages, failed loads, timeouts, and cache hits are not billed, and response headers say the page verdict and billing status. Its MCP server provides take_screenshot, get_page_info, and capture_pdf for Claude, Cursor, or another MCP client. The free plan includes 1,000 screenshots a month with no card; paid plans start at $5 for 3,000 screenshots.

Create a free ScreenshotNeo account for 1,000 screenshots a month with no card.

9. FAQ

Should I take the loading screenshot before or after the response?

Before it resolves, while the request is deliberately pending. Then release the response and assert the resolved state separately.

Can a screenshot prove that an error message is accessible?

No. Pair the image with role/name, keyboard, and recovery behavior assertions; pixels do not establish accessible semantics.

Does Cypress compare screenshots by itself?

No. Its built-in screenshot command captures images; use a comparison integration or review captures manually.

Should every state have a full-page baseline?

No. Capture the smallest area that proves the visual requirement, and use full-page images when overall layout is part of that requirement.

Sources