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.You're building the smallest viable test framework: register cases with it, execute them in order with run, report what passed and what failed.
Every test framework you've used — Jest, Mocha, Vitest, Jasmine — is, at its heart, a list and a loop. The list is the tests you registered; the loop walks the list, calls each function, and remembers which ones threw. Everything else (matchers, mocks, fixtures, watch mode, snapshots) is convenience layered on top.
Your job here is the kernel. it(name, fn) adds a case to a list. run() walks the list, calls each fn, and returns a summary: how many passed, how many failed, and a per-test breakdown. Synchronous tests that throw are failures; asynchronous tests that reject are also failures. Tests that return cleanly — including ones that return non-promise values like 42 — are passes.
The factory matters too. createRunner() must give back an isolated pair, so two runners created back-to-back never see each other's tests.
Two halves: a registry (an array of { name, fn } objects living inside a closure) and a runner (an async function that loops the registry, awaits each fn, and accumulates a results array). it writes to the registry; run reads from it.
The closure is doing the isolation work. Because tests lives inside the function body of createRunner — not at module scope — every call to the factory gets a brand-new array. Two runners are two closures.
The most natural first sketch reaches for forEach and a try/catch:
function createRunner() {
const tests = [];
return {
it(name, fn) { tests.push({ name, fn }); },
run() {
let passed = 0, failed = 0;
const results = [];
tests.forEach(({ name, fn }) => {
try {
fn();
results.push({ name, ok: true });
passed++;
} catch (error) {
results.push({ name, ok: false, error });
failed++;
}
});
return { passed, failed, total: tests.length, results };
},
};
}
It works for sync tests. It is completely wrong for async tests. When fn is async () => { throw new Error('boom') }, calling fn() returns a promise that will reject on the next microtask — it does not throw synchronously. So the try block sees no exception, the catch never fires, and the test is counted as passed even though it's about to reject. The rejection then becomes an UnhandledPromiseRejection somewhere in the console, totally disconnected from the test summary.
A second tempting fix: detect promises with result && typeof result.then === 'function', then chain .then/.catch. That works, but you've now got two code paths — one for sync, one for async — and the bookkeeping (incrementing passed/failed, ordering the results array) has to be duplicated in both. There's a cleaner way.
function createRunner() {
// tests lives in this closure. Two createRunner() calls = two arrays.
const tests = [];
return {
// Registration is pure bookkeeping — never invoke fn here.
it(name, fn) { tests.push({ name, fn }); },
async run() {
const results = [];
let passed = 0, failed = 0;
for (const { name, fn } of tests) {
try {
// The key trick. Promise.resolve().then(fn) does two jobs:
// 1. If fn throws synchronously, the throw happens inside the
// .then callback, which the promise chain captures as a
// rejection — bubbling out to our try/catch via await.
// 2. If fn returns a promise (async fn or explicit), .then
// adopts that promise's eventual state. A rejection
// bubbles to our try/catch the same way.
// One catch handles both shapes — no isPromise check needed.
await Promise.resolve().then(() => fn());
results.push({ name, ok: true });
passed++;
} catch (error) {
// error is whatever was thrown / rejected with — Error, string,
// number, anything. We just carry it through to the caller.
results.push({ name, ok: false, error });
failed++;
}
}
return { passed, failed, total: tests.length, results };
},
};
}
module.exports = { createRunner };
Three shifts from the naive version. Closure-captured tests array so every createRunner() call gets a fresh registry — no module-level globals, no cross-contamination. for…of instead of forEach so await actually pauses between tests (a forEach callback with an await inside fires all callbacks in parallel and returns a useless promise). await Promise.resolve().then(() => fn()) as the universal call site — sync throws and async rejections both arrive at the catch as rejections of the awaited promise, and one branch covers both.
A deliberate choice: this runner is sequential, not parallel. We could replace the loop with Promise.all(tests.map(...)) and finish faster, but tests routinely mutate shared state (a counter, a fixture, the DOM); parallelism would make outcomes depend on scheduling. Sequential is the safe default. Parallel mode is something you opt into per-suite when you've verified tests are pure — see "Going further."
Take this script:
const runner = createRunner();
let setupCount = 0;
runner.it('first sync pass', () => {
setupCount++; // side effect — proves the test ran
});
runner.it('second sync fail', () => {
throw new Error('expected boom');
});
runner.it('third async pass', async () => {
await Promise.resolve();
setupCount++;
});
const summary = await runner.run();
Step by step:
it calls. Each pushes a { name, fn } onto the closure's tests array. setupCount is still 0 — none of the test bodies has executed.runner.run() invoked. We enter the async function. results = [], passed = 0, failed = 0. We start the for…of loop.'first sync pass'. We hit await Promise.resolve().then(() => fn()). fn runs synchronously inside .then, increments setupCount to 1, returns undefined. The .then callback returned cleanly, so the promise resolves; await produces undefined; the try block continues. We push { name: 'first sync pass', ok: true } and bump passed to 1.'second sync fail'. await Promise.resolve().then(() => fn()). fn throws Error('expected boom') inside the .then callback. The promise chain catches that throw and turns it into a rejection. await unwraps the rejection by throwing it into our try. The catch fires with error = Error('expected boom'). We push { name: 'second sync fail', ok: false, error } and bump failed to 1.'third async pass'. fn is async, so calling it returns a promise. The .then callback returns that promise, and .then adopts its state. The async function body runs: await Promise.resolve() yields a microtask, then setupCount++ makes it 2. The async function resolves with undefined. The outer await completes. We push { name: 'third async pass', ok: true } and bump passed to 2.{ passed: 2, failed: 1, total: 3, results: [<three entries in registration order>] }.Final setupCount is 2. The results array preserves registration order — important, because callers often grep through it for r.results.find(r => r.name === 'specific test').
fn() directly in a try/catch is a bug for async tests. A throw inside an async function is a rejection of its returned promise, not a synchronous throw at the call site. Your try never sees it. The fix — await Promise.resolve().then(() => fn()) — pulls the rejection back into the await so the surrounding try catches it.forEach does not wait for await. tests.forEach(async (t) => { await ... }) fires every callback in parallel and resolves the outer call immediately, before any test finishes. Use for…of (or for (let i = 0; ...)), where await actually suspends the loop.Promise.all) would make pass/fail depend on scheduling. Default to sequential; offer parallel only when callers opt in and have verified purity.run() re-executes the tests. If a test mutates external state (like setupCount above), the second run() will mutate it again. The summary itself is the same shape, but side effects compound. If you want memoized results, cache the summary and return it on the second call.const tests = []; at module scope instead of inside createRunner. With module scope, two createRunner() calls share one array — b.it(...) would show up in a.run(). Keep tests inside the factory body.describe(group, fn) blocks — let callers nest tests under a label. The simplest implementation pushes the group name onto a stack at the start of fn, runs fn (which registers child its with the stack-prefixed name), then pops. The summary's name field becomes 'group > test'.beforeEach / afterEach hooks — store hook arrays per group; in run, call each beforeEach before each test's fn and each afterEach after. Hooks that throw should fail the test the same way the test itself does.fn against a new Promise((_, reject) => setTimeout(() => reject(new Error('timeout')), ms)). The losing race throws; your existing catch handles it. Default the timeout to something generous (5s); let callers override per it.for…of with await Promise.all(tests.map(runOne)). Faster, but only safe when tests are pure. The serious test frameworks let you mark suites as parallel: true and otherwise default to sequential..only / .skip — flag tests at registration (it.only('focus this', fn)); during run, if any test was marked only, skip the rest. The mechanics are bookkeeping on the registry — no new control flow.Keep practising the same patterns with a nearby challenge.
No submissions yet
Share your approach and start the discussion.
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.You're building the smallest viable test framework: register cases with it, execute them in order with run, report what passed and what failed.
Every test framework you've used — Jest, Mocha, Vitest, Jasmine — is, at its heart, a list and a loop. The list is the tests you registered; the loop walks the list, calls each function, and remembers which ones threw. Everything else (matchers, mocks, fixtures, watch mode, snapshots) is convenience layered on top.
Your job here is the kernel. it(name, fn) adds a case to a list. run() walks the list, calls each fn, and returns a summary: how many passed, how many failed, and a per-test breakdown. Synchronous tests that throw are failures; asynchronous tests that reject are also failures. Tests that return cleanly — including ones that return non-promise values like 42 — are passes.
The factory matters too. createRunner() must give back an isolated pair, so two runners created back-to-back never see each other's tests.
Two halves: a registry (an array of { name, fn } objects living inside a closure) and a runner (an async function that loops the registry, awaits each fn, and accumulates a results array). it writes to the registry; run reads from it.
The closure is doing the isolation work. Because tests lives inside the function body of createRunner — not at module scope — every call to the factory gets a brand-new array. Two runners are two closures.
The most natural first sketch reaches for forEach and a try/catch:
function createRunner() {
const tests = [];
return {
it(name, fn) { tests.push({ name, fn }); },
run() {
let passed = 0, failed = 0;
const results = [];
tests.forEach(({ name, fn }) => {
try {
fn();
results.push({ name, ok: true });
passed++;
} catch (error) {
results.push({ name, ok: false, error });
failed++;
}
});
return { passed, failed, total: tests.length, results };
},
};
}
It works for sync tests. It is completely wrong for async tests. When fn is async () => { throw new Error('boom') }, calling fn() returns a promise that will reject on the next microtask — it does not throw synchronously. So the try block sees no exception, the catch never fires, and the test is counted as passed even though it's about to reject. The rejection then becomes an UnhandledPromiseRejection somewhere in the console, totally disconnected from the test summary.
A second tempting fix: detect promises with result && typeof result.then === 'function', then chain .then/.catch. That works, but you've now got two code paths — one for sync, one for async — and the bookkeeping (incrementing passed/failed, ordering the results array) has to be duplicated in both. There's a cleaner way.
function createRunner() {
// tests lives in this closure. Two createRunner() calls = two arrays.
const tests = [];
return {
// Registration is pure bookkeeping — never invoke fn here.
it(name, fn) { tests.push({ name, fn }); },
async run() {
const results = [];
let passed = 0, failed = 0;
for (const { name, fn } of tests) {
try {
// The key trick. Promise.resolve().then(fn) does two jobs:
// 1. If fn throws synchronously, the throw happens inside the
// .then callback, which the promise chain captures as a
// rejection — bubbling out to our try/catch via await.
// 2. If fn returns a promise (async fn or explicit), .then
// adopts that promise's eventual state. A rejection
// bubbles to our try/catch the same way.
// One catch handles both shapes — no isPromise check needed.
await Promise.resolve().then(() => fn());
results.push({ name, ok: true });
passed++;
} catch (error) {
// error is whatever was thrown / rejected with — Error, string,
// number, anything. We just carry it through to the caller.
results.push({ name, ok: false, error });
failed++;
}
}
return { passed, failed, total: tests.length, results };
},
};
}
module.exports = { createRunner };
Three shifts from the naive version. Closure-captured tests array so every createRunner() call gets a fresh registry — no module-level globals, no cross-contamination. for…of instead of forEach so await actually pauses between tests (a forEach callback with an await inside fires all callbacks in parallel and returns a useless promise). await Promise.resolve().then(() => fn()) as the universal call site — sync throws and async rejections both arrive at the catch as rejections of the awaited promise, and one branch covers both.
A deliberate choice: this runner is sequential, not parallel. We could replace the loop with Promise.all(tests.map(...)) and finish faster, but tests routinely mutate shared state (a counter, a fixture, the DOM); parallelism would make outcomes depend on scheduling. Sequential is the safe default. Parallel mode is something you opt into per-suite when you've verified tests are pure — see "Going further."
Take this script:
const runner = createRunner();
let setupCount = 0;
runner.it('first sync pass', () => {
setupCount++; // side effect — proves the test ran
});
runner.it('second sync fail', () => {
throw new Error('expected boom');
});
runner.it('third async pass', async () => {
await Promise.resolve();
setupCount++;
});
const summary = await runner.run();
Step by step:
it calls. Each pushes a { name, fn } onto the closure's tests array. setupCount is still 0 — none of the test bodies has executed.runner.run() invoked. We enter the async function. results = [], passed = 0, failed = 0. We start the for…of loop.'first sync pass'. We hit await Promise.resolve().then(() => fn()). fn runs synchronously inside .then, increments setupCount to 1, returns undefined. The .then callback returned cleanly, so the promise resolves; await produces undefined; the try block continues. We push { name: 'first sync pass', ok: true } and bump passed to 1.'second sync fail'. await Promise.resolve().then(() => fn()). fn throws Error('expected boom') inside the .then callback. The promise chain catches that throw and turns it into a rejection. await unwraps the rejection by throwing it into our try. The catch fires with error = Error('expected boom'). We push { name: 'second sync fail', ok: false, error } and bump failed to 1.'third async pass'. fn is async, so calling it returns a promise. The .then callback returns that promise, and .then adopts its state. The async function body runs: await Promise.resolve() yields a microtask, then setupCount++ makes it 2. The async function resolves with undefined. The outer await completes. We push { name: 'third async pass', ok: true } and bump passed to 2.{ passed: 2, failed: 1, total: 3, results: [<three entries in registration order>] }.Final setupCount is 2. The results array preserves registration order — important, because callers often grep through it for r.results.find(r => r.name === 'specific test').
fn() directly in a try/catch is a bug for async tests. A throw inside an async function is a rejection of its returned promise, not a synchronous throw at the call site. Your try never sees it. The fix — await Promise.resolve().then(() => fn()) — pulls the rejection back into the await so the surrounding try catches it.forEach does not wait for await. tests.forEach(async (t) => { await ... }) fires every callback in parallel and resolves the outer call immediately, before any test finishes. Use for…of (or for (let i = 0; ...)), where await actually suspends the loop.Promise.all) would make pass/fail depend on scheduling. Default to sequential; offer parallel only when callers opt in and have verified purity.run() re-executes the tests. If a test mutates external state (like setupCount above), the second run() will mutate it again. The summary itself is the same shape, but side effects compound. If you want memoized results, cache the summary and return it on the second call.const tests = []; at module scope instead of inside createRunner. With module scope, two createRunner() calls share one array — b.it(...) would show up in a.run(). Keep tests inside the factory body.describe(group, fn) blocks — let callers nest tests under a label. The simplest implementation pushes the group name onto a stack at the start of fn, runs fn (which registers child its with the stack-prefixed name), then pops. The summary's name field becomes 'group > test'.beforeEach / afterEach hooks — store hook arrays per group; in run, call each beforeEach before each test's fn and each afterEach after. Hooks that throw should fail the test the same way the test itself does.fn against a new Promise((_, reject) => setTimeout(() => reject(new Error('timeout')), ms)). The losing race throws; your existing catch handles it. Default the timeout to something generous (5s); let callers override per it.for…of with await Promise.all(tests.map(runOne)). Faster, but only safe when tests are pure. The serious test frameworks let you mark suites as parallel: true and otherwise default to sequential..only / .skip — flag tests at registration (it.only('focus this', fn)); during run, if any test was marked only, skip the rest. The mechanics are bookkeeping on the registry — no new control flow.Keep practising the same patterns with a nearby challenge.
No submissions yet
Share your approach and start the discussion.