---
title: "Testing Components With jsdom and Chrome"
description: "Testing with the lemonadejs/test harness (render, query, snapshot, verify): jsdom for state and DOM logic, real Chrome for the layout jsdom cannot see."
source: https://lemonadejs.com/docs/tests/
---

# Testing components

Testing components needs no setup. The harness ships with the framework, and
assertions are synchronous:

```javascript
import { render } from 'lemonadejs/test';     // any DOM: browser or jsdom

const t = render(Counter, { start: 5 });
t.query('button').click();
t.query('p').textContent === '6';   // assert on the NEXT LINE: no act(),
t.unmount();                        // no nextTick(), no waitFor()
```

But a unit harness is half the story. This chapter documents the
discipline the project actually practices: **jsdom for state and DOM
logic, real Chrome for geometry**, because the second half is where
test suites overstate what they verified.

A test in plain JavaScript: mount into a detached element, click, assert on the next line. No `act()`, no `waitFor()`.

<!--example-->

```js
import { html, mount } from 'lemonadejs';

const Counter = (props, { state }) => {
    const n = state(0);
    return html`<div><p>${n}</p><button onclick="${() => n.value++}">+</button></div>`;
};

const results = [];
const check = (name, ok) => results.push((ok ? 'PASS  ' : 'FAIL  ') + name);

const root = document.createElement('div');       // detached: no page needed
const t = mount(Counter, root);
check('renders the initial value', root.querySelector('p').textContent === '0');
root.querySelector('button').click();
check('a click updates synchronously', root.querySelector('p').textContent === '1');
t.unmount();
check('unmount leaves the root empty', root.childNodes.length === 0);

const App = () => html`<pre>${results.join('\n')}</pre>`;
```

## Why two layers

- **jsdom has no layout engine.** Every `getBoundingClientRect()` returns
  zeros; nothing has a width, nothing overlaps, nothing overflows,
  nothing anchors. A dropdown panel rendered 400px away from its input
  passes every test written for it.
- **Scheduled updates require flush calls.** When the engine schedules
  DOM updates, assertions must wait for them to settle. The common case
  is usually handled automatically; flakiness clusters at the edges:
  timers, transitions, manually scheduled work.
- **"Renders without crashing" is not conformance.** A published
  component promises props, events, a bindable value, an api. Almost no
  test suite mechanically checks the promise.
- **Agents make this acute.** An agent cannot eyeball a browser. If its
  feedback loop cannot see layout, it ships geometry bugs *with a green
  test suite*, and both real bugs below did exactly that.

## The test layers

**Layer 1, `lemonadejs/test`, on any DOM.** `render()` mounts into a
fresh container and returns a handle: `query`/`queryAll` (scoped
querySelector), `text()`, `snapshot()` (deterministic, attribute-sorted,
diffable text rendering of the DOM tree), `inspect()` (the live component
tree as plain JSON: `{ component, contract, states, children }`), and
`unmount()`. Because [state updates are synchronous](/docs/state/), there
is no flush step: click, then assert.

Two helpers cover what synchronous updates cannot. **`flush()`** drains
microtasks and zero-delay timers, the awaiting step for genuinely async
work like a [resource()](/docs/async/) response landing:
`await flush()` then assert. **`setRect(el, { left, width, ... })`**
plants geometry on an element in jsdom, where no layout engine exists
and every rectangle is zero, so a modal's auto-adjust, a tooltip's
anchoring or a drag's hit-testing become *logic-testable* in jsdom, with
the real-pixel truth still owned by the probe layer below.

`verify()` is the conformance layer: it reads the component's
[contract](/docs/contracts/) and exercises the promise. It mounts with
defaults, every prop as a plain value **and** as a live state, every
event, `bind`, and the declared api actually exposed through
`props.ref`. Any `LJS-*` dev warning during any check fails that check.
The Studio registry refuses blocks whose `verify()` fails.

**Layer 2, real Chrome, for geometry.** The probe pattern, zero
dependencies:

