Testing: Ship Assertions, Not Linters

mx has no compiler, no build step, and no warning layer. It will render a structurally wrong app without a word of complaint. That is a feature - but it means correctness checking has to live somewhere, and with mx the right place is inside your app, at runtime: concentrated, binary, and executable - not smeared across a toolchain as advisory noise.

Build-time warnings occupy a no-man's land: weak enough to satisfy with a token fix, loud enough to nag on every build. An assertion is different - it either passes or your app refuses to pretend it works. This page shows the patterns that replace an entire linter stack with a few dozen lines you own.

Expose your app's verbs

Make the app drivable without a mouse. Attach a plain object of the app's real operations - the same functions your event handlers call - to window in dev builds. This is not test scaffolding bolted on afterwards; it is your app's API surface, and defining it usually improves the design.

// The app's verbs - handlers call these, and so do tests
window.__app = {
  reset(),                 // return to a known state
  addItem(title),          // mutate like the UI would
  setFilter(text),
  visibleIds(),            // observe: what is actually rendered
  count()                  // observe: derived numbers
};

Every operation must leave the DOM committed when it returns - mx renders synchronously, so this is automatic. A headless browser can now drive your real app through real state transitions with no framework-specific test tooling at all.

Assert invariants, not snapshots

Snapshot tests rot. Invariants don't. After every mutation, the relationships that define correctness should hold: the counter matches the rendered rows, the model matches the DOM, a filtered list only contains matches.

function check() {
  let rows = document.querySelectorAll('.row').length;
  console.assert(rows === state.items.length,
    'DOM rows (' + rows + ') != model (' + state.items.length + ')');
  console.assert(
    document.querySelector('.counter').textContent === String(rows) + ' shown',
    'counter is stale');
}

// Call it after every render in dev
function update() {
  host.render(view());
  if (DEV) check();
}

Because mx re-renders from state, a single check() after render catches whole categories of bugs - stale derived values, model/DOM drift, filter leaks - that per-rule linters never see.

Verify at the DOM, not the model

Asserting on your state object only proves you mutated your state object. The user sees the DOM. Observation methods should read what is actually rendered:

visibleIds() {
  // Read the truth: what rows exist, in document order
  return [...document.querySelectorAll('.row')].map(r => +r.dataset.id);
}

This distinction has caught real bugs: an app can hold perfectly correct state while rendering something else - a scroll position captured after a mutation instead of before it, a row updated in the model but skipped in the render. A DOM-reading assertion fails; a state-reading one lies.

Drive it headlessly

With verbs exposed and invariants asserted, an end-to-end test is a page load plus a script - Playwright, Puppeteer, or raw CDP:

let page = await browser.newPage();
await page.goto('http://localhost:8000/');
await page.waitForFunction(() => window.__app);

let result = await page.evaluate(() => {
  let A = window.__app;
  A.reset();
  A.addItem('buy milk');
  A.setFilter('milk');
  return { visible: A.visibleIds(), count: A.count() };
});

if (result.visible.length !== 1) throw new Error('filter broken');

Ten of these cover more than a thousand unit tests over mocked components, because they exercise the app the way it actually runs: real engine, real DOM, real event order.

Parity testing: two implementations, one truth

The strongest form of this pattern: when behaviour is specified, assert two independent implementations agree - a rewrite against the app it replaces, a keyed variant against a positional one, this month's build against last month's. Drive both through the identical operation sequence and compare every observable:

let ops = [a => a.reset(), a => a.setFilter('auth'), a => a.toggle(42)];

for (let op of ops) {
  op(oldApp); op(newApp);
  assertEqual(oldApp.visibleIds(), newApp.visibleIds());
  assertEqual(oldApp.count(), newApp.count());
}

A parity gate is binary and merciless. It has flagged subtle, real bugs - like scroll anchoring that read "was I at the bottom?" after a mutation instead of before it - that no static analysis could conceive of, because the bug lives in event timing, not syntax.

Free in production

Gate assertions behind a dev flag and let your minifier drop them. A top-level let DEV = location.hostname === "localhost" costs a handful of bytes; wrapping the whole check layer in if (DEV) costs nothing measurable. Your shipped payload stays exactly as small as it was.

Why not just add a linter?

Because a linter answers a different question. Linting is about how the code is written - naming, formatting, forbidden constructs, consistency across a team. It reads source text and never runs it, so it can't know whether the app it describes actually works. Assertions are about what the code does - they execute against the real DOM and fail on real misbehaviour. The two aren't rivals; they just aren't substitutes, and it's the second one that catches bugs:

Nothing stops you from also running a linter - it is one npx away if you want it. The point is what carries the correctness load. With mx, make it assertions: executable, binary, and shipped with the app.