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

  1. Registration is not execution describe and test push 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.
  2. Nesting is one variable, saved and restored describe sets current, calls your body, then puts the old value back in a finally. That is the whole mechanism, and the finally is what stops one broken suite from capturing every later registration.
  3. Assertions throw, and that is the entire protocol An assertion helper cannot return false and hope you check it. It raises an error, because a throw is the only thing that can stop a function you did not write.
  4. 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, Map and Set.
  5. The runner catches, and it awaits An async case returns a promise. Forget the await and the case "passes" before it has finished, and the rejection turns up later as an unhandled promise error pointing at nothing.
  6. Hooks run around the case, even when it fails beforeEach builds fresh fixtures into a context object, afterEach cleans up whatever happened. The first error wins, because it is the one that explains the rest.
  7. Throwing is a behaviour, so assert on it assertThrows proves 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.
  8. The async twin is not optional Without the await inside assertRejects, the rejection escapes the try entirely and your assertion cannot see it. Same bug as the runner, one level down.
  9. A spy is the same call signature, plus a memory Record arguments, return values and thrown errors, and expose a callCount getter so it is always live. This is how you test that a callback was called without changing the code that calls it.
  10. 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.

HelperPasses whenCommon mistake
assert(cond, msg)the value is truthyasserting on a value you never computed
assertEqual(a, b)the structures matchexpecting reference equality
assertClose(a, b, eps)the numbers are within epsiloncomparing floats with assertEqual
assertThrows(fn, m)fn throws and matchespassing fn() instead of fn, so it throws before the helper runs
assertRejects(p, m)the promise rejects and matchesforgetting to await the helper
spy(impl)always, it is a recorderasserting on callCount before the async call finished

Extend it

  1. Add test.skip and a skipped count in the summary, and show skipped rows in a muted style.
  2. Add a per-case timeout that rejects with a clear message when a case hangs.
  3. Add test.only, and make the runner ignore every other case once one exists.
  4. Add a real line diff for multi-line strings, so a failed snapshot points at the line that moved.
  5. Add fake timers: replace setTimeout for 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 — Because run awaits each case, the rejection lands inside the try and is recorded as a failure. Remove that await and the answer becomes 1 0, with an unhandled rejection appearing in the console a moment later, attached to nothing.
Why does assertThrows take 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 describe scope, so later cases land in the wrong suite.
  • Comparing objects with === in an equality helper, so nothing ever matches.
  • Passing fn() to assertThrows instead of fn.
  • 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.