ScreenshotNeo

BlogEngineering

Headless Website Testing Automation

Learn how headless browser testing works, build reliable Playwright tests, run them in CI, debug failures, and choose the right automation tool.

By the ScreenshotNeo team1 October 20268 min read

Headless website testing runs a real browser engine without opening a visible window. The browser still loads HTML, executes JavaScript, applies CSS, sends network requests, and interacts with the page. The difference is that rendering happens without a graphical display, which makes headless mode practical for servers, containers, and continuous-integration (CI) pipelines.

For most new browser test suites, Playwright is a strong default because it supports Chromium, Firefox, WebKit, and branded Chrome or Edge channels, plus JavaScript/TypeScript, Python, Java, and .NET. It launches browsers headlessly by default. Selenium WebDriver, Puppeteer, and Cypress remain useful when their language support, protocol model, or test architecture matches your project.

What headless testing actually does

A headless test follows the same broad lifecycle as an interactive browser session:

  1. Start a browser process without a visible window.
  2. Create an isolated browser context and page.
  3. Navigate to the application under test.
  4. Wait for the required DOM state or network activity.
  5. Interact with controls such as links, forms, menus, and dialogs.
  6. Assert visible behavior, URLs, DOM state, accessibility state, or responses.
  7. Save evidence such as screenshots, video, console logs, HTML reports, and traces.

It is different from an HTTP-only check. An HTTP client can verify status codes and response bodies, but it cannot reliably exercise client-side routing, layout-dependent controls, browser storage, or JavaScript interactions.

Headless versus headed execution

Mode Best use Trade-off
Headless CI, containers, scheduled checks, parallel runs You cannot watch the browser directly while it runs
Headed Local debugging and exploratory work Requires a display and usually consumes more resources

Chrome documents headless execution for servers, containers, and CI pipelines. Playwright launches headless by default; pass headless: false when you need to see the browser locally.

Choosing a headless browser framework

Tool Best fit Key characteristics
Playwright Cross-browser end-to-end testing and automation Chromium, Firefox, WebKit, branded Chrome/Edge channels; JavaScript/TypeScript, Python, Java, and .NET; traces, screenshots, and HTML reports
Selenium WebDriver WebDriver-based desktop and mobile website automation Broad WebDriver ecosystem and remote-browser integrations
Puppeteer JavaScript automation focused on Chrome and Firefox High-level APIs over Chrome DevTools Protocol and WebDriver BiDi
Cypress End-to-end and component testing Test code runs in the same run loop as the application, unlike Selenium’s network-based remote commands

Compare frameworks on browser-engine coverage, language support, execution architecture, CI integration, parallelization, debugging artifacts, and how much control you need over browser contexts and network behavior. Do not choose solely because a tool can take a screenshot; the test runner, isolation model, and failure evidence determine maintenance cost.

Build a complete Playwright headless test

1. Create the project

mkdir headless-tests
cd headless-tests
npm init -y
npm install --save-dev @playwright/test
npx playwright install

For Linux CI machines, install browser binaries and operating-system dependencies together:

npx playwright install --with-deps

Each Playwright version expects compatible browser binaries. Keep the framework version and installed browsers under the same lockfile-controlled workflow. Headless-only jobs can use the smaller Chromium headless shell:

npx playwright install --with-deps --only-shell

Use branded channels only when the corresponding browser is installed and you specifically need to test it:

npx playwright test --project=chromium

2. Add a deterministic test

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

test('homepage exposes the primary navigation', async ({ page }) => {
  await page.goto('https://example.com', { waitUntil: 'domcontentloaded' });
  await expect(page).toHaveTitle(/Example Domain/);
  await expect(page.locator('h1')).toHaveText('Example Domain');
});

Save this as tests/home.spec.js. Prefer user-visible locators such as roles, labels, and stable test IDs over brittle CSS or generated class names.

3. Configure projects and artifacts

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

