hTest Tests
Two modes: JS-first for logic (runs in Node or browser, CI-compatible) and HTML-first for UI (runs in browser only, reactive).
Full reference: https://htest.dev. This skill summarizes common patterns and pitfalls — when in doubt about a property's exact semantics, the docs are the source of truth.
JS-First Mode
Tests are nested object literals; properties cascade from parent to child, so you only specify what differs.
Core Pattern
import { myFn } from "../src/my-fn.js";
export default {
name: "myFn()",
run: myFn,
tests: [
{
name: "descriptive name",
arg: "input",
expect: "output",
},
],
};
Best Practices
Two principles guide everything below. Come back to these whenever a specific rule seems wrong for your test:
-
What abstraction makes the tests easier to understand for humans? If a rule below would impose an abstraction that makes a test harder to read, the rule is wrong for that test. Declarativeness exists to remove repetitive noise — not to force inherently imperative code into a data shape.
-
Write the tests first; scaffold around them. Express each test as data (
name,arg,expect) in whatever shape gives you maximum signal-to-noise on the intent. DRY isn't the endgame — it often correlates with clarity, but not always. Writerun(),beforeEach(), and other scaffolding at the end, derived from what the tests need. When deciding whether to factorrun()out or leave it per-test, write the test both ways and pick the version that expresses intent better.
arg+expectis the default for value transforms. When a test naturally fits "given input, expect output," express it that way. Don't bend stateful or imperative tests into this shape. Liftingrunto a shared level pays off only when the abstraction simplifies. If it would require encoding sequential side effects asargdata — an array of functions whose job is to mutate state — leaverunper-test: the imperative content stays where the reader expects it. When several tests genuinely share execution, group those under a focusedrunrather than forcing the whole file under one root-level shape.- Structure mirrors API — nest test groups to match your module's shape. One export = one top-level group. The hierarchy should let you locate any test by navigating the API.
- Check the shape, not every byte — use
check: { subset: true }when you only care about specific properties. Tests that over-specifyexpectbreak for wrong reasons. - Each test = one unique branch — don't test the same code path twice with different data. A passing duplicate isn't a safety net.
- Don't test the language — spread, object merge, array methods: those are JavaScript, not your code. Only test behavior your function owns.
Common Mistakes
| Mistake | Fix |
|---|---|
Custom run in every test with minor differences |
Extract shared run at parent and push the variations into arg/args. But if each test's run has unique imperative content (writes, sequences), per-test is correct — don't impose uniformity. |
expect: true with boolean logic in run |
Usually a smell — return the actual value. Exception: when the test name is a yes/no question or assertion (e.g. "Inputs are sorted", "Cache hit on repeat read"), the boolean is the literal answer to the name and expect: true is fine. |
Reading values via this.arg.X / this.args[0] inside run |
The parameter list IS where arg/args arrive. Name and unpack them however fits the case: run ({ x }), run ([first, ...rest]), run (args) { let first = args[0]; }, etc. Reserve this for this.data (lifecycle state) and instance props. |
Inline // comment explaining what the test verifies |
Use the description field — that's its home as structured metadata next to the test. Keep it brief (a sentence, maybe two): add what the name can't carry — an issue reference, a subtle invariant, a non-obvious edge case. Don't write a paragraph and don't restate the name in prose. Reserve comments for short notes about mechanics. |
Setup in beforeAll but used in arg expressions |
Move to module top level — arg/expect evaluate at import time, before hooks |
new Instance() in both arg and expect |
Share one instance variable — default check uses === at leaves, so two structurally-equal instances fail. See Reference Equality for Instances. |
data for values only used in run |
Inline the value directly |
args: [1, 2, 3] when function expects one array |
Use arg: [1, 2, 3] — args spreads elements as separate arguments |
Test Object Properties
All properties are optional and inherit from parent to child.
Execution
| Property | Description |
|---|---|
run |
Function to execute. Called via run.apply(testInstance, args). Inherited from parent — define once, never repeat. If omitted, result defaults to args[0] |
arg |
Single argument passed to run. Can be any value |
args |
Array of arguments passed to run. Non-arrays auto-wrapped. arg takes precedence |
expect |
Expected result. Deep equality by default |
getExpect |
Function to generate expected value dynamically. Called like run: getExpect.apply(test, args). Inherited. expect takes precedence if both are set |
throws |
true (any error), false (asserts no error thrown), Error subclass (TypeError), or predicate e => e.code === "ENOENT". Inherited |
Structure
| Property | Description |
|---|---|
name |
Test/group label. Also accessible via this.name and this.parent.name in run. If a function, it's used as getName instead |
getName |
Function to generate names dynamically. Called like run: getName.apply(test, args). Inherited (unlike name) |
description |
Human-readable explanation of the test's intent or edge case. Ignored by the runner |
tests |
Array of child tests. If present, this is a group (parent); if absent, a leaf test |
data |
Inherited object accessible via this.data. Child data merges with parent |
skip |
true or function returning truthy to skip. Inherited — setting on a parent skips all children |
Comparison
| Property | Description |
|---|---|
check |
Custom assertion: function (actual, expect) => boolean or object { deep, looseTypes, subset, epsilon }. Inherited |
map |
Transform result before comparison. Applies to both result AND expected. Inherited |
Default check is deep(shallowEquals()) — recursive === at leaf level.
check Object Shorthand
Pass an object instead of a function to configure built-in comparison behavior:
| Option | Default | Behavior |
|---|---|---|
subset |
false |
Extra properties in actual are OK; undefined expected values are skipped |
epsilon |
0 |
Numeric tolerance: passes if Math.abs(actual - expect) <= epsilon |
looseTypes |
false |
Use == instead of === at leaf level |
deep |
true |
Recurse into objects/arrays. Set false for shallow-only |
subset: true is the most useful option. Use it when results may contain extra fields you don't want to validate:
{
run: parseItem,
check: { subset: true }, // inherited by all children
tests: [
{ arg: "foo:bar", expect: { key: "foo", value: "bar" } },
// passes even if parseItem() also returns { raw: "foo:bar", index: 0, ... }
],
}
Combine options freely:
check: { epsilon: 0.005 } // numeric tolerance, deep (default)
check: { subset: true, epsilon: 0.0001 } // partial match + tolerance
check: { deep: false, looseTypes: true } // shallow loose equality
For a custom comparison, use an inline function — no import needed:
{
check: (actual, expect) => Math.abs(actual - expect) < 0.01,
tests: [...],
}
For pre-built utilities and composition, import the check module:
import check from "htest.dev/check";
export default {
run: computeValue,
check: check.proximity({ epsilon: 0.005 }), // pre-built deep + tolerance
// check: check.deep(check.proximity({ epsilon: 0.005 })), // composed
tests: [...],
};
Lifecycle
| Property | Description |
|---|---|
beforeEach / afterEach |
Run before/after each test. Receive no parameters — access state via this.data, this.arg, etc. Inherited. afterEach runs even if the test throws. Sync or async |
beforeAll / afterAll |
Run before/after all tests in the group where defined. Receive no parameters — access state via this.data, this.arg. Not inherited |
this Inside run
run is called with this set to the Test instance. Available properties:
this.args— argument arraythis.data— inherited data objectthis.name— test namethis.level— nesting depth (root = 0). Useful ingetNamefor depth-aware labelsthis.parent— parent test/group. Useful for extending the parent'srunin a child: callthis.parent.run(...args)first, then transform the resultthis.expect— expected value
Async
hTest auto-awaits Promises returned from run. No special handling needed:
{
async run () {
let result = await fetchSomething();
return result.value;
},
expect: 42,
}
Parallel Execution
Tests at the same nesting level run in parallel (Promise.allSettled). Don't rely on execution order or shared mutable state between sibling tests:
let counter = 0;
// ❌ Sibling tests run in parallel — counter increments are nondeterministic
{
tests: [
{ run: () => ++counter, expect: 1 },
{ run: () => ++counter, expect: 2 },
],
}
// ✅ Each test is self-contained
{
tests: [
{ run: () => "foo".toUpperCase(), expect: "FOO" },
{ run: () => "bar".toUpperCase(), expect: "BAR" },
],
}
Inheritance
Properties cascade from parent to child. Children only specify what differs:
{
run: parse, // Shared by all children
tests: [
{ arg: "foo", expect: { type: "foo" } }, // inherits run
{ arg: "bar baz", expect: { type: "bar", mod: "baz" } },
],
}
Override run at group level when a subset needs different logic:
let item = { id: 1 };
{
name: "Promise",
async run (arg) {
let result = transform(Promise.resolve(arg));
return { value: await result.value };
},
tests: [
{ arg: item, expect: { value: item } }, // inherits async run
{ arg: "foo", expect: { value: "bar" } },
],
}
Reference Equality for Instances
hTest's default check uses === at leaf level. Share the same instance between arg and expect:
let config = new Map([["key", "value"]]);
// ✅ Same instance — passes
{ arg: config, expect: { result: config } }
// ❌ Different instances — fails
{ arg: new Map([["key", "value"]]), expect: { result: new Map([["key", "value"]]) } }
map — Both Sides
map transforms both result and expected before comparison. When either is an array, map is applied element-wise. Design so expected values pass through unchanged:
{
map (result) {
if (result?.items instanceof Set) {
return { ...result, items: [...result.items] };
}
return result;
},
tests: [
{ arg: { items: new Set(["a", "b"]) }, expect: { items: ["a", "b"] } },
],
}
data — Shared Fixtures
data is a cascading object accessible to run and any setup hook via this.data. A test's data merges with its parent's, so common values can live at a higher level and be overridden where needed. The full definition lives in the docs (define / data) — what follows is patterns that come up in practice.
Good use: shared object, accessed via this.data
export default {
data: {
user: new User({ name: "alice", role: "admin" }),
},
run(method) {
return this.data.user[method]();
},
tests: [
{ arg: "isAdmin", expect: true },
{ arg: "getLabel", expect: "Admin: alice" },
],
};
Good use: nested groups vary one config value
export default {
run(arg) {
return transform(arg, this.data.mode);
},
tests: [
{
name: "strict mode",
data: { mode: "strict" },
tests: [{ arg: "foo", expect: "FOO" }],
},
{
name: "loose mode",
data: { mode: "loose" },
tests: [{ arg: "foo", expect: "foo" }],
},
],
};
Good use: setup hook builds a per-test fixture for run
When setup is more involved than a literal value — instantiating a class, opening a connection, assembling a DOM tree — do it in beforeEach and stash the result on this.data so run (and afterEach) can use it. beforeEach/afterEach are invoked with no arguments, so they read this.arg and write this.data. run still receives its args via the parameter list.
export default {
beforeEach () {
let parser = new Parser(this.arg.options);
Object.assign(this.data, { parser });
},
afterEach () {
this.data.parser.close();
},
run ({ input }) {
return this.data.parser.parse(input);
},
tests: [
{
name: "strict mode rejects extra whitespace",
arg: { options: { strict: true }, input: "1, 2, 3" },
throws: SyntaxError,
},
{
name: "loose mode parses anyway",
arg: { options: { strict: false }, input: "1, 2, 3" },
expect: [1, 2, 3],
},
],
};
Don't use data when the value is only used in one place — inline it instead:
// ❌ Unnecessary indirection
data: { prefix: "https://example.com/" },
run (arg) { return fn(arg, this.data.prefix); },
// ✅ Just inline it
run (arg) { return fn(arg, "https://example.com/"); },
Module-Level Setup
arg and expect expressions are evaluated at import time — during Test construction, before any hooks run. A variable that will be assigned in beforeAll is still undefined when arg captures it:
// ❌ db is undefined at import time — beforeAll hasn't run yet
let db;
export default {
beforeAll () { db = createFakeDatabase(); },
tests: [
{ arg: db, expect: { rows: 0 } }, // arg captures undefined, not the db object
],
};
// ✅ Module top level — exists when arg expressions evaluate
let db = createFakeDatabase();
export default {
run: query,
tests: [
{ arg: db, expect: { rows: 0 } },
],
};
This applies to any setup that arg/expect depends on: mock classes, shared fixtures, or preconfigured instances.
Data-Driven Test Generation
Test arrays are plain JS — you can build them with .map(), .flatMap(), or any array method before exporting:
const cases = [
{ input: "foo", expected: "FOO" },
{ input: "bar", expected: "BAR" },
];
export default {
run: toUpperCase,
tests: cases.map(({ input, expected }) => ({
arg: input,
expect: expected,
})),
};
Useful for tests driven by an external data file, a registry, or computed expected values:
// Compute expected from a reference implementation
tests: rawCases.map(c => ({ ...c, expect: referenceImpl(c.arg) })),
Running Tests
npx htest test/file.js # Single file
npx htest test/ # All JS in directory (not recursive, skips index*)
npx htest test/index.js # Use index files for recursive aggregation
npx htest test/file.js --ci # Force non-interactive mode (automatic in non-TTY environments)
npx htest test/file.js --verbose # Show all tests, including passing
HTML-First Mode
Use for UI-heavy code that needs a real DOM — web components, layout, interaction. Tests run in the browser only (not CI-compatible). Can be mixed with JS-first tests in the same suite.
Setup
Include hTest in an HTML file — no build step needed:
<link rel="stylesheet" href="https://htest.dev/htest.css" crossorigin />
<script src="https://htest.dev/htest.js" crossorigin></script>
Open the file directly in a browser. hTest bootstraps on DOMContentLoaded.
Reftests — Core Pattern
Each <table class="reftest"> contains tests as <tr> rows. The last two cells are compared (output | expected). When they match → pass; when they don't → fail.
<section>
<h1>My Component</h1>
<table class="reftest">
<tr>
<td><my-component value="42"></my-component></td>
<td>42</td>
</tr>
</table>
</section>
3-column tables add a data/setup column before output and expected (first column is ignored in matching):
<tr>
<td><!-- setup / ignored --></td>
<td><!-- output --></td>
<td><!-- expected --></td>
</tr>
data-test — Comparator
Default is "contents" — compares trimmed text content of the last two cells.
| Value | Behavior |
|---|---|
"contents" |
Compare trimmed text content (default) |
"selector" |
Reference cell is a CSS selector the output must match; add class="not" to negate |
"numbers" |
Extract and compare numbers only; use data-epsilon for tolerance |
"dom" |
Compare nodeName, attributes, and text content structurally |
Can also be a JS expression or global function name receiving the cells as arguments.
data-click — Simulate Interaction
Automates clicks for reactive tests. Syntax: [selector] [wait Ns] [after eventname] [N times]
<!-- Click the button, then evaluate tests -->
<table class="reftest" data-click="button">
<!-- Click after load, with delay -->
<tr data-click="button wait 1s after load"></tr>
</table>
data-error — Expected Throws
<tr data-error>
<td>
<script>
throw new TypeError("bad");
</script>
</td>
<td>TypeError</td>
</tr>
Isolation / Debugging
- Click a
<section>heading link → show only that section's tests - Alt + double-click a
<tr>→ isolate that single test - "Show all tests" link appears to restore the full suite
When to Use Each Mode
| JS-first | HTML-first | |
|---|---|---|
| Pure logic, utilities | ✅ | — |
| CI pipeline | ✅ | ❌ |
| Real DOM / web components | — | ✅ |
| Simulated interactions | — | ✅ |
| Reactive re-evaluation | — | ✅ |