ScreenshotNeo

BlogHow-to

How to Test Web Pages with Dynamic Content

Test dynamic pages reliably by controlling data, waiting for user-visible outcomes, and covering loading, interaction, hydration, and visual states.

By the ScreenshotNeo team4 October 202610 min read

To test a web page with dynamic content, drive it through a realistic user action, wait for the rendered outcome that matters, and assert that outcome. Make the data and browser context reproducible. Add screenshot comparisons for visual regressions when appearance matters, with a deliberate plan for expected changes such as timestamps or rotating content.

This guide uses Playwright with TypeScript for runnable browser tests. The same approach applies to pages updated by API responses, JavaScript hydration, user actions, timers, permissions, and responsive layouts.

1. Define what the user should see

Start with the observable contract for each scenario. For example: after selecting a filter, the result count changes; after submitting a valid form, a confirmation appears; when a request fails, an error message is shown. Prefer roles, accessible names, visible text, and user-observable state over CSS classes or internal function calls. Playwright’s best practices recommend testing user-visible behavior and using web-first assertions that retry until the expected condition appears.

Separate behavior coverage from visual coverage. A functional assertion can establish that filtering updates the results, but does not establish that the updated layout looks right. A screenshot comparison can catch a layout change, but does not prove that the filter works.

2. Set up a Playwright test

Install Playwright and its Chromium browser in a Node.js project:

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',
  fullyParallel: true,
  retries: process.env.CI ? 2 : 0,
  use: {
    baseURL: 'http://127.0.0.1:3000',
    browserName: 'chromium',
    headless: true,
    viewport: { width: 1280, height: 800 },
    trace: 'on-first-retry',
  },
  webServer: {
    command: 'npm run dev',
    url: 'http://127.0.0.1:3000',
    reuseExistingServer: !process.env.CI,
  },
});

Adjust the command, URL, and viewport for your application. Add a test script to package.json:

{
  "scripts": {
    "test:e2e": "playwright test"
  }
}

3. Control the data and test context

Dynamic tests become flaky when they depend on changing server data, third-party availability, or state left behind by another test. Use a known response for each scenario and isolate browser state. Playwright creates an isolated browser context for each test by default. Its network documentation covers monitoring, intercepting, modifying, and mocking requests, including fetch and XHR.

The following example assumes the page has a search box with accessible name “Search products,” a button named “Search,” a result count, and a result list. Change the route and accessible names to match your app.

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

test('shows matching products after a search', async ({ page }) => {
  await page.route('**/api/products?**', async route => {
    await route.fulfill({
      status: 200,
      contentType: 'application/json',
      body: JSON.stringify({
        total: 1,
        items: [{ id: 'p-1', name: 'Blue running shoes' }],
      }),
    });
  });

  await page.goto('/products');
  await page.getByRole('textbox', { name: 'Search products' }).fill('running');
  await page.getByRole('button', { name: 'Search' }).click();

  await expect(page.getByText('1 result')).toBeVisible();
  await expect(page.getByRole('link', { name: 'Blue running shoes' })).toBeVisible();
});

Mock at the network boundary when the test is about your page’s handling of a response. Keep separate scenarios for populated success, empty results, server errors, and other states that matter. Avoid making your product test depend on an uncontrolled external service; stub that service’s response instead.

4. Wait for the condition that matters

After an action, assert the visible result with a retrying assertion such as toBeVisible(), toHaveText(), or toHaveURL(). Playwright retries web-first assertions until the condition succeeds or the timeout expires. This is more reliable than checking once immediately after a click.

await page.getByRole('button', { name: 'Apply filters' }).click();
await expect(page.getByRole('status')).toHaveText('12 products found');

A fixed sleep such as waitForTimeout(2000) should not be the normal readiness signal: it can waste time on fast runs and still fail on slow ones. Use a delay only when the duration itself is the behavior under test. You can await a particular response when the response is part of the scenario, but still assert the user-visible result:

const responsePromise = page.waitForResponse(response =>
  response.url().includes('/api/products') && response.request().method() === 'GET'
);
await page.getByRole('button', { name: 'Search' }).click();
const response = await responsePromise;
expect(response.ok()).toBeTruthy();
await expect(page.getByRole('list')).toContainText('Blue running shoes');

