DOM testing

Use --dom for component and browser-facing unit tests:

Shell
esdev test --dom
esdev test --dom --file=src/button.test.ts

--dom is deliberately test-runner-only. It creates a fresh in-memory DOM realm for every test file; normal esdev programs do not receive window or document. Fresh realms prevent event listeners, custom-element registrations, storage, and document mutations leaking into another test file.

What it is for

The DOM realm is intended for modern component tests: creating and querying nodes, mutating attributes and text, dispatching events, rendering templates, using forms, custom elements, Shadow DOM, MutationObserver, and focused controls. It is a DOM implementation, not a browser renderer. A test that needs layout or anything else only a browser has can run the same file in one with --browser.

JavaScript
import { test, assertEquals } from "runtime:test";

test("updates a control", () => {
  const input = document.createElement("input");
  input.value = "ready";
  document.body.append(input);

  assertEquals(document.querySelector("input")?.value, "ready");
});

Use the built-in runtime:test assertions and runner. --dom does not add compatibility layers for other test frameworks.

Libraries that drive the DOM through standard APIs run as they do in a browser. Testing Library's userEvent types, selects, tabs, copies and pastes: the realm has the text selection APIs, Selection, FileReader and an extensible navigator it relies on.

Strict modern HTML

innerHTML and fragment parsing accept conforming modern HTML, including the tags HTML allows to be omitted — </li>, </p>, </td>, an implied <tbody> before a <tr> — so a fixture builds the same tree here as in a browser.

Malformed input is refused with a diagnostic instead of being repaired:

MarkupResult
<table><tr><td>x</td></tr></table><table><tbody><tr><td>x</td></tr></tbody></table>
<ul><li>a<li>b</ul><ul><li>a</li><li>b</li></ul>
<p><i>unclosedSyntaxError
<b><i>x</b></i>SyntaxError
<table><td>x</td></table>SyntaxError — a cell needs a row
<my-widget><my-widget>SyntaxError — a custom element closes like any other

A test that depends on parser recovery does not state the DOM tree it expects. That includes custom elements: an unclosed <my-widget> is refused rather than nested and auto-closed, so a fixture ported from a browser runner may need its tags closed first.

Web components

Custom elements, shadow DOM and templates are covered in full, and measured against Chrome:

AreaWhat works
ReactionsconnectedCallback, disconnectedCallback, adoptedCallback, attributeChangedCallback with observedAttributes, namespaces and old values, in specification order
Registrydefine, get, getName, whenDefined, upgrade; one constructor per name; new MyElement() from script
Shadow DOMopen and closed roots, delegatesFocus, clonable, serializable, named and manual slot assignment, assignedSlot, slotchange, retargeted events and paths
Styling:host, :host(…), :host-context(…), ::slotted(…), adoptedStyleSheets per root
FormsElementInternals, form association, setFormValue, setValidity, formDisabledCallback, formResetCallback, CustomStateSet and :state()
Templatesinert content, declarative shadow roots through setHTMLUnsafe and Document.parseHTMLUnsafe, getHTML serialization
Dialogs & popoversshow, showModal, close, requestClose, returnValue, :modal; showPopover, hidePopover, togglePopover, :popover-open, beforetoggle/toggle, CommandEvent
MovingmoveBefore(), which keeps a node's state instead of removing and re-inserting it

Scoped custom element registries — new CustomElementRegistry() used with attachShadow({ customElements }) — are a proposal and are not implemented. A registry that is not the document's keeps its own definitions and upgrades nothing.

Boundaries

The realm has no rendering engine, network, browsing contexts, or navigation. Do not use it to test layout, geometry, media loading, XHR, iframe behavior, script execution, or page navigation.

location, history, and web storage are in-memory test state. Form submit events are dispatched, but no navigation occurs. Animation frames use the test clock. Media, resize, and intersection observers are safe never-firing stubs. Geometry answers zero rather than throwing, so feature detection runs. A dialog and a popover have no top layer and no backdrop: what they have is their state, their selectors and their events.

Styles and the cascade

getComputedStyle() resolves the cascade — stylesheets, <style> elements, constructable sheets, adoptedStyleSheets, specificity, !important, inheritance through the flat tree, custom properties, user-agent defaults, @media, @supports and CSS nesting — and resolves everything that needs no box.

JavaScript
import { test, assertEquals } from "runtime:test";

test("a variant restyles the button", () => {
  const style = document.createElement("style");
  style.textContent = ".danger { color: rgb(220, 38, 38) }";
  const button = document.createElement("button");
  button.className = "danger";
  document.body.append(style, button);

  assertEquals(getComputedStyle(button).color, "rgb(220, 38, 38)");
});

@media is evaluated against a declared viewport: window.innerWidth and innerHeight start at 1024×768 and are assignable.

Lengthspixels: getComputedStyle(h1).fontSize is 32px, line-height: 1.5 at a 12px font is 18px, letter-spacing: 2em is 20px
Coloursrgb()/rgba(): color: red reads rgb(255, 0, 0), transparent reads rgba(0, 0, 0, 0), currentcolor the element's own colour
Shorthandsexpanded to longhands, and serialized back from them: computed.border is 1px solid rgb(255, 0, 0)
A declarationholds longhands too — style.border = "1px solid red" gives style.length === 17, and cssText prints the shorthand back

What it does not do: resolve anything a box decides — a percentage of the containing block, ex, ch, the viewport units, line-height: normal — answer for an element outside the tree, or style a pseudo-element. See DOM parity.

Conformance

The DOM is checked against the JavaScript tests of the Web Platform Tests, and against headless Chrome, jsdom and happy-dom on the same cases, with Chrome's result taken as correct. The current results are on DOM parity.

Tests that need rendering, layout, navigation or a network are outside its scope. For those, use --browser.

Last updated on
Edit this page