You're going to build a tiny test framework — the kind of thing Jest or Vitest is at its core, minus the matchers, the watch mode, and the 200,000 lines of plumbing. The API has two functions: it(name, fn) to register a test case, and run() to execute every registered test and report the results. A test "passes" if its function returns or resolves without throwing; it "fails" if it throws synchronously or returns a promise that rejects.
function createRunner(): {
it: (name: string, fn: () => void | Promise<void>) => void;
run: () => Promise<{
passed: number;
failed: number;
total: number;
results: Array<{ name: string; ok: boolean; error?: unknown }>;
}>;
};
createRunner is a factory. Each call returns a fresh { it, run } pair backed by its own private registry — two runners do not share state.
A single passing test:
const r = createRunner();
r.it('1 + 1 is 2', () => { if (1 + 1 !== 2) throw new Error('bad math'); });
await r.run();
// { passed: 1, failed: 0, total: 1, results: [{ name: '1 + 1 is 2', ok: true }] }
A single failing test:
const r = createRunner();
r.it('always fails', () => { throw new Error('nope'); });
await r.run();
// { passed: 0, failed: 1, total: 1,
// results: [{ name: 'always fails', ok: false, error: Error('nope') }] }
Mixed pass and fail, registration order preserved:
const r = createRunner();
r.it('a', () => {});
r.it('b', () => { throw new Error('x'); });
r.it('c', () => {});
const { passed, failed, total, results } = await r.run();
// passed: 2, failed: 1, total: 3
// results[0].name === 'a', results[1].name === 'b', results[2].name === 'c'
Async tests — resolving counts as pass, rejecting counts as fail:
const r = createRunner();
r.it('async pass', async () => { await Promise.resolve(); });
r.it('async fail', async () => { throw new Error('boom'); });
await r.run();
// { passed: 1, failed: 1, total: 2, ... }
it(name, fn) just stores the case; fn does not run until run() is called. A test with a side effect inside fn should not produce that side effect at registration time.fn that throws synchronously and one that returns a rejecting promise are treated identically — both fail with the thrown/rejected value attached as error.results array must reflect that order. Some tests share mutable state; parallelism would make outcomes non-deterministic.run() never throws. A failing test does not bubble. run() always resolves; the failure shows up as ok: false in the corresponding result, with the thrown value on error.createRunner — not a module-level const tests = []. Two runners created back-to-back must not see each other's tests.