Mini Test Framework
Mental model: Registration fills an array. Assertions throw. The runner catches and awaits. Every test framework you have used is those three sentences with more features bolted on.
Level: intermediate · about 24 minutes
Nothing demystifies testing faster than writing the runner. Once you have seen that a pass is "the function returned" and a fail is "the function threw", the tooling stops being a black box and your tests get better, because you know precisely what the runner can and cannot see. You will finish with a kit small enough to read in one sitting and real enough to test with.
The lab ships real suites. Pick one, press Run suite, and rows turn green with measured timings. One case in the first suite is red on purpose, so the diff panel always has something to say: it prints the expected and actual objects side by side. Flip the bug switch and a second suite injects a fault into the code under test, never into the expectations, so six green cases go red with a numeric diff. Every case also has its own Run one button, which is test.only by another name.
The build, decision by decision
- Registration is not execution
describeandtestpush onto arrays held in a closure. Calling your suite file runs no tests at all, which is why a runner can list everything before deciding what to run. - Nesting is one variable, saved and restored
describesetscurrent, calls your body, then puts the old value back in afinally. That is the whole mechanism, and thefinallyis what stops one broken suite from capturing every later registration. - Assertions throw, and that is the entire protocol An assertion helper cannot return
falseand hope you check it. It raises an error, because a throw is the only thing that can stop a function you did not write. - Compare structure, not identity Two separately built objects with the same shape are equal for testing purposes. Your comparison also has to handle the values that break naive equality:
NaN, dates,MapandSet. - The runner catches, and it awaits An async case returns a promise. Forget the
awaitand the case "passes" before it has finished, and the rejection turns up later as an unhandled promise error pointing at nothing. - Hooks run around the case, even when it fails
beforeEachbuilds fresh fixtures into a context object,afterEachcleans up whatever happened. The first error wins, because it is the one that explains the rest. - Throwing is a behaviour, so assert on it
assertThrowsproves a guard clause runs. The matcher can be an error class, a regular expression against the message, or a substring, and a missing matcher means any throw. - The async twin is not optional Without the
awaitinsideassertRejects, the rejection escapes thetryentirely and your assertion cannot see it. Same bug as the runner, one level down. - A spy is the same call signature, plus a memory Record arguments, return values and thrown errors, and expose a
callCountgetter so it is always live. This is how you test that a callback was called without changing the code that calls it. - Report like you mean it A failure message is a teaching moment. Carry the expected and actual values so the report can print a diff, and give every case a way to be run on its own: one case, one assertion, one diff is the fastest debugging loop there is.
The core mechanism
class AssertionError extends Error {
constructor(message, pair) {
super(message);
this.name = 'AssertionError';
if (pair) {
this.expected = pair.expected;
this.actual = pair.actual;
}
}
}
const show = (v) => JSON.stringify(v) ?? String(v);
const same = (a, b) => Object.is(a, b) || show(a) === show(b);
function assertEqual(actual, expected, message = 'Values are not equal') {
if (!same(actual, expected)) throw new AssertionError(message, { expected, actual });
}
const suites = [];
let current = null;
function describe(name, body) {
const suite = { name, cases: [] };
suites.push(suite);
const parent = current;
current = suite;
try {
body();
} finally {
current = parent;
}
}
function test(name, fn) {
current.cases.push({ name, fn }); // registration only, nothing runs
}
async function run() {
let passed = 0;
let failed = 0;
for (const suite of suites) {
console.log(suite.name);
for (const c of suite.cases) {
try {
await c.fn(); // await: an async case returns a promise
passed += 1;
console.log(' pass ' + c.name);
} catch (err) {
failed += 1;
const diff = 'expected' in err ? ' (expected ' + show(err.expected) + ', received ' + show(err.actual) + ')' : '';
console.log(' FAIL ' + c.name + ' -> ' + err.message + diff);
}
}
}
console.log(passed + ' passed, ' + failed + ' failed');
}
const lineTotal = (item) => item.price * item.qty;
describe('cart', () => {
test('a line total is price times quantity', () => {
assertEqual(lineTotal({ price: 4, qty: 3 }), 12);
});
test('compares structure, not identity', () => {
assertEqual({ id: 1, tags: ['a'] }, { id: 1, tags: ['a'] });
});
test('this one fails on purpose', () => {
assertEqual(lineTotal({ price: 4, qty: 3 }), 14, 'the fixture says 14');
});
test('an async case is really awaited', async () => {
await new Promise((resolve) => setTimeout(resolve, 5));
assertEqual(1 + 1, 2);
});
});
console.log('registered', suites[0].cases.length, 'cases, run nothing yet');
await run();A whole test framework: registration, an assertion that throws, and a runner that catches and awaits.
Interactive visualiser: closure. Enable JavaScript to use it.
const results = [];
async function runCase(fn, awaited) {
try {
if (awaited) {
await fn();
} else {
const dropped = fn(); // the bug: a promise nobody waits for
dropped.catch(() => {}); // silenced only so this demo prints cleanly
}
results.push('pass');
} catch (err) {
results.push('fail: ' + err.message);
}
}
const failing = async () => {
await new Promise((resolve) => setTimeout(resolve, 5));
throw new Error('assertion failed after an await');
};
await runCase(failing, false);
await runCase(failing, true);
console.log(results);
// -> [ 'pass', 'fail: assertion failed after an await' ]
// The first row is a lie. One missing await and your suite is decoration.The forgotten await, which makes a failing async case look green.
const suites = [];
let current = null;
function describe(name, body) {
const suite = { name, cases: [] };
suites.push(suite);
current = suite;
body();
// note: current is NOT restored
}
describe('outer', () => {
describe('inner', () => {});
current.cases.push({ name: 'belongs to?' });
});
console.log(suites[0].cases.length, suites[1].cases.length);The inner describe overwrote current and never put it back, so the case registered afterwards lands in inner. Restoring the previous value in a finally is what makes nesting work, and the finally matters because a suite body that throws would otherwise capture every registration that follows.
| Helper | Passes when | Common mistake |
|---|---|---|
assert(cond, msg) | the value is truthy | asserting on a value you never computed |
assertEqual(a, b) | the structures match | expecting reference equality |
assertClose(a, b, eps) | the numbers are within epsilon | comparing floats with assertEqual |
assertThrows(fn, m) | fn throws and matches | passing fn() instead of fn, so it throws before the helper runs |
assertRejects(p, m) | the promise rejects and matches | forgetting to await the helper |
spy(impl) | always, it is a recorder | asserting on callCount before the async call finished |
Extend it
- Add
test.skipand askippedcount in the summary, and show skipped rows in a muted style. - Add a per-case timeout that rejects with a clear message when a case hangs.
- Add
test.only, and make the runner ignore every other case once one exists. - Add a real line diff for multi-line strings, so a failed snapshot points at the line that moved.
- Add fake timers: replace
setTimeoutfor the duration of one case and advance the clock by hand.
Try it yourself
Carry a diff with the error
class AssertionError extends Error {
constructor(message, pair) {
super(message);
this.name = 'AssertionError';
this.hasDiff = Boolean(pair);
if (pair) {
this.expected = pair.expected;
this.actual = pair.actual;
}
}
}
const format = (v) => (typeof v === 'string' ? "'" + v + "'" : JSON.stringify(v) ?? String(v));
function report(fn) {
try {
fn();
console.log('pass');
} catch (err) {
console.log(err.name + ': ' + err.message);
if (err.hasDiff) {
console.log(' expected: ' + format(err.expected));
console.log(' received: ' + format(err.actual));
}
}
}
report(() => {
throw new AssertionError('the saved user does not match the fixture', {
expected: { name: 'Ada', roles: ['admin', 'owner'] },
actual: { name: 'Ada', roles: ['admin'] },
});
});
report(() => {
throw new AssertionError('balance must be positive'); // no pair, so no diff
});
Add an assertClose that reports how far off the number was. Failure messages are the product here.
A spy with a memory
function spy(impl = () => undefined) {
const calls = [];
const fn = (...args) => {
const record = { args, value: undefined };
calls.push(record);
record.value = impl(...args);
return record.value;
};
fn.calls = calls;
fn.calledWith = (...args) => calls.some((c) => JSON.stringify(c.args) === JSON.stringify(args));
Object.defineProperty(fn, 'callCount', { get: () => calls.length });
return fn;
}
const log = spy((line) => '[' + line + ']');
function boot(logger) {
logger('starting');
logger('ready');
}
boot(log);
console.log('callCount', log.callCount); // -> 2
console.log('lastArgs', log.calls[1].args); // -> [ 'ready' ]
console.log('firstValue', log.calls[0].value); // -> [starting]
console.log('calledWith(ready)', log.calledWith('ready')); // -> true
Make the spy record thrown errors too, then write a case that proves a retry helper called your function twice.
Exercises
Write the runner
Write createRunner() returning an object with test(name, fn) and async run(). test only registers. run executes the cases in registration order, awaiting each one, and resolves with { passed, failed, results } where results is an array of { name, status, error } with status of pass or fail and error of null on a pass. A failing case must not stop the ones after it.
An assertion that proves a throw
Write expectThrows(fn, matcher). Call fn. If it does not throw, throw an error whose name is AssertionError. If it throws, check the thrown value against matcher: an omitted matcher accepts any throw, an error class checks instanceof, a RegExp tests the message, and a string checks that the message contains it. On a match, return the thrown error so the caller can assert on it further. On a mismatch, throw an AssertionError.
Check yourself
- What makes a test case pass in a runner like this one?
- It returns without throwing — The runner wraps the case in
try/catch. Returning normally is a pass, throwing is a fail. That is why a case with no assertions at all passes, and why "passing" and "tested" are not the same word. - What does this print?
0 1— Becauserunawaits each case, the rejection lands inside thetryand is recorded as a failure. Remove thatawaitand the answer becomes1 0, with an unhandled rejection appearing in the console a moment later, attached to nothing.- Why does
assertThrowstake a function rather than a value? - Because the helper has to control when the code runs, so it can catch the throw — If you pass a call expression, the throw happens while evaluating the argument, before the helper exists on the stack. Passing the function hands the helper the ability to run it inside its own
try. - Your suite is green. A colleague changes the code under test to always return 0, and it stays green. What is the most likely explanation?
- Both could be true, and both are worth checking — A test that cannot fail is worse than no test, and there are two common ways to get one: assertions that never look at the result, and async cases that were not awaited. Deliberately breaking the code is the cheapest way to prove your suite can see it, which is what the bug switch in the lab exists to teach.
Common mistakes
- Not awaiting an async case, so every one of them passes instantly.
- Forgetting to restore the previous
describescope, so later cases land in the wrong suite. - Comparing objects with
===in an equality helper, so nothing ever matches. - Passing
fn()toassertThrowsinstead offn. - Writing tests with no assertion, which pass forever and prove nothing.
- Asserting on source text or private internals, so a valid refactor turns the suite red.
- Depending on the clock, randomness or the network, which is how a suite becomes flaky and then ignored.
Takeaways
- Registration fills arrays held in a closure. Nothing runs until the runner walks them.
- A throw is the failure protocol, and the thrown error carries the message and the diff.
- The runner must await every case, or async failures vanish.
- Hooks run around the case, and the after hooks run even when it failed.
- Equality for tests is structural, and it has to handle
NaN, dates and collections. - Break the code on purpose to prove the suite can see it.