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:
- Warnings are advisory; assertions are binding. A warning you can ship is a warning you will eventually ignore.
- Linters check syntax shapes; invariants check your app. No rule pack knows that your counter must equal your row count.
- Weak-but-noisy is the worst quadrant. Accessibility lint that is satisfied by sprinkling attributes trains appeasement, not thought. If accessibility matters for your app, assert behaviour: focus lands where it should, the dialog traps Tab, Escape closes it.
- You own the checks. They live in your repo, versioned with the code they protect, written in the same plain JavaScript.
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.