mrkeyoor.com_
Thu 06 Aug 10:58 UTC
npmWeb Frontendupdated 06 Aug 2026

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.

Verdict

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.

API stability2/5Nine major versions shipped between September 2023 and October 2025, and because the public surface is the DOM itself, a major bump can silently change how an existing test behaves rather than failing to compile; teams routinely pin the version and upgrade deliberately.
Docs3/5The GitHub wiki has getting-started, test-environment setup, and a settings reference, and the README lists supported features, but there is no API reference for the several hundred exported classes, and behaviour differences from a real browser are documented mostly in issue threads.
Maintenance4/5Pushed 22 July 2026 with 20.11.1 released the same day and releases going out most weeks, but 289 open issues (365 counting PRs) against what is largely one maintainer with sponsor support means reported DOM gaps can sit for a long time.
Ecosystem4/5Around 13.8M weekly downloads, a documented Vitest environment, an official Jest environment package, a global registrator, and use inside Bun's test tooling; jsdom is still the default in more places, and most Testing Library guides assume jsdom.

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
Skip it if

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

PackageRegistryPick it when
jsdomnpmYou want the older, slower, more thoroughly exercised implementation with a longer history of spec corner cases already fixed
linkedomnpmYou only need to parse and manipulate HTML server side and want something much smaller than a full browser environment
playwrightnpmYou need a real browser: layout, CSS, screenshots, and event behaviour that matches what users get
@vitest/browsernpmYou want to keep Vitest tests but run them in a real browser instead of a simulated DOM