Testing
Testing Library does not work here and no shim will fix it: its queries are built on a DOM document. Its five failure modes are in the negative-results register; a jsdom shim only makes the query fail differently.
The replacement ships in the box: react-x11/test —
renderX11, screen, userEvent, waitFor and pixel assertions, over a
real X server running in your test process. Read that page first. Below is
the hand-rolled version, for a test that wants the connection itself, and
the third-party runners and tools that work alongside it.
The harness, by hand
node-x11 ships a pure-JS X server that runs in your test process, so a test
needs no $DISPLAY, no Xvfb, and no native dependency. This is what
renderX11 does, spelled out for a test that holds the pieces directly.
// helpers/harness.jsx
import React from 'react';
import { createRoot } from 'react-x11';
import xserver from 'x11/lib/xserver/index.js';
export async function createHeadlessRoot() {
const server = xserver.createServer({ width: 640, height: 480 });
const [serverEnd, clientEnd] = xserver.createStreamPair();
server.addClientStream(serverEnd);
// `stream` reaches a server that is not on the other end of $DISPLAY,
// which is the whole trick; the root owns the connection and closes it
// on unmount.
return await createRoot({ stream: clientEnd });
}
/** The render callback hands back the ntk window instance. `root.render`
* is synchronous, so this stays a plain helper — only obtaining the root
* is async, and that happens once. */
export const render = (root, element) =>
new Promise((resolve) => root.render(element, (wnd) => resolve(wnd)));
export const tick = () => new Promise((r) => setImmediate(r));
import test from 'node:test';
import assert from 'node:assert/strict';
import { createHeadlessRoot, render, tick } from './helpers/harness.jsx';
test('paints a box', async () => {
const root = await createHeadlessRoot();
const wnd = await render(
root,
<window width={320} height={240} title="probe">
<box style={{ flexGrow: 1, backgroundColor: '#204080' }} />
</window>,
);
await tick(); // layout runs on the frame clock, after the commit
const node = wnd._reactX11Node; // the root drawn node
assert.equal(node.children[0].kind, 'box');
assert.deepEqual(node.children[0].abs, {
x: 0,
y: 0,
width: 320,
height: 240,
});
await root.unmount();
});
The tick() is not optional. render's callback resolves at commit;
layout runs a task later on the frame clock, so every abs rect is
{x: 0, y: 0, width: 0, height: 0} until you yield once. Assert on kind
and props immediately, never on geometry.
wnd._reactX11Node is the root of the drawn tree. Each node carries kind
(the element name), props, children, and abs (its laid-out rect):
walk it. It is not a DOM; ownerDocument and getClientRects() exist only
because React DevTools' measureHostInstance dereferences them.
A <text> node holds textchunk children whose .text is the string:
export function textOf(node) {
const out = [];
(function walk(n) {
if (n.kind === 'textchunk') out.push(n.text);
(n.children ?? []).forEach(walk);
})(node);
return out.join('');
}
To run with no X server at all — faster, and enough for most component
tests — pass createRoot({ app }) a mock ntk app that records canvas
operations instead of painting them. react-x11/test exports the one the
renderer's unit tests use, createMockApp(), and
renderX11(el, { backend: 'mock' }) builds it for you
(mock backend).
Driving events
@testing-library/user-event throws on setup()
(register); use react-x11/test's
userEvent, or emit the raw ntk event on the window. react-x11 subscribes
to the ntk window's emitter, so this exercises the whole synthetic-event
path: hit testing, capture, bubble, default actions.
test('a synthetic click reaches the handler', async () => {
const root = await createHeadlessRoot();
let clicks = 0;
const wnd = await render(
root,
<window width={200} height={100}>
<box style={{ flexGrow: 1 }} onClick={() => (clicks += 1)} />
</window>,
);
await tick();
wnd.emit('mousedown', { x: 20, y: 20, button: 1, buttons: 1 });
wnd.emit('mouseup', { x: 20, y: 20, button: 1, buttons: 0 });
await tick();
assert.equal(clicks, 1);
await root.unmount();
});
Keyboard input is the same shape — wnd.emit('keydown', { keycode, codepoint, buttons }) — with the keysym registered in
app.X.keycode2keysyms so the renderer can resolve it. button is the X
button number: left is 1, not 0.
Runners
node:test (built-in) is the baseline. react-x11's own suite uses it,
and it handles the ESM graph without configuration. Run .jsx files through
tsx: node --import tsx --test 'test/**/*.test.jsx'.
Vitest
Out of the box. vitest@4.1.10.
Native ESM, built-in JSX and TypeScript transform, watch mode, snapshots. A
.test.jsx that mounts a tree, drives a prop update and asserts on the node
tree and the paint-op log runs green with no flags. The one setting that
matters:
// vitest.config.mjs
import { defineConfig } from 'vitest/config';
export default defineConfig({
test: { environment: 'node' }, // the default; never 'jsdom'
esbuild: { jsx: 'automatic' },
});
Never environment: 'jsdom'. It defines window and document,
which silently flips environment detection in third-party libraries —
@tanstack/query-core decides it is in a browser and changes retry and
refetch behaviour, for one. 'node' is the default; do not copy a web
project's config across.
- Vitest runs test files in worker threads by default. The in-process X
server works there;
pool: 'forks'is the escape hatch if long-lived server streams misbehave. vi.useFakeTimers()shares the sinon caveat: pass an explicittoFakelist, or React's scheduling and the renderer's paint scheduling freeze.
Jest
Works, but only in real ESM mode. jest@30.4.2.
Jest's default CJS path cannot work: babel-jest down-compiles to
CommonJS, and react-x11/src/Reconciler.js and src/DevToolsIntegration.js
call createRequire(import.meta.url), which has no CJS equivalent, so Jest's
own suggestions lead to
SyntaxError: Cannot use 'import.meta' outside a module. Both dead ends are
in the register. What works:
// jest.config.mjs — run with NODE_OPTIONS=--experimental-vm-modules
export default {
extensionsToTreatAsEsm: ['.jsx'],
transform: {
'\\.jsx$': [
'babel-jest',
{
presets: [
[
'@babel/preset-env',
{ modules: false, targets: { node: 'current' } },
],
['@babel/preset-react', { runtime: 'automatic' }],
],
},
],
},
};
modules: false is the load-bearing part: with no CJS transform, Jest runs
the same ESM graph as everything else. node:test and Vitest need none of
this.
msw
Out of the box — msw/node. msw@2.15.0.
Request-level API mocking. In Node, setupServer patches http, https,
fetch/undici and XMLHttpRequest, so http.get(url, resolver) handlers
intercept real requests — no service worker, no DOM. A react-x11 app's data
layer is ordinary Node networking: components call fetch, msw answers, the
renderer repaints.
import { setupServer } from 'msw/node';
import { http, HttpResponse } from 'msw';
const server = setupServer(
http.get('https://api.example.test/user', () =>
HttpResponse.json({ name: 'Ada' }),
),
);
before(() => server.listen({ onUnhandledRequest: 'error' }));
after(() => server.close());
function User() {
const [name, setName] = React.useState('loading');
React.useEffect(() => {
let alive = true;
fetch('https://api.example.test/user')
.then((r) => r.json())
.then((d) => alive && setName(d.name));
return () => {
alive = false;
};
}, []);
return <text>{name}</text>;
}
- The fetch →
setState→ commit round trip is asynchronous, and onesetImmediatetick is not enough. Poll the tree with a deadline. server.listen()patches process-wide networking. Callserver.close()inafter, or the harness and msw can leave process state between files.- The in-process X server connection is a plain stream, not HTTP; msw never touches it.
onUnhandledRequest: 'error'makes any unmocked request a loud failure — the right default for hermetic UI tests.- nock also works; prefer it only for its scope-level assertions
(
scope.done()) on legacyhttp.request-based clients.
fast-check
Out of the box. fast-check@4.9.0.
Property-based testing: generators plus a property, run against hundreds of random inputs, with any failure shrunk to a minimal counterexample and a replay seed. The renderer has two oracles that cannot lie:
- Convergence —
mount(A)thenupdate(A → B)must produce the same tree as a freshmount(B). - Damage soundness — a partial repaint must equal a forced full repaint.
import fc from 'fast-check';
const { node } = fc.letrec((tie) => ({
node: fc.oneof(
{ maxDepth: 3 },
fc.record({
kind: fc.constant('text'),
label: fc.constantFrom('a', 'hello x11'),
}),
fc.record({
kind: fc.constant('box'),
style: fc.record(
{
width: fc.integer({ min: 10, max: 120 }),
flexDirection: fc.constantFrom('row', 'column'),
},
{ requiredKeys: [] },
),
children: fc.array(tie('node'), { maxLength: 3 }),
}),
),
}));
const toEl = (s, key) =>
s.kind === 'text' ? (
<text key={key}>{s.label}</text>
) : (
<box key={key} style={s.style}>
{s.children.map(toEl)}
</box>
);
const win = (kids) => (
<window width={320} height={240}>
{kids.map(toEl)}
</window>
);
const snap = (n) => ({
kind: n.kind,
abs: { ...n.abs },
children: n.children.map(snap),
});
await fc.assert(
fc.asyncProperty(
fc.array(node, { maxLength: 4 }),
fc.array(node, { maxLength: 4 }),
async (a, b) => {
const root1 = await createHeadlessRoot();
const w1 = await render(root1, win(a));
await tick();
await render(root1, win(b)); // update path
await tick();
const root2 = await createHeadlessRoot();
const w2 = await render(root2, win(b)); // fresh-mount path
await tick();
assert.deepStrictEqual(snap(w1._reactX11Node), snap(w2._reactX11Node));
},
),
{ numRuns: 30 },
);
- Compare structure (
kindplusabsrects), not paint-op logs: op logs accumulate across paints on the update path, so they only match after a forced full repaint on both sides. - Keep trees shallow; each run is a real yoga layout pass.
- Include a mid-sequence unmount/remount and
keypermutations in the generators; index keys exercise much less ofinsertBefore/removeChildthan keyed reorders. - A seeded PRNG would also do with no dependency; fast-check buys shrinking
and
{ seed, path }replay.
sinon
Out of the box, with one required restriction. sinon@22.1.0.
Spies, stubs and @sinonjs/fake-timers. Fake timers work — a toast that
auto-dismisses via setTimeout dismisses on clock.tick(1000) — but the
default configuration is a trap.
Never fake setImmediate or queueMicrotask. Sinon's default toFake
list includes them; React's scheduler drives work with them in Node, and the
renderer schedules paints via setImmediate when there is no frame clock.
Faked, every render, effect flush and repaint freezes until a clock.tick —
usually a test that hangs at the first await. Pass an explicit list:
const clock = sinon.useFakeTimers({
toFake: ['setTimeout', 'clearTimeout', 'Date'],
});
The renderer's animation clock reads Date.now() at call time, so a fake
Date reaches style transitions.
test('toast auto-dismisses', async () => {
const clock = sinon.useFakeTimers({
toFake: ['setTimeout', 'clearTimeout', 'Date'],
});
try {
const root = await createHeadlessRoot();
const wnd = await render(
root,
<window width={100} height={50}>
<Toast />
</window>,
);
await tick(); // mount + passive effect
clock.tick(1000); // fires the timeout synchronously
await tick();
await tick(); // setState -> commit -> repaint: two tasks
assert.strictEqual(wnd._reactX11Node.children.length, 0);
} finally {
clock.restore(); // always; a leaked fake Date poisons the file
}
});
- Two
setImmediateticks afterclock.tick, not one — the timer callback'ssetStatecommits a task later. clock.restore()infinally, every time. A leaked fakeDatebreaks later tests' transitions and timestamps in the same process.- The text-input caret blink uses
setInterval; faking it without ticking leaves the caret timer pending — harmless, but it shows inclock.countTimers(). - Versus
node:testmock timers:t.mock.timers.enable({ apis: ['setTimeout'] })plust.mock.timers.tick(1000)handles the plain-timer case with no dependency and auto-restores. Prefer it; use sinon for fakeDate(the animation clock),clock.countTimers(), or spies and stubs.
pixelmatch
Out of the box. pixelmatch@7.2.0.
Perceptual image diff: two RGBA buffers plus an output buffer in,
changed-pixel count and a painted diff image out. Anti-aliasing aware, zero
dependencies. Render through the harness and read pixels back with
getImageData — no display needed. jest-image-snapshot cannot do this (it
wants a Buffer and receives a window object;
register), but the pipeline under it is four
lines.
ctx.getImageData(x, y, w, h) resolves to a canvas ImageData; hand
pixelmatch its .data, already straight RGBA in a Uint8ClampedArray with
opaque pixels at alpha 255. Readback races the paint: poll a known pixel
before taking the comparison frame.
import fs from 'node:fs';
import pixelmatch from 'pixelmatch';
import { PNG } from 'pngjs';
const ctx = wnd.getContext('2d');
const diff = new PNG({ width: 320, height: 240 });
const before = (await ctx.getImageData(0, 0, 320, 240)).data;
// ...re-render with the changed prop, wait for the repaint...
const after = (await ctx.getImageData(0, 0, 320, 240)).data;
const changed = pixelmatch(before, after, diff.data, 320, 240, {
threshold: 0.1,
});
if (changed > 0) fs.writeFileSync('diff.png', PNG.sync.write(diff));
- Fonts are the determinism problem. Glyph indices travel on the wire,
so pin every family with ntk's
StaticFontSource; never let a snapshot script fall through toFontconfigFontSource. Pinned, two renders of the same tree diff to zero pixels; unpinned, the diff is noise. thresholdis a per-pixel colour distance, not a pass/fail budget; gate on the returned count.- Buffers must have identical dimensions, or pixelmatch throws. After a window resize, re-read rather than crop.
- fontkit version bumps change hinting and advance rounding. Treat pixel diffs as tolerance-based regression tests; gate CI exactly on tree or op-log assertions.
- Under Xvfb, expect nonzero noise from the server's own rendering; the zero-noise floor is specific to the in-process JS server.
react-test-renderer
Works, but do not build on it. react-test-renderer@19.2.8.
React's headless renderer treats any string element type as a host element, so react-x11 vocabulary passes through and coarse JSON snapshots work. But:
- It is deprecated. Importing it prints
react-test-renderer is deprecated; it still ships in 19.2.x, but React discourages new usage. - It bypasses the actual renderer. No prop validation, style
resolution, yoga layout or paint, so a tree that snapshots fine can still
throw
react-x11: <box width=…> is a style propertyor lay out wrong under react-x11.
The harness above is barely more code and exercises the real
createInstance/commitUpdate path with layout. Use react-test-renderer
only as a bridge when porting an existing suite.
- Set
globalThis.IS_REACT_ACT_ENVIRONMENT = truein test setup, or everyactcall warns. toJSON()records the raw props, so snapshots are of input, not of what would render.