export default defineConfig({
  testDir: './tests',
  timeout: 30_000,
  expect: { timeout: 5_000 },
  fullyParallel: false,
  forbidOnly: !!process.env.CI,
  retries: process.env.CI ? 2 : 0,
  workers: process.env.CI ? 1 : undefined,
  reporter: [['html', { outputFolder: 'playwright-report', open: 'never' }]],
  use: {
    baseURL: 'https://example.com',
    headless: true,
    trace: 'on-first-retry',
    screenshot: 'only-on-failure',
    video: 'retain-on-failure',
  },
  projects: [
    { name: 'chromium', use: { ...devices['Desktop Chrome'] } },
    { name: 'firefox', use: { ...devices['Desktop Firefox'] } },
    { name: 'webkit', use: { ...devices['Desktop Safari'] } },
  ],
});

Run the suite and open its report:

npx playwright test
npx playwright show-report playwright-report

Waiting, isolation, and reliable assertions

Most flaky tests are synchronization or state-isolation problems. Playwright locators and assertions wait for expected conditions, so use them instead of arbitrary sleeps whenever possible.

test('checkout flow', async ({ page, context }) => {
  await context.addCookies([{ name: 'currency', value: 'USD', domain: 'example.com', path: '/' }]);
  await page.goto('/checkout');
  await page.getByRole('button', { name: 'Continue' }).click();
  await expect(page.getByRole('heading', { name: 'Payment' })).toBeVisible();
});
  • Use a fresh context per test when cookies, local storage, permissions, or authentication could leak between tests.
  • Wait for a meaningful selector, URL, response, or assertion rather than a fixed delay.
  • Use page.waitForResponse around a user action when a specific API response controls the next state.
  • Mock unstable third-party APIs with page.route when the integration itself is outside the test’s scope.
  • Set explicit timeouts for navigation and assertions so failures identify the operation that stalled.
const responsePromise = page.waitForResponse(response =>
  response.url().includes('/api/cart') && response.request().method() === 'POST'
);
await page.getByRole('button', { name: 'Add to cart' }).click();
const response = await responsePromise;
if (!response.ok()) throw new Error(`Cart request failed: ${response.status()}`);

Run Playwright in GitHub Actions

Playwright’s documented CI sequence is to install project packages, install browsers and operating-system dependencies, run tests, and publish reports or other artifacts.

name: browser-tests

on:
  push:
  pull_request:

jobs:
  test:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with:
          node-version: 20
          cache: npm
      - run: npm ci
      - run: npx playwright install --with-deps
      - run: npx playwright test
      - name: Upload Playwright report
        if: always()
        uses: actions/upload-artifact@v4
        with:
          name: playwright-report
          path: playwright-report/
      - name: Upload test results
        if: always()
        uses: actions/upload-artifact@v4
        with:
          name: test-results
          path: test-results/

Use one worker in CI by default for reproducibility. If your self-hosted runners have sufficient CPU and memory, increase workers after confirming that tests isolate their data. Sharding distributes tests across multiple jobs:

npx playwright test --shard=1/4
npx playwright test --shard=2/4
npx playwright test --shard=3/4
npx playwright test --shard=4/4

Browser-cache restoration is not always faster than downloading browsers, especially when Linux dependencies also need installation. Measure the complete job, and pin Node, Playwright, and browser versions through your lockfile and CI configuration.

Debug failed headless tests

Retain evidence so a failure can be investigated without immediately rerunning it. HTML reports summarize failures, screenshots show the final viewport, console and network logs reveal application errors, and traces provide a timeline with DOM snapshots, network requests, console information, and screenshots.

npx playwright test --trace on
npx playwright show-trace test-results/**/trace.zip

For browser-launch problems, enable Playwright’s browser debug logging:

DEBUG=pw:browser npx playwright test

To reproduce a CI-only failure locally, run the same browser project, viewport, environment variables, and test command. Run headed mode only as a debugging aid:

npx playwright test tests/home.spec.js --headed --project=chromium

Performance and scaling

  • Reuse setup carefully: authenticate once with a storage state when that state is safe to share, then create isolated contexts for tests.
  • Reduce unnecessary browser projects: run the full cross-browser matrix on pull requests or scheduled jobs, and a focused project on every commit when appropriate.
  • Control parallelism: more workers reduce wall-clock time only when runners have enough CPU, memory, and independent test data.
  • Shard large suites: distribute tests across CI jobs instead of making one worker handle the entire suite.
  • Limit evidence overhead: retain traces and video on failure or retry, while keeping screenshots for failures.
  • Keep dependencies deterministic: pinned browser binaries avoid unexpected rendering and timing changes.

