Skip to content

DOM and browser testing

ExperimentalRequires happy-domRequires playwrightLinux and macOS

Two backends are available for page-shaped tests, and one fixture can run on either. DOM testing is stable; browser testing is experimental and may change in a minor release. Both are selected per test file, not globally.

The decision

happy-dom (default)Real browser
What runsA DOM implementation in the same process as the testA headless Chromium process driven over the Playwright protocol
SpeedFast: no browser process, no protocol round-tripSlower to start, and every interaction is a round-trip
FidelityDOM APIs only — no layout, no paint, no real navigation engineA real engine: layout, CSS cascade, real timers, real fetch
Prerequisitesbun add -d happy-dombun add -d playwright and bunx playwright install chromium
PlatformsAnywhere bun test runsLinux and macOS; on Linux the Chromium build also needs system libraries — see Browser tests in CI
Use it forRendering logic, DOM manipulation, component output, event wiring, text and attribute assertionsReal layout, computed styles, focus and keyboard behaviour, anything where the browser engine is the thing under test
Known limitsNo layout or paint; evaluate runs with the happy-dom window as this rather than in a page context; a goto fetches the document and sets it instead of navigatingExperimental API; headless only — there is no headed mode (ADR 0030); the browser fixture launches Chromium only
MaturityStableExperimental

If a suite only needs DOM APIs, happy-dom is the right default and it is already the default. Reach for the browser backend when a test would pass against happy-dom and the real engine disagrees — that difference is usually the bug.

A minimal DOM test

Install the peer, then request page (or window / document):

Runnable — complete sample — the docs check runs it against the built package

Terminal window
bun add -d happy-dom

Runnable — complete sample — the docs check runs it against the built package

renders.test.ts
import { expect, test } from "@archont561/bun-test-utils";
test("renders a heading with the DOM fixture", async ({ page }) => {
page.mount('<h1 id="title">Ada</h1>');
expect(page.querySelector("#title")?.textContent).toBe("Ada");
expect(page.html()).toContain("Ada");
});

Runnable — complete sample — the docs check runs it against the built package

Terminal window
bun test renders.test.ts

page is test-scoped: each test mounts into a fresh document, and the window is torn down afterwards. page.mount(html) returns the mounted element; querySelector, querySelectorAll, click, type, html, and clear make up the rest of the helper. Without happy-dom installed, the fixture fails with MISSING_OPTIONAL_DEPENDENCY and the message says Install via 'bun add -d happy-dom'.

A minimal browser test

The browser backend needs the peer and a Chromium build:

Runnable — complete sample — the docs check runs it against the built package

Terminal window
bun add -d playwright
bunx playwright install chromium

Then either request a Playwright-specific fixture — browserPage — or keep the portable webPage and select the backend with the environment variable:

Illustrative — a complete sample the check cannot run, or one that calls helpers it does not define

browser.test.ts
import { expect, test } from "@archont561/bun-test-utils";
test("renders a heading in a real page", async ({ browserPage }) => {
await browserPage.setContent('<h1 id="title">Ada</h1>');
expect(await browserPage.textContent("#title")).toBe("Ada");
});

Illustrative — a complete sample the check cannot run, or one that calls helpers it does not define

Terminal window
BUN_TEST_UTILS_WEB_ENV=browser bun test browser.test.ts

browser (the launched Chromium) is session-scoped, so one browser process is reused across the run; browserContext and browserPage are test-scoped, so each test gets a clean context. This is the experimental surface: the fixtures, their members, and the environment-variable values may change in a minor release.

One test, both backends

webPage is the portable fixture. Its members mean the same thing on both backends, so the same assertion runs unchanged:

Runnable — complete sample — the docs check runs it against the built package

portable.test.ts
import { expect, test } from "@archont561/bun-test-utils";
test("renders a heading on either backend", async ({ webPage }) => {
await webPage.setContent('<h1 id="title">Ada</h1>');
expect(await webPage.textContent("#title")).toBe("Ada");
});

Partial — one step of a longer file or workflow — surrounding lines are omitted

Terminal window
bun test portable.test.ts # happy-dom
BUN_TEST_UTILS_WEB_ENV=browser bun test portable.test.ts # headless Chromium

Keep the assertion the same and let the environment choose. Backend-specific code belongs behind webPage.mode or on webPage.raw:

MemberOn happy-domOn the browser backend
mode"dom""browser"
goto(url)Fetches the URL and sets the document bodyNavigates the page
setContent(html) / mount(html)Replaces the document contentReplaces the page content
click(selector) / type(selector, text)Dispatches a synthetic eventDrives real user input
textContent(selector) / html()Reads the DOMReads the DOM
evaluate(fn)Runs fn with this bound to the happy-dom windowRuns fn inside the page
rawThe happy-dom windowThe Playwright Page

page always uses happy-dom and browserPage always uses Playwright; their meaning never changes. Use them when a test is deliberately backend-specific, and webPage when it should run on both.

Bringing a browser into the fixture model

httpMock handlers can be installed on a real page too. Request browserHttpMock to put the same handlers on the browserContext automatically, or call httpMock.install(page) yourself:

Partial — one step of a longer file or workflow — surrounding lines are omitted

test("the page sees mocked data", async ({ browserPage, httpMock }) => {
httpMock.get("/api/user", () => Response.json({ name: "Ada" }));
const uninstall = await httpMock.install(browserPage);
// ... drive the page, then:
await uninstall();
});

See HTTP mocking and test servers for the handler API.

Troubleshooting

SymptomCauseFix
MISSING_OPTIONAL_DEPENDENCY naming playwright, with Install via 'bun add -d playwright'The peer is not installed in the project.bun add -d playwright in the project that runs the browser test.
FIXTURE_SETUP_FAILED: “No browser is available — both the headless shell and the full chromium build failed to launch.”Playwright is installed but has no Chromium build, or the build cannot launch. The underlying cause reads Executable doesn't exist at …/chromium-<revision>/chrome-linux64/chrome.Run bunx playwright install chromium in the project. The message names the repository’s own bun run install-browsers script, which is a convenience alias for this repository, not a command in your project.
The same error on a Linux CI runner, with a launch failure that is not about a missing executableChromium’s shared libraries are missing.Install the system dependencies: bunx playwright install --with-deps chromium, or use Playwright’s documented container. See Browser tests in CI.
A selector matches in happy-dom and not in the browserhappy-dom does not implement layout, so selectors that depend on rendering, :visible, or computed styles can differ.Assert on text, attributes, and roles instead of layout, or move the test to the browser backend deliberately.
evaluate behaves differently between backendsOn happy-dom, fn runs in the test process with this bound to the window; in a browser it is serialized into the page.Keep evaluate callbacks free of closures over test variables, or branch on webPage.mode.
Browser tests are slow in CIEvery browser launch is a process; contexts add start-up cost.Keep browser session-scoped (it is), and reuse browserPage across assertions within one test.

Docs for bun-test-utils v0.1.0 Release notes Report a docs issue