ScreenshotNeo

BlogHow-to

Headless Website Testing with Jest

Learn when Jest needs jsdom or a real browser, configure Puppeteer for CI, fix failures, and capture pages without browser setup.

By the ScreenshotNeo team1 October 20266 min read

Short answer: Jest does not launch a browser by default. Its default node environment runs JavaScript without browser globals. Select jsdom when you need a browser-like DOM for component and integration tests; use Puppeteer connected to Jest when the test must observe real navigation, layout, painting, browser behavior, or user interaction.

This distinction determines your setup, runtime, and what a passing test proves.

1. Choose the right environment

Question Use Proves Does not prove
Does this component update the DOM? Jest + jsdom Application logic and browser API calls against an emulated DOM Pixels, layout, fonts, painting, or browser-specific rendering
Does a route load, redirect, or submit in Chromium? Jest + Puppeteer Behavior in an actual browser page Full Jest coverage for code executed through page.evaluate, page.$eval, or page.$$eval
Do I need browser-oriented CI? Puppeteer or Playwright Real browser automation A universal speed or stability winner

Jest documents node as the default and supports global or per-file environments. See the Jest environment documentation.

2. Configure Jest with jsdom

npm install --save-dev jest jest-environment-jsdom
{"scripts":{"test":"jest"},"jest":{"testEnvironment":"jsdom","testMatch":["**/*.test.js"]}}

Jest 28 and later require jest-environment-jsdom as a separate package.

// src/greeting.js
export function mountGreeting(root, name) {
  const heading = document.createElement('h1');
  heading.textContent = `Hello, ${name}`;
  root.replaceChildren(heading);
}

// src/greeting.test.js
/** @jest-environment jsdom */
import { mountGreeting } from './greeting.js';

test('writes a greeting', () => {
  document.body.innerHTML = '<main id="app"></main>';
  mountGreeting(document.querySelector('#app'), 'Ada');
  expect(document.querySelector('h1')).toHaveTextContent('Hello, Ada');
});

Use a docblock when most tests should remain in Node:

/** @jest-environment jsdom */
test('has browser globals', () => {
  expect(window).toBeDefined();
  expect(document).toBeDefined();
});

3. Configure jsdom options

Set URL and user agent through testEnvironmentOptions. These affect window.location, relative URLs, and code that reads the user agent. See the Jest configuration reference.

// jest.config.js
module.exports = {
  testEnvironment: 'jsdom',
  testEnvironmentOptions: {
    url: 'https://example.test/account/settings',
    userAgent: 'jest-jsdom-test'
  }
};

Per-file options are also supported:

/**
 * @jest-environment jsdom
 * @jest-environment-options {"url":"https://shop.test/cart","userAgent":"checkout-test"}
 */
test('resolves a relative URL', () => {
  expect(new URL('/app.js', document.baseURI).href)
    .toBe('https://shop.test/app.js');
});

jsdom can expose visibility and animation-frame APIs with pretendToBeVisual, but it still does not render visual content or implement layout. The jsdom documentation describes this limitation.

4. Test application logic without a browser

Mock network and time at the boundary, then assert observable DOM state.

/** @jest-environment jsdom */
import { loadProfile } from './profile.js';

beforeEach(() => {
  global.fetch = jest.fn().mockResolvedValue({
    ok: true,
    json: async () => ({ name: 'Grace' })
  });
});
afterEach(() => jest.restoreAllMocks());

test('renders the fetched profile', async () => {
  document.body.innerHTML = '<div id="profile"></div>';
  await loadProfile(document.querySelector('#profile'));
  expect(fetch).toHaveBeenCalledWith('/api/profile');
  expect(document.querySelector('#profile')).toHaveTextContent('Grace');
});

This verifies your code’s decisions. It does not validate server behavior, CSS, browser navigation, or third-party scripts.

5. Run a real browser from Jest with Puppeteer

Jest documents a jest-puppeteer preset and a custom global setup, environment, and teardown pattern. The custom arrangement makes lifecycle responsibilities explicit.

npm install --save-dev jest puppeteer jest-environment-puppeteer
// jest.config.js
module.exports = {
  testEnvironment: './puppeteer-environment.js',
  globalSetup: './puppeteer-setup.js',
  globalTeardown: './puppeteer-teardown.js',
  testTimeout: 30000
};

// puppeteer-setup.js
const puppeteer = require('puppeteer');
module.exports = async function globalSetup() {
  global.__BROWSER__ = await puppeteer.launch({
    headless: true,
    args: process.env.CI ? ['--no-sandbox', '--disable-setuid-sandbox'] : []
  });
};