Do not treat generic network-idle as a universal ready signal. Pages can keep background connections open, and Playwright’s Page API discourages network-idle waiting for tests. Prefer the specific response or rendered state needed by the scenario.

5. Cover loading, empty, success, and error states

Build a small scenario matrix based on the page’s user-visible contract. Not every page needs every row, but important outcomes should be explicit.

Scenario Arrange Assert
Loading Hold the API response until the loading state appears Spinner or status is visible; controls have the expected state
Success Return a known populated response Expected data and count appear
Empty Return an empty response Empty-state message appears; stale results are absent
Error Return an error status or fail the request Useful error message and recovery action appear
Interaction Use a known starting state Filter, menu, form, or pagination changes the expected outcome

For a loading-state test, deliberately delay the mocked response rather than sleeping after the action:

test('shows a loading state while results are pending', async ({ page }) => {
  let releaseResponse!: () => void;
  const responseGate = new Promise<void>(resolve => { releaseResponse = resolve; });

  await page.route('**/api/products**', async route => {
    await responseGate;
    await route.fulfill({
      contentType: 'application/json',
      body: JSON.stringify({ total: 0, items: [] }),
    });
  });

  await page.goto('/products');
  await page.getByRole('button', { name: 'Search' }).click();
  await expect(page.getByRole('status')).toHaveText('Loading');
  releaseResponse();
  await expect(page.getByRole('status')).toHaveText('No products found');
});

If your app fetches immediately on page load, the route must be installed before navigation, as in this example. If the route is too broad, narrow its URL or method match so unrelated requests continue normally.

6. Test hydration and overlays

Hydration is the process of attaching client-side behavior to HTML that may already be visible. A control can appear on screen before its click handler is ready. To investigate this race, throttle the connection in Chrome DevTools, for example with Slow 3G, and try the action as soon as the control appears. Playwright’s navigation guidance describes the problem and recommends keeping interactive controls disabled until hydration finishes.

Test the user-visible contract around hydration: a control should not appear ready when it cannot yet work, and after it becomes enabled its action should produce the expected result. Avoid making a test depend on an arbitrary delay as a proxy for hydration.

If a predictable dialog blocks the flow, handle it as part of that flow: assert that it appears, accept or dismiss it, then continue. Playwright supports locator handlers for intermittent overlays, but those handlers can alter page state during an action; use them only when the overlay is genuinely incidental to the scenario.

7. Add visual regression coverage where it helps

Use screenshots for risks such as layout, responsive behavior, CSS changes, and selected cross-browser differences. Playwright’s toHaveScreenshot() can establish a baseline and compare later captures:

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

test('product results layout matches its baseline', async ({ page }) => {
  await page.route('**/api/products**', route => route.fulfill({
    contentType: 'application/json',
    body: JSON.stringify({ total: 1, items: [{ id: 'p-1', name: 'Blue running shoes' }] }),
  }));

  await page.goto('/products');
  await expect(page.getByRole('list')).toBeVisible();
  await expect(page).toHaveScreenshot('product-results.png', { fullPage: true });
});

Run the test once to create the baseline, review the image, and commit it. On later runs, a pixel difference can fail the comparison. Keep the browser, operating system, viewport, fonts, and test data consistent with the baseline environment. Dynamic content such as clocks, ads, carousels, random identifiers, and rotating banners can create noise. Stabilize those inputs or mask only a small, known variable region. Masking a large or important region can hide the regression you meant to catch. BrowserStack’s Percy visual testing documentation also discusses filtering dynamic elements.

Choose a few representative states and viewport sizes rather than screenshotting every possible combination. Functional tests should continue to cover interaction logic, including states that are not useful as visual baselines.

8. Check viewport and browser-dependent behavior

When content changes with viewport size, test the breakpoints users depend on with explicit viewport settings. For device emulation, Playwright provides device descriptors:

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

test.use({ ...devices['iPhone 13'] });

test('mobile navigation opens', async ({ page }) => {
  await page.goto('/');
  await page.getByRole('button', { name: 'Open menu' }).click();
  await expect(page.getByRole('navigation', { name: 'Main' })).toBeVisible();
});

Use the browser engines and operating systems your product supports. For screenshot baselines, keep the comparison environment consistent: small differences in fonts or rendering can otherwise create noisy diffs. Test browser-specific behavior separately when it changes the user-visible contract.

