happy-dom
happy-dom is a browser environment implemented in JavaScript, meant to run inside Node. It gives you document, window, Element, CustomEvent, MutationObserver, custom elements, shadow DOM, Fetch, and several hundred other web APIs, all in plain JavaScript objects with no browser process behind them. The point is testing: a component test can mount markup, click a button, and read the resulting DOM without launching Chrome. It is the same job jsdom does, and it exists because it aims to be faster and to cover newer APIs sooner. You use it either through your test runner (Vitest takes environment: 'happy-dom', Jest takes @happy-dom/jest-environment, Bun's test runner uses it internally) or directly by constructing a Window yourself. There is also a Browser class that models pages and frames if you need more than one document at a time.
As a faster drop-in for jsdom in Vitest and Jest, happy-dom does the job and the speed difference is real on a large suite. Treat it as a fast approximation, not a browser: no layout, a busy issue tracker, and a major version every few months are the price you pay for that speed.
Use it if
- You run component tests with Vitest and want DOM tests that start in milliseconds; switching environment from jsdom to happy-dom is a one-line config change and usually the cheapest test speed win available
- You test web components. Custom elements, declarative shadow DOM, and slot behaviour are first-class here and have historically landed sooner than in jsdom
- You need to parse or manipulate HTML server side, for example scraping, email template processing, or server rendering checks, and you want DOM methods rather than a string-matching parser
- You are on Bun, which already ships happy-dom paths internally, so matching it in your own tooling avoids two different DOM implementations disagreeing
- You want Fetch, Headers, Request, and Response inside the test environment without pulling a separate polyfill, plus a fetch interceptor and virtual servers for stubbing requests
- Your test depends on layout. There is no rendering engine, so getBoundingClientRect returns zeros, offsetWidth and offsetHeight are zero, scroll positions do not move, and anything measuring or positioning elements has to be mocked. That limitation is permanent, not a missing feature
- You need browser truth. Visual regressions, real event timing, CSS behaviour, and cross-browser bugs need Playwright, Cypress, or Vitest browser mode. A passing happy-dom suite is evidence that your logic works, not that your page works
- You need spec coverage over speed. 289 issues are open (365 counting PRs) against a 4.6k-star project, and most of them are behaviours that differ from a real browser. jsdom is older, slower, and has had more of those corners found and fixed
- You cannot absorb frequent major versions. Nine majors shipped between September 2023 and October 2025, and they routinely change enough DOM behaviour to break a test suite that was passing an hour earlier
- You are on CommonJS or Node 18. The package is type: module with no CommonJS build and declares engines node >=20, so require('happy-dom') does not work on older runtimes
- You want to execute page scripts safely. JavaScript evaluation is off by default, and when you turn it on the library itself prints a warning that a VM context is not an isolated environment and untrusted code can escape to process level. Parsing untrusted HTML is a security decision, not a config flag
Setup reality
For Vitest, npm install -D happy-dom and set test.environment to 'happy-dom' in vite.config; that is genuinely the whole setup. For Jest you also need @happy-dom/jest-environment, and for a global document outside a test runner you need @happy-dom/global-registrator, which are separate packages versioned in lockstep with the main one. The rest of the friction is in what the defaults actually do. The package is ESM only, type: module, no CommonJS entry, and engines says node >=20, so a CJS test setup file fails at import. It ships @types/node, @types/ws, and @types/whatwg-mimetype as regular dependencies rather than dev or peer ones, which means those type packages land in installs that did not ask for them, alongside ws, entities, whatwg-mimetype, and buffer-image-size. Defaults worth knowing before you debug for an hour: script evaluation is disabled unless you set enableJavaScriptEvaluation, image loading is off, but external CSS and script file loading are on, so a document with a link tag will try to make a real network request during your test. The viewport is 1024 by 768 and does not change unless you call setViewport. Async work is the other common trap: fetches, timers, and microtasks queued by a component are not awaited by your assertion, so you call await window.happyDOM.waitUntilComplete() before asserting and await window.close() afterwards, or the process hangs on open handles. Finally, canvas rendering needs the optional @happy-dom/node-canvas-adapter package; without it canvas contexts exist but draw nothing.
Patterns
Use it as the Vitest DOM environmentvitest-environment
// vite.config.ts
import { defineConfig } from 'vite'
export default defineConfig({
test: {
environment: 'happy-dom',
environmentOptions: {
happyDOM: {
url: 'https://example.com/',
settings: { navigator: { userAgent: 'test-agent' } },
},
},
},
})Vitest resolves the environment by name, so happy-dom only needs to be installed, not imported. Set url here rather than in each test; the default about:blank breaks anything that reads location or resolves a relative fetch.
Switch environment for one test fileper-file-environment
// @vitest-environment happy-dom
import { expect, test } from 'vitest'
test('renders', () => {
document.body.innerHTML = '<button id="go">Go</button>'
expect(document.querySelector('#go')?.textContent).toBe('Go')
})The docblock comment must be the first thing in the file, before imports. Useful when most of your suite is node environment and only a handful of files touch the DOM, which keeps the rest of the run faster.
Create a document without a test runnerstandalone-window
import { Window } from 'happy-dom'
const window = new Window({
url: 'https://example.com/',
width: 1280,
height: 720,
})
const document = window.document
document.body.innerHTML = '<div class="card">hello</div>'
console.log(document.querySelector('.card')?.textContent)
await window.happyDOM.waitUntilComplete()
await window.close()Always close the window. It holds timers, an async task manager, and possibly open sockets, so skipping close() is the usual reason a script or test run refuses to exit. width and height replaced the deprecated innerWidth and innerHeight options.
Wait for fetches and timers before assertingawait-async-work
document.body.innerHTML = '<my-widget></my-widget>'
await window.happyDOM.waitUntilComplete()
expect(document.querySelector('.loaded')).not.toBeNull()
// give up on anything still pending
await window.happyDOM.abort()waitUntilComplete resolves when every tracked timer, fetch, and script has settled, which is what you want after mounting a component that loads data. A component with a repeating setInterval never settles, so use abort() or the timer settings to cap it.
Put document and window on globalThisglobal-registrator
// setup.ts
import { GlobalRegistrator } from '@happy-dom/global-registrator'
GlobalRegistrator.register({ url: 'https://example.com/' })
// teardown
await GlobalRegistrator.unregister()This is how you get a DOM in a plain node script or a runner with no environment concept. Register before importing any module that touches document at import time, otherwise that module captured the undefined global already.
Wire it into Jestjest-environment
npm install -D @happy-dom/jest-environment
// jest.config.js
module.exports = {
testEnvironment: '@happy-dom/jest-environment',
testEnvironmentOptions: {
url: 'https://example.com/',
},
};This is a separate package released in lockstep with happy-dom, so both versions must match or you get confusing missing-API errors. Jest with ESM-only dependencies still needs transform or extensionsToTreatAsEsm configuration.
Test a web componentcustom-elements
class MyBadge extends window.HTMLElement {
connectedCallback() {
this.attachShadow({ mode: 'open' })
this.shadowRoot.innerHTML = '<span>badge</span>'
}
}
window.customElements.define('my-badge', MyBadge)
document.body.innerHTML = '<my-badge></my-badge>'
const el = document.querySelector('my-badge')
expect(el.shadowRoot.textContent).toBe('badge')Extend window.HTMLElement from the same Window instance, not a global HTMLElement from somewhere else, or the constructor check fails with an obscure error. Shadow DOM including declarative shadow roots is supported, but styles inside it are parsed rather than applied.
Intercept network requests in testsstub-fetch
const window = new Window({
url: 'https://example.com/',
settings: {
fetch: {
interceptor: {
beforeAsyncRequest: async ({ request }) => {
if (request.url.endsWith('/api/user')) {
return new window.Response(JSON.stringify({ id: 1 }), {
headers: { 'content-type': 'application/json' },
})
}
},
},
},
},
})Returning a Response from beforeAsyncRequest short-circuits the real request; returning nothing lets it through, which means a typo in the URL check quietly makes a live network call from your test suite.
Control file loading and script evaluationsettings-and-safety
const window = new Window({
settings: {
disableJavaScriptFileLoading: true,
disableCSSFileLoading: true,
enableJavaScriptEvaluation: false,
timer: { maxTimeout: 1000, preventTimerLoops: true },
},
})External CSS and script file loading are on by default, so a document with link or script tags makes real network requests during tests. Script evaluation is off by default; enabling it prints a warning that the VM context is not isolated and untrusted markup can reach process level.
Test responsive behaviourviewport-and-media
window.happyDOM.setViewport({ width: 375, height: 812 })
expect(window.matchMedia('(max-width: 600px)').matches).toBe(true)
window.happyDOM.setURL('https://example.com/dashboard?tab=2')
expect(new URL(window.location.href).searchParams.get('tab')).toBe('2')matchMedia evaluates against the configured viewport and device settings rather than any real rendering, so width and height queries work while anything depending on actual element size does not. setURL changes location without a navigation.
Read console output produced inside the pagecapture-console
import { Window, VirtualConsolePrinter } from 'happy-dom'
const window = new Window()
const printer = window.happyDOM.virtualConsolePrinter
window.console.error('boom')
const logs = printer.readAsString()
expect(logs).toContain('boom')By default page console output is captured rather than printed, which is why a console.log inside a component seems to vanish. Read it from the virtual console printer, or pass your own console into the Window constructor to see it in the terminal.
Work with several pages or framesmulti-page-browser
import { Browser } from 'happy-dom'
const browser = new Browser({ settings: { disableCSSFileLoading: true } })
const page = browser.newPage()
await page.goto('https://example.com/')
console.log(page.mainFrame.document.title)
await browser.close()The Browser class is the right tool for scraping or anything needing more than one document, since each page gets its own context and cookies. goto performs a real HTTP request, so in tests pair it with a fetch interceptor or virtual servers.
Alternatives
| Package | Registry | Pick it when |
|---|---|---|
| jsdom | npm | You want the older, slower, more thoroughly exercised implementation with a longer history of spec corner cases already fixed |
| linkedom | npm | You only need to parse and manipulate HTML server side and want something much smaller than a full browser environment |
| playwright | npm | You need a real browser: layout, CSS, screenshots, and event behaviour that matches what users get |
| @vitest/browser | npm | You want to keep Vitest tests but run them in a real browser instead of a simulated DOM |