// puppeteer-environment.js
const NodeEnvironment = require('jest-environment-node').TestEnvironment;
module.exports = class PuppeteerEnvironment extends NodeEnvironment {
  async setup() {
    await super.setup();
    this.global.browser = global.__BROWSER__;
    this.global.page = await global.__BROWSER__.newPage();
  }
  async teardown() {
    await this.global.page?.close();
    await super.teardown();
  }
};

// puppeteer-teardown.js
module.exports = async function globalTeardown() {
  await global.__BROWSER__?.close();
};

In production, pass the browser endpoint between global setup and the environment using the mechanism recommended by Jest’s guide, such as a temporary file. Process globals are not reliably shared between Jest workers.

// homepage.e2e.test.js
/** @jest-environment ./puppeteer-environment.js */
test('finds the primary heading', async () => {
  await page.goto('https://example.com', { waitUntil: 'networkidle0' });
  await page.waitForSelector('h1');
  await expect(page.$eval('h1', el => el.textContent.trim())).resolves
    .toBe('Example Domain');
});

Use an explicit base URL, isolate pages, and close them in teardown. Read the Jest Puppeteer integration guide and verify package versions before upgrades.

6. CI setup

  1. Install the browser during image build or CI setup and cache the binary.
  2. Print the browser version when a test fails.
  3. Set finite navigation and Jest timeouts.
  4. Collect a screenshot, HTML, console errors, and failed requests.
  5. Limit Jest workers if browser processes exhaust memory.
  6. Use Linux sandbox flags only when your runner requires them.

Playwright documents installing a headless shell when CI needs only that shell. This is an installation option, not evidence of a universal performance winner. See its browser documentation.

7. Troubleshooting

Symptom Cause Fix
document is not defined Node environment is active. Set jsdom globally or add the docblock.
jsdom environment cannot be found Package is not installed. Install a compatible jest-environment-jsdom version.
Layout values are zero jsdom has no visual layout engine. Move the assertion to Puppeteer.
Wrong origin or relative URL Default jsdom URL is being used. Set testEnvironmentOptions.url.
Browser executable missing CI did not install the browser. Install and cache it explicitly; print its path and version.
Navigation hangs Requests or service workers never settle. Use explicit wait conditions and finite timeouts; log pending requests.
Worker-only failures Shared page state or excessive parallelism. Create pages per suite, clear storage, and reduce workers.
Coverage drops Page evaluation runs outside Jest instrumentation. Keep business logic in importable modules and test it separately.

8. Performance, reliability, and cost

  • Use the cheapest faithful environment. jsdom is suitable for DOM logic; reserve Chromium for browser behavior and rendering.
  • Control isolation. Reset DOM, cookies, storage, service workers, and mocks between tests.
  • Make waits explicit. Prefer selectors or application-ready signals over arbitrary sleeps.
  • Retry selectively. Retry infrastructure failures, not deterministic assertion failures.
  • Measure CI resources. Real browsers consume more CPU and memory than jsdom.
  • Budget costs. Account for CI minutes, browser downloads, and maintenance. The supplied sources do not establish universal hosted-browser pricing or benchmarks.

9. Or skip the browser setup

For a screenshot or PDF of a URL, ScreenshotNeo provides a single GET request. It supports PNG, JPEG, WebP, and PDF output, full-page capture, device presets or custom viewports, retina scale, CSS selectors, dark mode, custom CSS and JavaScript, waits, blocking rules, headers, cookies, geolocation, resizing, caching, signed links, async jobs, bulk capture, and usage reporting. See the ScreenshotNeo API docs.

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

Cookie banners, newsletter popups, and chat widgets are removed before capture. Bot checks, blank pages, timeouts, failed loads, and cache hits are not billed; response headers report the page verdict and billing result. Its 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 per month with no card; paid plans start at $5 for 3,000. Create a free ScreenshotNeo account.

10. FAQ

Does Jest use a browser?

Not by default. Node is the default, jsdom emulates browser APIs, and Puppeteer connects Jest to an actual browser.

Can jsdom verify CSS?

No. Use a real browser for layout, computed geometry, screenshots, and visual behavior.

Can jsdom and Puppeteer suites coexist?

Yes. Select environments globally or per file and isolate browser lifecycle setup.

When should I use Playwright?

Use it when its browser automation and CI installation model fit your project. Evaluate other trade-offs with your own tests.

How do I keep browser tests maintainable?

Keep business rules in modules tested under jsdom, expose stable selectors or readiness signals, and keep a small end-to-end suite for browser integration.