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").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.