Test components
Render components in tests with renderToString, mount them with happy-dom, and assert updates.
Test components with Bun’s runner plus happy-dom. No browser, no emulator.
Setup
Install happy-dom’s global registrator and preload it:
npm install -d @happy-dom/global-registratorpnpm add -d @happy-dom/global-registratoryarn add -d @happy-dom/global-registratorbun add -d @happy-dom/global-registrator# bunfig.toml
[test]
preload = "./happydom.ts"
// happydom.ts
import { class GlobalRegistratorGlobalRegistrator } from "@happy-dom/global-registrator";
class GlobalRegistratorGlobalRegistrator.GlobalRegistrator.register(options?: {
width?: number;
height?: number;
url?: string;
settings?: IOptionalBrowserSettings;
}): void
Registers Happy DOM globally.register();
Render to HTML
renderToString is async — it waits until in-flight work is idle:
import { const atom: AtomFnatom, function renderToString(fn: Component, opts?: RenderToStringOptions): Promise<string>renderToString } from "ilha";
import { const expect: ExpectAsserts that a value matches some criteria.expect, const test: Test<[]>Runs a test.test } from "bun:test";
const const Counter: () => ViewCounter = () => {
const const count: AtomHandle<number>count = atom<number>(init: number | Atom<number> | Effect<number, any, any> | Stream<number, any, any>): AtomHandle<number>atom(0);
return (
<IntrinsicElements[string]: anybutton
type: stringtype="button"
onclick: () => voidonclick={() => const count: AtomHandle<number>count.AtomHandle<number>.update(f: (current: number) => number): voidupdate((n: numbern: number) => n: numbern + 1)}
>
{const count: AtomHandle<number>count}
</IntrinsicElements[string]: anybutton>
);
};
function test(label: string, fn: (done: (err?: unknown) => void) => void | Promise<unknown>, options?: number | TestOptions): voidRuns a test.test("renders the count", async () => {
const const html: stringhtml = await function renderToString(fn: Component, opts?: RenderToStringOptions): Promise<string>renderToString(const Counter: () => ViewCounter);
expect<string>(actual: string, customFailMessage?: string): Matchers<string> (+2 overloads)expect(const html: stringhtml).MatchersBuiltin<string>.toContain(expected: string): void (+1 overload)Asserts that a value contains what is expected.
The value must be an array or iterable, which
includes strings.toContain(">0<");
});
renderToString needs a DOM. In Node, ilha registers happy-dom automatically when document is missing — the preload above covers browser-only APIs your component touches at import time.
Mount and simulate events
For behavior, mount into a detached element and dispatch events:
import { const atom: AtomFnatom, function mount(el: Element, fn: Component, opts?: MountOptions): () => voidmount } from "ilha";
import { const expect: ExpectAsserts that a value matches some criteria.expect, const test: Test<[]>Runs a test.test } from "bun:test";
const const Counter: () => ViewCounter = () => {
const const count: AtomHandle<number>count = atom<number>(init: number | Atom<number> | Effect<number, any, any> | Stream<number, any, any>): AtomHandle<number>atom(0);
return (
<IntrinsicElements[string]: anybutton
type: stringtype="button"
onclick: () => voidonclick={() => const count: AtomHandle<number>count.AtomHandle<number>.update(f: (current: number) => number): voidupdate((n: numbern: number) => n: numbern + 1)}
>
{const count: AtomHandle<number>count}
</IntrinsicElements[string]: anybutton>
);
};
function test(label: string, fn: (done: (err?: unknown) => void) => void | Promise<unknown>, options?: number | TestOptions): voidRuns a test.test("clicking increments", async () => {
const const el: HTMLDivElementel = var document: Document**`window.document`** returns a reference to the document contained in the window.
[MDN Reference](https://developer.mozilla.org/docs/Web/API/Window/document)document.Document.createElement<"div">(tagName: "div", options?: ElementCreationOptions): HTMLDivElement (+2 overloads)In an HTML document, the **`document.createElement()`** method creates the HTML element specified by localName, or an HTMLUnknownElement if localName isn't recognized.
[MDN Reference](https://developer.mozilla.org/docs/Web/API/Document/createElement)createElement("div");
var document: Document**`window.document`** returns a reference to the document contained in the window.
[MDN Reference](https://developer.mozilla.org/docs/Web/API/Window/document)document.Document.body: HTMLElementThe **`Document.body`** property represents the <body> or <frameset> node of the current document, or null if no such element exists.
[MDN Reference](https://developer.mozilla.org/docs/Web/API/Document/body)body.ParentNode.append(...nodes: (Node | string)[]): voidInserts nodes after the last child of node, while replacing strings in nodes with equivalent Text nodes.
Throws a "HierarchyRequestError" DOMException if the constraints of the node tree are violated.
[MDN Reference](https://developer.mozilla.org/docs/Web/API/Document/append)append(const el: HTMLDivElementel);
const const unmount: () => voidunmount = function mount(el: Element, fn: Component, opts?: MountOptions): () => voidmount(const el: HTMLDivElementel, const Counter: () => ViewCounter);
await new var Promise: PromiseConstructor
new <unknown>(executor: (resolve: (value: unknown) => void, reject: (reason?: any) => void) => void) => Promise<unknown>
Creates a new Promise.Promise((r: (value: unknown) => voidr) => function setTimeout<[value: unknown]>(callback: (value: unknown) => void, delay?: number, value?: unknown): NodeJS.Timeout (+4 overloads)[MDN Reference](https://developer.mozilla.org/docs/Web/API/Window/setTimeout)setTimeout(r: (value: unknown) => voidr, 5));
const const button: HTMLButtonElementbutton = const el: HTMLDivElementel.ParentNode.querySelector<"button">(selectors: "button"): HTMLButtonElement | null (+4 overloads)Returns the first element that is a descendant of node that matches selectors.
[MDN Reference](https://developer.mozilla.org/docs/Web/API/Document/querySelector)querySelector("button")!;
const button: HTMLButtonElementbutton.HTMLElement.click(): voidThe **`HTMLElement.click()`** method simulates a mouse click on an element. When called on an element, the element's click event is fired (unless its disabled attribute is set).
[MDN Reference](https://developer.mozilla.org/docs/Web/API/HTMLElement/click)click();
await new var Promise: PromiseConstructor
new <unknown>(executor: (resolve: (value: unknown) => void, reject: (reason?: any) => void) => void) => Promise<unknown>
Creates a new Promise.Promise((r: (value: unknown) => voidr) => function setTimeout<[value: unknown]>(callback: (value: unknown) => void, delay?: number, value?: unknown): NodeJS.Timeout (+4 overloads)[MDN Reference](https://developer.mozilla.org/docs/Web/API/Window/setTimeout)setTimeout(r: (value: unknown) => voidr, 5));
expect<string>(actual: string, customFailMessage?: string): Matchers<string> (+2 overloads)expect(const button: HTMLButtonElementbutton.Element.textContent: string[MDN Reference](https://developer.mozilla.org/docs/Web/API/Node/textContent)textContent).MatchersBuiltin<string>.toContain(expected: string): void (+1 overload)Asserts that a value contains what is expected.
The value must be an array or iterable, which
includes strings.toContain("1");
const unmount: () => voidunmount();
const el: HTMLDivElementel.ChildNode.remove(): voidRemoves node.
[MDN Reference](https://developer.mozilla.org/docs/Web/API/CharacterData/remove)remove();
});
The short sleeps let microtasks paint. unmount() stops streams and listeners — call it before removing the host so a late event cannot paint into a detached tree.
Hydration round-trip
Render, inject, then hydrate to verify a snapshot restores:
import { const atom: AtomFnatom, function mount(el: Element, fn: Component, opts?: MountOptions): () => voidmount, function renderToString(fn: Component, opts?: RenderToStringOptions): Promise<string>renderToString } from "ilha";
import { const expect: ExpectAsserts that a value matches some criteria.expect, const test: Test<[]>Runs a test.test } from "bun:test";
const const App: () => ViewApp = () => <IntrinsicElements[string]: anyp>hello</IntrinsicElements[string]: anyp>;
function test(label: string, fn: (done: (err?: unknown) => void) => void | Promise<unknown>, options?: number | TestOptions): voidRuns a test.test("hydrates SSR markup", async () => {
const const html: stringhtml = await function renderToString(fn: Component, opts?: RenderToStringOptions): Promise<string>renderToString(const App: () => ViewApp);
const const el: HTMLDivElementel = var document: Document**`window.document`** returns a reference to the document contained in the window.
[MDN Reference](https://developer.mozilla.org/docs/Web/API/Window/document)document.Document.createElement<"div">(tagName: "div", options?: ElementCreationOptions): HTMLDivElement (+2 overloads)In an HTML document, the **`document.createElement()`** method creates the HTML element specified by localName, or an HTMLUnknownElement if localName isn't recognized.
[MDN Reference](https://developer.mozilla.org/docs/Web/API/Document/createElement)createElement("div");
const el: HTMLDivElementel.Element.innerHTML: stringThe **`innerHTML`** property of the Element interface gets or sets the HTML or XML markup contained within the element, omitting any shadow roots in both cases.
[MDN Reference](https://developer.mozilla.org/docs/Web/API/Element/innerHTML)innerHTML = const html: stringhtml; // test host; markup is your own render output
var document: Document**`window.document`** returns a reference to the document contained in the window.
[MDN Reference](https://developer.mozilla.org/docs/Web/API/Window/document)document.Document.body: HTMLElementThe **`Document.body`** property represents the <body> or <frameset> node of the current document, or null if no such element exists.
[MDN Reference](https://developer.mozilla.org/docs/Web/API/Document/body)body.ParentNode.append(...nodes: (Node | string)[]): voidInserts nodes after the last child of node, while replacing strings in nodes with equivalent Text nodes.
Throws a "HierarchyRequestError" DOMException if the constraints of the node tree are violated.
[MDN Reference](https://developer.mozilla.org/docs/Web/API/Document/append)append(const el: HTMLDivElementel);
const const unmount: () => voidunmount = function mount(el: Element, fn: Component, opts?: MountOptions): () => voidmount(const el: HTMLDivElementel, const App: () => ViewApp, { hydrate?: boolean | undefinedhydrate: true });
await new var Promise: PromiseConstructor
new <unknown>(executor: (resolve: (value: unknown) => void, reject: (reason?: any) => void) => void) => Promise<unknown>
Creates a new Promise.Promise((r: (value: unknown) => voidr) => function setTimeout<[value: unknown]>(callback: (value: unknown) => void, delay?: number, value?: unknown): NodeJS.Timeout (+4 overloads)[MDN Reference](https://developer.mozilla.org/docs/Web/API/Window/setTimeout)setTimeout(r: (value: unknown) => voidr, 5));
expect<string>(actual: string, customFailMessage?: string): Matchers<string> (+2 overloads)expect(const el: HTMLDivElementel.Element.textContent: string[MDN Reference](https://developer.mozilla.org/docs/Web/API/Node/textContent)textContent).MatchersBuiltin<string>.toContain(expected: string): void (+1 overload)Asserts that a value contains what is expected.
The value must be an array or iterable, which
includes strings.toContain("hello");
const unmount: () => voidunmount();
const el: HTMLDivElementel.ChildNode.remove(): voidRemoves node.
[MDN Reference](https://developer.mozilla.org/docs/Web/API/CharacterData/remove)remove();
});
A snapshot-order bug logs a hydrate mismatch warning and falls back to a full mount — assert on the warning in CI if hydration is load-bearing for you.
Related
| Topic | Guide |
|---|---|
| Render and hydrate | Render |
| Streams in tests | Streams |
| Server islands in dev | Server islands |