1. The Problem
Build a minimal testing framework. Given a collection of named test functions, run each one, catch any assertion failure or error so one failing test does not stop the others, and report how many passed and failed (with the failure messages).
Why this project? Every test runner — Jest, Mocha, pytest — is fundamentally: discover tests, run each in a try/catch, tally results. Building it demystifies the tools you rely on every day.
2. How to Think About This
Model tests as an object mapping name → function (that is the "discovery" step). Loop over them; run each inside a try/catch so a throw becomes a recorded failure instead of crashing the run. Provide a small assert that throws on a false condition. At the end, tally passes and failures and return a structured report.
3. The Build
// A tiny assertion helper — throws (which the runner catches) on failure.
export function assert(condition, message) {
if (!condition) throw new Error(message || "assertion failed");
}
// Run a { name: testFunction } object and report.
export function runTests(tests) {
let passed = 0, failed = 0;
const failures = [];
for (const [name, testFn] of Object.entries(tests)) {
try {
testFn();
passed++;
console.log("PASS:", name);
} catch (e) {
failed++;
failures.push([name, e.message]);
console.log("FAIL:", name, "\u2014", e.message);
}
}
console.log(`\n${passed} passed, ${failed} failed`);
return { passed, failed, failures };
}
// Example tests to run.
runTests({
test_addition: () => assert(2 + 2 === 4),
test_string: () => assert("ab".toUpperCase() === "AB"),
test_will_fail: () => assert(1 === 2, "one is not two"),
});
4. Test & Prove
import { runTests, assert } from "./runner.mjs";
let pass = 0, fail = 0;
function test(desc, fn) {
try { fn(); console.log(" \u2713", desc); pass++; }
catch (e) { console.log(" \u2717", desc, "\u2014", e.message); fail++; }
}
function check(cond, msg) { if (!cond) throw new Error(msg || "assertion failed"); }
test("counts passes and failures", () => {
const r = runTests({ a: () => {}, b: () => { throw new Error("x"); } });
check(r.passed === 1 && r.failed === 1);
});
test("one failure does not stop later tests", () => {
let ran = false;
runTests({ boom: () => { throw new Error("x"); }, later: () => { ran = true; } });
check(ran === true);
});
test("assert throws on a false condition", () => {
let threw = false;
try { assert(false, "nope"); } catch { threw = true; }
check(threw);
});
test("failure messages are captured", () => {
const r = runTests({ t: () => assert(false, "specific message") });
check(r.failures[0][1] === "specific message");
});
console.log(`\n${pass} passed, ${fail} failed`);
5. The Interface
node runner.mjs PASS: test_addition PASS: test_string FAIL: test_will_fail — one is not two 2 passed, 1 failed
6. Run It
node runner.mjs
node runner.test.mjs🎯 Try this next
- Add richer matchers: assertEqual(a, b), assertThrows(fn)
- Add beforeEach / afterEach hooks that run around each test
- Measure and print how long each test took
- Group tests into suites with a describe(name, fn) helper
What you practised
Test discovery as iterating an object of functions, isolating each run with try/catch, a throwing assert, and building a structured pass/fail report.
Reference: Functions · Error Handling · Objects