Tier 5 — Expert

Testing Framework

Build your own test runner: discover test functions, run each in isolation, catch failures, and report — the engine behind Jest and pytest.

🐍 Python 🟨 JavaScript

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

runner.mjs
// 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

runner.test.mjs
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

  1. Add richer matchers: assertEqual(a, b), assertThrows(fn)
  2. Add beforeEach / afterEach hooks that run around each test
  3. Measure and print how long each test took
  4. 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

Try It Yourself