- `.probe/*.ts` are in-page assertion scripts: mount the block, drive it
  (open, scroll, type, drag), measure with real
  `getBoundingClientRect()`, and write `PASS`/`FAIL` lines into a
  `<pre id="lm-probe">`.
- `scripts/chrome-probe.mjs` (96 lines, no packages) spawns the
  *installed* Chrome headless with a DevTools port, connects over Node's
  global `WebSocket`, polls the page for the `#lm-probe` block, prints
  it, and exits non-zero on any `FAIL`.
- `npm run probe` bundles every probe page and runs them all against the
  dev server.

An assertion from the dropdown probe, the kind jsdom structurally
cannot make:

```javascript
api.open();
await frame();
const panelRect = panel.getBoundingClientRect();
log('panel-anchored-under-input',
    Math.abs(panelRect.top - (inputRect.bottom + 1)) <= 2 &&
    Math.abs(panelRect.left - inputRect.left) <= 2,
    { panelTop: Math.round(panelRect.top), inputBottom: Math.round(inputRect.bottom) });
```

## In practice

The receipts: at the time of writing, the repository runs **1,049
behavior tests** (engine + all 43 Studio blocks, jsdom) and **62
real-Chrome probe assertions** across eight probe pages (modal,
dropdown, grid, menu, calendar, schedule, gantt, router) on every
change. Two bugs that suite structure caught, and jsdom alone provably
could not:

**The Modal anchor bug.** Anchored panels read their `top`/`left` props
once, at construction (setup runs once by design), so a panel fed live
anchor coordinates opened wherever the coordinates were *before* the
caller measured them. Every jsdom test stayed green: in jsdom all
rectangles are zero, so "panel at 0,0" is indistinguishable from
correct. The probe assertion above (panel top within 2px of the input's
bottom) failed in real Chrome; the fix re-reads position props per open.
This bug class later motivated [computed()](/docs/computed/).

**The dropdown focus race.** In a real browser, a mousedown's default
action focuses the label; opening the panel swaps a branch, the renderer
disposes the focused node, and the browser fires `focusout` exactly as
if the user clicked away, closing the panel in the same tick it opened.
jsdom never produces that event sequence. The probe drives the genuine
flow (`field.focus()` then a coordinate-carrying `mousedown`) and
asserts the panel survives; the fix is the `isDisposing()` guard
(see [Destroy](/docs/destroy/)).

And the conformance loop an agent runs after generating a component:

```javascript
import { verify } from 'lemonadejs/test';

const report = verify(Switch);
report.pass;     // true, or:
report.checks;   // [{ name: 'prop label (live state)', pass: false, detail: '...' }]
```

Generate the component and its proof in one breath; a failing check
names the broken promise.

## Reference

```javascript
import { render, verify } from 'lemonadejs/test';

const t = render(Component, props?);   // TestHandle
t.root            // the mounted container
t.query(sel)      // querySelector, scoped: HTMLElement | null
t.queryAll(sel)   // real array of elements
t.text()          // full visible text
t.snapshot()      // deterministic indented DOM tree (attrs sorted), diffable
t.inspect()       // { component, contract, states, children } as plain JSON
t.unmount()       // dispose everything

verify(C)         // { component, pass, checks: [{ name, pass, detail? }] }
                  // requires a contract; any LJS-* warning fails a check

import { flush, setRect } from 'lemonadejs/test';
await flush()     // drain microtasks + zero-delay timers (async work landed)
setRect(el, { left: 10, width: 200, height: 24 })
                  // plant geometry in jsdom (no layout there): modal,
                  // tooltip and drag logic become testable
```

Probe commands (library repo):

```bash
npm test                   # vitest: engine + every block, jsdom
npm run dev                # serves the playground + probe pages on :3000
npm run probe              # bundle .probe/*.ts, run each page in headless Chrome
node scripts/chrome-probe.mjs http://localhost:3000/.probe/probe-grid.html
# CHROME=/path/to/chrome to point at a different binary
```

The division of labor, memorized: state, events, contract conformance go to
jsdom, synchronous, fast. Anything with a coordinate in it goes to a probe,
in the real engine, with the assertion in the page.