The npm jest-environment-jsdom package gives Jest a simulated browser DOM, and since Jest 28 you must install it explicitly — it no longer ships inside Jest itself. That 2022 packaging change still generates a steady stream of document is not defined failures in upgraded projects, and it also made a meaningful dependency tree (jsdom and everything under it) an opt-in line in your manifest. This guide covers correct setup, the gotchas that consume debugging afternoons, and how to think about the security surface of a fake browser living in your test runner.
Installation and wiring
Since Jest 28, the default test environment is node. Anything touching document, window, or DOM APIs needs the jsdom environment installed and selected:
npm install --save-dev jest-environment-jsdom
// jest.config.js — project-wide
module.exports = {
testEnvironment: 'jsdom',
};
Or per-file, which is the better pattern for mixed codebases where most tests are pure logic:
/**
* @jest-environment jsdom
*/
test('renders the banner', () => {
document.body.innerHTML = '<div id="root"></div>';
// ...
});
Per-file docblocks keep the majority of your suite on the faster node environment; jsdom setup and teardown per test file is not free, and large suites feel the difference.
Version alignment matters here just as with the rest of the jest npm family: jest-environment-jsdom is released in lockstep with Jest, so match majors (jest@29 with jest-environment-jsdom@29) and let the lockfile handle the rest. Mismatches produce subtle failures — environment API drift rather than clean errors.
The gotchas that actually cost time
document is not defined after a Jest upgrade. The classic post-Jest-28 symptom: the project relied on the old bundled default. Install the package and set testEnvironment.
jsdom is not a browser. It implements the DOM and HTML spec surface, not a rendering engine. There is no layout (offsetWidth is 0), no navigation, no real network stack by default. Tests asserting on geometry or full page lifecycle belong in Playwright or similar, not in jest environment jsdom setups.
Missing modern APIs. fetch, TextEncoder, matchMedia, IntersectionObserver, and friends are absent or partial depending on the jsdom version underneath. The standard fix is a setup file with explicit, minimal polyfills:
// jest.setup.js
Object.defineProperty(window, 'matchMedia', {
writable: true,
value: (query) => ({
matches: false, media: query,
addEventListener: () => {}, removeEventListener: () => {},
}),
});
Resist the urge to install kitchen-sink polyfill packages for this; every one is another dependency executing in CI, and a ten-line stub you own is cheaper than a package you have to track.
Timer and cleanup leaks. jsdom environments keep the window alive per test file; intervals and event listeners that outlive a test bleed into the next one. jest.useFakeTimers() and disciplined cleanup in afterEach prevent the flaky-suite spiral.
What you are actually adding to the tree
Installing this environment pulls in jsdom, which is a substantial project: an HTML parser, CSS-selector engine, and a large slice of the web platform implemented in JavaScript, with its own transitive dependencies for parsing, URLs, and encoding. A few supply chain observations follow.
It executes untrusted-looking input by design. jsdom parses whatever HTML your tests feed it, and if you enable script execution (runScripts: 'dangerously'), it will execute embedded JavaScript inside your test process — with full Node privileges, because jsdom's sandbox is not a security boundary and its own documentation says so. Never point a jsdom environment at genuinely untrusted HTML with scripts enabled; that pattern belongs in an isolated scraper service, not a test runner.
It runs where credentials live. Like every dev dependency, the environment tree executes on developer machines and CI runners holding tokens. The hardening playbook is the same one we outline in the babel-jest toolchain guide: frozen installs (npm ci), an update cooldown for toolchain packages, scoped CI credentials, and scanning that does not exempt dev scope.
Advisories arrive via the tree, not the package. Historically, audit findings in this corner of the ecosystem surface in jsdom's transitive parsers and utilities rather than in jest-environment-jsdom itself. That means manifest-level review misses them; you need lockfile-resolution scanning. This is bread-and-butter SCA work — a scanner such as Safeguard reads the resolved versions across every repo's lockfile and flags the vulnerable leaf, wherever it sits in the graph.
Configuration surface worth reviewing
The environment forwards options to jsdom through testEnvironmentOptions:
module.exports = {
testEnvironment: 'jsdom',
testEnvironmentOptions: {
url: 'https://app.example.test/', // sets window.location
// runScripts intentionally NOT set to 'dangerously'
},
};
Three review points:
runScripts: 'dangerously'— grep your repo for it. Legitimate uses exist (testing script-injection behavior), but each one deserves a comment explaining why and a guarantee the HTML under test is authored, not fetched.url— code that branches on origin (cookie logic, postMessage targets) behaves differently under the defaulthttp://localhost/; set a realistic origin so security-relevant branches are actually exercised.- Custom environments extending this one — teams sometimes subclass the environment to inject globals. Those subclasses are toolchain code with full process access; review them like source.
Testing security-relevant frontend behavior
A jsdom environment is genuinely useful for security unit tests, within its limits. Good candidates: DOM-based sanitizer behavior (asserting your encoder neutralizes <img onerror> patterns), clipboard and storage handling, CSP-independent logic like token storage choices. Poor candidates: anything depending on real browser security machinery — actual CSP enforcement, cookie SameSite behavior, frame isolation — because jsdom does not implement those boundaries. For runtime verification of the deployed app, that is DAST territory; the unit environment complements it rather than replacing it.
FAQ
Why does Jest say jest-environment-jsdom cannot be found?
Since Jest 28, the jsdom environment is a separate package. Install jest-environment-jsdom as a devDependency and set testEnvironment: 'jsdom' (or use per-file docblocks). Keep its major version matched to Jest's.
Should every test run in the jsdom environment?
No. jsdom setup has real overhead and most non-UI code needs no DOM. Default the project to node and opt individual DOM-touching files in with the @jest-environment jsdom docblock.
Is jsdom a security sandbox?
No. jsdom explicitly does not provide an isolation boundary; with script execution enabled, parsed HTML runs code in your Node process. Treat HTML fed to jsdom as code, and never enable runScripts: 'dangerously' against content you did not author.
Do I need to security-scan test-only packages like this?
Yes. The environment and its jsdom tree execute on CI runners that hold credentials. Scan devDependencies with lockfile resolution, and apply update cooldowns to toolchain packages just as you would for production dependencies.