DOM testing
Learn how to test DOM elements and components using Bun with happy-dom and React Testing Library
Bun's test runner plays well with existing component and DOM testing libraries, including React Testing Library and happy-dom.
Test environments#
bun test can set up happy-dom or jsdom for you. Test files get the same globals they get in Vitest (window, document, HTMLElement, ...). Install the one you want as a dev dependency.
bun add -d happy-domTo use it in every test file, set environment in bunfig.toml or run bun test --environment=happy-dom.
[test]
environment = "happy-dom" # or "jsdom", or "node" (the default: no DOM)To use it in one file, add the same comment Vitest and Jest use. The comment overrides the project's setting.
// @vitest-environment happy-dom
import { test, expect } from "bun:test";
test("dom test", () => {
document.body.innerHTML = `<button>My button</button>`;
expect(document.querySelector("button")?.textContent).toEqual("My button");
});Bun looks for each comment in the same place Vitest or Jest does:
@vitest-environmentcan be anywhere in the file.@jest-environmentmeans the same thing, but it has to be in a/** */comment at the very top of the file. It's ignored in a//comment.
// @vitest-environment node runs a file without a DOM. Other environments, such as edge-runtime or a custom one, aren't supported.
If a comment names a package that isn't installed or an environment that isn't supported, Bun prints a warning and runs the file without a DOM. If Bun can't set up the environment from bunfig.toml or --environment, the file fails.
Options#
@vitest-environment-options (or @jest-environment-options) takes one line of JSON. Bun passes it to happy-dom's Window constructor or jsdom's JSDOM constructor. The default URL is http://localhost:3000.
/**
* @vitest-environment jsdom
* @vitest-environment-options { "url": "https://example.com/" }
*/Windows and test files#
By default, test files share one global object and one module registry. Files with the same environment and options also share one window, so a module that read document.body when it was imported still has the right element in the next file. Bun restores the globals after each file, so a file that runs in node doesn't see document.
With --isolate or --parallel, every file gets its own window and its own copy of every module. Use them when files in different environments import the same DOM library, because a shared module stays bound to the first window it saw.
The DOM package itself is the exception: each process loads it once, outside the files' globals, so its code stays compiled from file to file. As in Vitest's vmThreads and vmForks pools, the plain arrays, promises and functions that jsdom's own code creates belong to another realm:
event.composedPath() instanceof Array; // false with --isolate
Array.isArray(event.composedPath()); // true
expect(element.getAttributeNames()).toStrictEqual(["class"]); // passes
element instanceof HTMLElement; // trueWith --isolate=modules there is one global object, the package is loaded into it, and these are ordinary arrays.
require("jsdom") in a test file loads a second copy of the package. The window's own JSDOM object is globalThis.jsdom.
For the same reason, the timers that jsdom sets for itself are real while fake timers are on. input.select(), input.focus(), window.postMessage() and a click on a #fragment link each set one. With --isolate, they don't count in vi.getTimerCount(), and vi.runAllTimers() doesn't run them.
Preload scripts run in the project's environment. With --isolate, they run again for each file, in that file's environment.
With jsdom, an error thrown in an event listener or a timer fails the running test, like in Vitest and Jest. If the file or a preload script has added an error listener to window, the test doesn't fail.
With jsdom, the global ArrayBuffer, Uint8Array and the other typed arrays are the window's, as in Vitest. What Bun creates belongs to its own realm, and instanceof accepts it all the same:
Buffer.from("a") instanceof Uint8Array; // true (false in Vitest)
new TextEncoder().encode("a") instanceof Uint8Array; // true (false in Vitest)
(await new Response("a").arrayBuffer()) instanceof ArrayBuffer; // trueWith jsdom and happy-dom, reportError() dispatches an error event at window, as in a browser. React 19 reports what a component throws through it, so an error listener on window hears those errors too.
Registering happy-dom in a preload#
Instead of setting environment, you can register the globals yourself in a preload script.
Install the @happy-dom/global-registrator package as a dev dependency.
bun add -d @happy-dom/global-registratorUse Bun's preload feature to register the happy-dom globals before your tests run, which makes browser APIs like document available in the global scope. Create a file called happydom.ts in the root of your project with the following code:
import { GlobalRegistrator } from "@happy-dom/global-registrator";
GlobalRegistrator.register();To preload this file before bun test, open or create a bunfig.toml file and add the following lines.
[test]
preload = ["./happydom.ts"]bun test now executes happydom.ts before your tests, so they can use browser APIs like document and window.
import { test, expect } from "bun:test";
test("dom test", () => {
document.body.innerHTML = `<button>My button</button>`;
const button = document.querySelector("button");
expect(button?.innerText).toEqual("My button");
});TypeScript Support#
Depending on your tsconfig.json setup, you may see a "Cannot find name 'document'" type error in the earlier code. To load the types for document and other browser APIs, add the following triple-slash directive to the top of any test file.
/// <reference lib="dom" />
import { test, expect } from "bun:test";
test("dom test", () => {
document.body.innerHTML = `<button>My button</button>`;
const button = document.querySelector("button");
expect(button?.innerText).toEqual("My button");
});Run the test with bun test:
bun testbun test v1.3.3
dom.test.ts:
✓ dom test [0.82ms]
1 pass
0 fail
1 expect() calls
Ran 1 test across 1 file. [125.00ms]React Testing Library#
Bun works with React Testing Library for testing React components. After setting up a DOM as described earlier, install and use React Testing Library normally.
bun add -d @testing-library/react @testing-library/jest-dom/// <reference lib="dom" />
import { test, expect } from 'bun:test';
import { render, screen } from '@testing-library/react';
import '@testing-library/jest-dom';
function Button({ children }: { children: React.ReactNode }) {
return <button>{children}</button>;
}
test('renders button', () => {
render(<Button>Click me</Button>);
expect(screen.getByRole('button')).toHaveTextContent('Click me');
});Advanced DOM Testing#
Custom Elements#
Test custom elements and web components with the same setup:
/// <reference lib="dom" />
import { test, expect } from "bun:test";
test("custom element", () => {
// Define a custom element
class MyElement extends HTMLElement {
constructor() {
super();
this.innerHTML = "<p>Custom element content</p>";
}
}
customElements.define("my-element", MyElement);
// Use it in tests
document.body.innerHTML = "<my-element></my-element>";
const element = document.querySelector("my-element");
expect(element?.innerHTML).toBe("<p>Custom element content</p>");
});Event Testing#
Test DOM events and user interactions:
/// <reference lib="dom" />
import { test, expect } from "bun:test";
test("button click event", () => {
let clicked = false;
document.body.innerHTML = '<button id="test-btn">Click me</button>';
const button = document.getElementById("test-btn");
button?.addEventListener("click", () => {
clicked = true;
});
button?.click();
expect(clicked).toBe(true);
});Configuration Tips#
Global Setup#
For more involved setups, create a preload file that also registers global mocks:
import { GlobalRegistrator } from "@happy-dom/global-registrator";
import "@testing-library/jest-dom";
// Register happy-dom globals
GlobalRegistrator.register();
// Add any global test configuration here
global.ResizeObserver = class ResizeObserver {
observe() {}
unobserve() {}
disconnect() {}
};
// Mock other APIs as needed
Object.defineProperty(window, "matchMedia", {
writable: true,
value: jest.fn().mockImplementation(query => ({
matches: false,
media: query,
onchange: null,
addListener: jest.fn(),
removeListener: jest.fn(),
addEventListener: jest.fn(),
removeEventListener: jest.fn(),
dispatchEvent: jest.fn(),
})),
});Then update your bunfig.toml:
[test]
preload = ["./test-setup.ts"]Troubleshooting#
Common Issues#
TypeScript errors for DOM APIs: Include the /// <reference lib="dom" /> directive at the top of your test files.
Missing globals: Check that the file runs in a test environment, or that your preload file imports and registers @happy-dom/global-registrator.
React component rendering issues: Check that @testing-library/react is installed and happy-dom is set up.
Performance Considerations#
happy-dom is fast, but for very large test suites you may want to:
- Use
beforeEachto reset the DOM state between tests - Avoid creating too many DOM elements in a single test
- Use
cleanupfunctions from testing libraries
import { afterEach } from "bun:test";
import { cleanup } from "@testing-library/react";
afterEach(() => {
cleanup();
document.body.innerHTML = "";
});