Headless mode removes the display requirement; it does not make a slow page fast. Network latency, third-party scripts, expensive client-side rendering, and test data setup still affect runtime.

Common errors and fixes

Error or symptom Likely cause Fix
Executable doesn't exist Browser binaries were not installed for the current Playwright version Run npx playwright install, or npx playwright install --with-deps on Linux CI
Browser fails to launch in Linux Missing system libraries, sandbox restrictions, or an incompatible container Use the official CI image or install dependencies with --with-deps; inspect DEBUG=pw:browser output
Test times out waiting for a locator Wrong locator, a navigation race, blocked request, or page state not reached Inspect the trace and console; use a stable role or test ID and wait for the relevant response or URL
Works headed but fails headless Timing sensitivity, viewport assumptions, missing fonts, or a headless environment difference Remove fixed sleeps, set the viewport explicitly, install required fonts, and compare traces
Tests pass alone but fail in the suite Shared cookies, database records, ports, or files Use isolated contexts and unique test data; avoid order-dependent assertions
Flaky third-party widget External network or service behavior is outside your control Stub it with routing for product tests, or move it to a separate integration check
CI job is too slow Too many browser projects, serial setup, or insufficient workers Profile setup, use appropriate workers, shard the suite, and retain only failure artifacts
Report is missing after a failure Artifact upload step was skipped when the test command failed Set artifact steps to if: always() and upload the report and test-results directories

Security and environment controls

  • Store credentials in CI secrets, never in test files or reports.
  • Use test accounts with the minimum permissions required.
  • Redact tokens from console output, traces, URLs, and screenshots.
  • Control timezone, locale, geolocation, and user agent when behavior depends on them.
  • Keep production data out of destructive end-to-end tests unless the environment is explicitly isolated.

Or skip the browser setup

If your goal is a clean screenshot rather than an interactive assertion, ScreenshotNeo provides a website screenshot API and MCP server. One GET request returns PNG, JPEG, WebP, or PDF. It accepts cookie and consent banners like a visitor, removes more than 60 known consent platforms plus newsletter popups and chat widgets, and lets you turn each cleanup step off. Bot checks or CAPTCHAs, blank pages, timeouts, failed loads, and cache hits are not billed; response headers identify the page verdict and whether the request was billed.

API documentation: ScreenshotNeo docs.

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}`);

ScreenshotNeo also supports full-page captures with lazy images loaded, CSS-selector element captures, dark mode, 12 device presets and custom viewports, retina scale, PDF paper and margin controls, custom CSS and JavaScript, clicks, selector or network-idle waits, ad/tracker/request blocking, custom headers and cookies, timezone and geolocation, transparent backgrounds, resizing, configurable caching, signed image links, asynchronous jobs with signed webhooks, bulk capture of up to 100 URLs per call, usage reporting, and an OpenAPI specification. An MCP server exposes take_screenshot, get_page_info, and capture_pdf to Claude, Cursor, and other MCP clients.

The Free plan includes 1,000 screenshots each month with no card. Paid plans start at $5 for 3,000 shots; every feature is available on every plan. Create a free ScreenshotNeo account.

FAQ

Does headless mode test the same browser as a user sees?

It runs a real browser engine, but the exact browser channel, version, fonts, viewport, GPU behavior, and operating system can affect rendering. Pin those variables when visual fidelity matters.

Should every test run in every browser?

No. Select a browser matrix based on your users and risk. Run broader cross-browser coverage on pull requests or scheduled jobs, and a smaller smoke suite on every change if that keeps feedback fast.

When should I use a screenshot instead of an end-to-end test?

Use end-to-end tests for behavior and assertions. Use screenshots for visual evidence, documentation, previews, or monitoring. A screenshot alone does not prove that a workflow is correct.

Why are retries not a reliability strategy?

A retry can expose intermittent failures, but it can also hide deterministic defects. Retain the first failure’s trace and fix synchronization, isolation, or environment problems before increasing retries.