9. Common failures and fixes

Symptom Likely cause Fix
Assertion fails immediately after click The page updates asynchronously and the check does not retry Use a web-first assertion on the expected visible result
Test passes locally but fails in CI Uncontrolled data, different browser or OS, leftover state, or slow external dependency Mock responses, isolate context and data, and align browser and OS for visual tests
Click succeeds but nothing happens Hydration has not attached the event handler, or an overlay intercepts input Ensure controls are disabled until ready; explicitly handle predictable overlays
Network-idle wait times out Background connections or polling keep the page active Wait for a specific response or the rendered state instead
Snapshot diffs on every run Unstable content, animation, fonts, viewport, or environment Stabilize fixtures and environment; mask only narrow known dynamic regions
Mock never matches Route pattern, query string, method, or registration timing is wrong Install route before navigation and inspect the actual request URL and method
Tests affect each other Shared cookies, local storage, mutable records, or test order assumptions Use isolated contexts and independent data; reset state at the start of each test
Selector breaks after a redesign Test is coupled to CSS classes or DOM nesting Prefer roles, accessible names, labels, and user-visible text

10. Performance, reliability, and cost

Keep the test suite fast by mocking external dependencies, testing focused scenarios, and capturing only useful visual states. A full browser test has setup and rendering cost, so reserve it for behavior that needs a real browser; use lower-level tests for logic that does not. Retries can help collect evidence about intermittent failures, but they should not conceal a reproducible bug. Preserve traces on failure or retry to inspect the action sequence and network behavior.

Reliability comes from explicit state, independent tests, stable fixtures, and assertions that match what a user sees. Visual baselines also have maintenance cost: review diffs and update a baseline only when the changed appearance is intended. The reviewed sources do not establish a neutral price comparison for visual testing services, so evaluate any hosted service against your own capture volume and workflow.

11. Browser-based do-it-yourself screenshot capture

For visual checks, Playwright can save the rendered page directly. This runnable Node.js example captures a full-page PNG after a user-visible condition is met:

import { chromium } from 'playwright';

const browser = await chromium.launch({ headless: true });
const page = await browser.newPage({ viewport: { width: 1280, height: 800 } });

await page.goto('https://example.com');
await page.getByRole('heading', { name: 'Example Domain' }).waitFor();
await page.screenshot({ path: 'page.png', fullPage: true });

await browser.close();

Install the browser package with npm install playwright and install its browser with npx playwright install chromium. For repeatable application tests, use controlled data as above. For a screenshot of an external page, its content may change independently, and a visible heading alone may not mean every lazy-loaded section has finished rendering.

Or skip the browser setup

ScreenshotNeo is a website screenshot API and MCP server. One GET request captures a URL as an image or PDF; the API accepts many parameter names used by other screenshot APIs. See the ScreenshotNeo documentation for the available options.

curl -G "https://api.screenshotneo.com/v1/shot" -d access_key=YOUR_API_KEY --data-urlencode url=https://stripe.com -o shot.webp
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)
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 import('node:fs/promises').then(fs => fs.writeFile('shot.webp', Buffer.from(await res.arrayBuffer())));

Cookie banners, newsletter popups, and chat widgets are removed before the shot. Bot checks, blank pages, failed loads, timeouts, and cache hits are never billed; response headers report the page verdict and billing status. Its MCP server gives AI agents tools to take screenshots, get page information, and capture PDFs. The free plan includes 1,000 screenshots a month with no card; paid plans start at $5 for 3,000.

Sign up for ScreenshotNeo and get 1,000 screenshots a month free, with no card.

Frequently asked questions

Should I mock API responses or test against a real backend?

Mock responses for stable scenarios that verify page behavior. Add a smaller number of integration or end-to-end checks against the real backend when the connection between services is itself part of the risk.

Do screenshot tests replace functional tests?

No. Screenshots compare appearance. They do not establish that an interaction, validation rule, or data update works correctly.

How many visual states should I capture?

Capture states tied to meaningful visual risks, such as a key success state and important responsive layouts. Keep the set small enough that reviewers can assess each changed baseline.

Can a screenshot prove dynamic content loaded?

A screenshot records what rendered at capture time. First wait for a meaningful application condition, then capture; the image itself does not prove that all intended data or interactions are ready.

Sources