Build a tiny test runner — a stripped-down Jest or Mocha. The catch: your specs and your setup/cleanup hooks can be synchronous OR asynchronous (returning a promise), and your runner must await every one of them. A real suite connects to a database in beforeEach, hits an API inside a test, and tears the connection down in afterEach — all async. A runner that fires those off without awaiting reports nonsense: tests "pass" before their assertions have even run.
createRunner() returns a runner object. You register specs with test(name, fn) (aliased as it) and lifecycle hooks with beforeEach, afterEach, beforeAll, and afterAll. Calling run() executes the whole suite and resolves to a results summary.
function createRunner(): {
test(name: string, fn: () => void | Promise<void>): void;
it(name: string, fn: () => void | Promise<void>): void; // alias for test
beforeEach(fn: () => void | Promise<void>): void;
afterEach(fn: () => void | Promise<void>): void;
beforeAll(fn: () => void | Promise<void>): void;
afterAll(fn: () => void | Promise<void>): void;
run(): Promise<{
passed: number;
failed: number;
total: number;
results: Array<{
name: string;
status: 'pass' | 'fail';
error?: unknown; // present only when status is 'fail'
}>;
}>;
};
A spec passes if its fn resolves without throwing, and fails if it throws (sync) or rejects (async). run() itself must never reject — a failing test is data in the summary, not an exception.
// Async tests: one passes, one rejects. run() resolves with the tally.
const r = createRunner();
r.test('fetches a user', async () => {
const user = await Promise.resolve({ id: 1 });
if (user.id !== 1) throw new Error('wrong id');
});
r.test('rejects on bad input', async () => {
await Promise.reject(new Error('bad input'));
});
await r.run();
// → {
// passed: 1,
// failed: 1,
// total: 2,
// results: [
// { name: 'fetches a user', status: 'pass' },
// { name: 'rejects on bad input', status: 'fail', error: Error('bad input') },
// ],
// }
// Hooks run in a fixed order around each test.
const r = createRunner();
const log = [];
r.beforeAll(() => log.push('beforeAll'));
r.afterAll(() => log.push('afterAll'));
r.beforeEach(() => log.push('beforeEach'));
r.afterEach(() => log.push('afterEach'));
r.test('first', () => log.push('test:first'));
r.test('second', () => log.push('test:second'));
await r.run();
// log === [
// 'beforeAll',
// 'beforeEach', 'test:first', 'afterEach',
// 'beforeEach', 'test:second', 'afterEach',
// 'afterAll',
// ]
fn or hook may return nothing (sync) or a promise (async). await works on both — await 42 resolves to 42 — so awaiting every call is safe and correct regardless. Don't branch on the return type.afterEach) has fully settled. No overlap. This mirrors Jest/Mocha's default and keeps shared setup state predictable.beforeAll runs once before the first test. Then for each test: beforeEach → the test → afterEach. afterAll runs once after the last test.run(). Capture the thrown value, record status: 'fail' with the error, and move on.afterEach runs even when the test fails. Cleanup is not optional — a test that throws halfway through still needs its database connection closed. Run afterEach whether the test passed or threw.beforeEach throws, that test fails and its body is skipped — but the run continues and run() still resolves.describe blocks, per-test timeouts, .only/.skip filtering, or running tests in parallel. Those are out of scope (see the solution's "Going further").The full solution is part of Premium
Walkthrough, edge cases, complexity notes, and the runnable editor unlock with a Premium subscription.
Submissions are part of Premium
Unlock community code, comments, reactions, and framework-specific approaches.
Build a tiny test runner — a stripped-down Jest or Mocha. The catch: your specs and your setup/cleanup hooks can be synchronous OR asynchronous (returning a promise), and your runner must await every one of them. A real suite connects to a database in beforeEach, hits an API inside a test, and tears the connection down in afterEach — all async. A runner that fires those off without awaiting reports nonsense: tests "pass" before their assertions have even run.
createRunner() returns a runner object. You register specs with test(name, fn) (aliased as it) and lifecycle hooks with beforeEach, afterEach, beforeAll, and afterAll. Calling run() executes the whole suite and resolves to a results summary.
function createRunner(): {
test(name: string, fn: () => void | Promise<void>): void;
it(name: string, fn: () => void | Promise<void>): void; // alias for test
beforeEach(fn: () => void | Promise<void>): void;
afterEach(fn: () => void | Promise<void>): void;
beforeAll(fn: () => void | Promise<void>): void;
afterAll(fn: () => void | Promise<void>): void;
run(): Promise<{
passed: number;
failed: number;
total: number;
results: Array<{
name: string;
status: 'pass' | 'fail';
error?: unknown; // present only when status is 'fail'
}>;
}>;
};
A spec passes if its fn resolves without throwing, and fails if it throws (sync) or rejects (async). run() itself must never reject — a failing test is data in the summary, not an exception.
// Async tests: one passes, one rejects. run() resolves with the tally.
const r = createRunner();
r.test('fetches a user', async () => {
const user = await Promise.resolve({ id: 1 });
if (user.id !== 1) throw new Error('wrong id');
});
r.test('rejects on bad input', async () => {
await Promise.reject(new Error('bad input'));
});
await r.run();
// → {
// passed: 1,
// failed: 1,
// total: 2,
// results: [
// { name: 'fetches a user', status: 'pass' },
// { name: 'rejects on bad input', status: 'fail', error: Error('bad input') },
// ],
// }
// Hooks run in a fixed order around each test.
const r = createRunner();
const log = [];
r.beforeAll(() => log.push('beforeAll'));
r.afterAll(() => log.push('afterAll'));
r.beforeEach(() => log.push('beforeEach'));
r.afterEach(() => log.push('afterEach'));
r.test('first', () => log.push('test:first'));
r.test('second', () => log.push('test:second'));
await r.run();
// log === [
// 'beforeAll',
// 'beforeEach', 'test:first', 'afterEach',
// 'beforeEach', 'test:second', 'afterEach',
// 'afterAll',
// ]
fn or hook may return nothing (sync) or a promise (async). await works on both — await 42 resolves to 42 — so awaiting every call is safe and correct regardless. Don't branch on the return type.afterEach) has fully settled. No overlap. This mirrors Jest/Mocha's default and keeps shared setup state predictable.beforeAll runs once before the first test. Then for each test: beforeEach → the test → afterEach. afterAll runs once after the last test.run(). Capture the thrown value, record status: 'fail' with the error, and move on.afterEach runs even when the test fails. Cleanup is not optional — a test that throws halfway through still needs its database connection closed. Run afterEach whether the test passed or threw.beforeEach throws, that test fails and its body is skipped — but the run continues and run() still resolves.describe blocks, per-test timeouts, .only/.skip filtering, or running tests in parallel. Those are out of scope (see the solution's "Going further").The full solution is part of Premium
Walkthrough, edge cases, complexity notes, and the runnable editor unlock with a Premium subscription.
Submissions are part of Premium
Unlock community code, comments, reactions, and framework-specific approaches.
Unlock the solution & editor
Runnable editor + tests
Solve in the browser with instant Jest feedback.
Detailed solutions
Walkthroughs, edge cases, and complexity notes.
Multi-framework variants
React, Vue, Vanilla, Angular — same question, different stacks.