Test components
Render components in tests with renderToString, mount them with happy-dom, and assert updates.
Test components with Bun’s runner. Use renderToString for HTML assertions. Add happy-dom only when you mount into real nodes.
Render to HTML
renderToString builds HTML without a DOM. It is async — it waits until in-flight work is idle:
import { const atom: AtomFnatom, const 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, unknown, AtomRegistry> | Stream<number, unknown, AtomRegistry>, options?: AtomOptions<number> | undefined): AtomHandle<number>atom(0);
return (
<JSX.IntrinsicElements.button: ButtonHTMLAttributesbutton type?: string | undefinedtype="button" EventProps<HTMLButtonElement>.onclick?: ((event: Targeted<HTMLButtonElement, MouseEvent>) => void) | undefinedonclick={() => const count: AtomHandle<number>count.AtomHandle<number>.update: (f: (current: number) => number) => voidupdate((n: numbern) => n: numbern + 1)}>
{const count: AtomHandle<number>count}
</JSX.IntrinsicElements.button: ButtonHTMLAttributesbutton>
);
};
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<");
});
Mount and simulate events
Mount and event tests need a DOM. Install happy-dom’s global registrator and preload it when you mount into real nodes or touch browser-only APIs at import time:
npm install -d @happy-dom/global-registratorpnpm add -d @happy-dom/global-registratoryarn add -d @happy-dom/global-registratorbun add -d @happy-dom/global-registratornub add -d @happy-dom/global-registratoraube 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();
For behavior, mount into a detached element and dispatch events:
import { const atom: AtomFnatom, const mount: (el: Element, fn: Component, opts?: MountOptions) => (() => void)mount } 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, unknown, AtomRegistry> | Stream<number, unknown, AtomRegistry>, options?: AtomOptions<number> | undefined): AtomHandle<number>atom(0);
return (
<JSX.IntrinsicElements.button: ButtonHTMLAttributesbutton type?: string | undefinedtype="button" EventProps<HTMLButtonElement>.onclick?: ((event: Targeted<HTMLButtonElement, MouseEvent>) => void) | undefinedonclick={() => const count: AtomHandle<number>count.AtomHandle<number>.update: (f: (current: number) => number) => voidupdate((n: numbern) => n: numbern + 1)}>
{const count: AtomHandle<number>count}
</JSX.IntrinsicElements.button: ButtonHTMLAttributesbutton>
);
};
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): (() => void)mount(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. This path also needs the happy-dom preload above:
import { const atom: AtomFnatom, const mount: (el: Element, fn: Component, opts?: MountOptions) => (() => void)mount, const 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 = () => <JSX.IntrinsicElements.p: HTMLAttributes<HTMLParagraphElement>p>hello</JSX.IntrinsicElements.p: HTMLAttributes<HTMLParagraphElement>p>;
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): (() => void)mount(const el: HTMLDivElementel, const App: () => ViewApp, { MountOptions.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 |