license: public domain CC0
Let’s design this together the way you would design a compiler: minimal surface area, zero magic, explicit semantics, and no reinvention of the universe. A test runner that is small, predictable, and debuggable—not a second operating system.
Below is what the true MVP of a sane JavaScript test runner would look like.
🌱 The world’s simplest test library (that still has good DX)
This is the smallest possible system that:
- runs tests in parallel
- captures logs and prints them only on failure
- supports flags (including via
npm test -- …) - supports naming, skipping, and only
- has predictable, explicit behavior
- avoids reinventing anything (no custom module loader, no VM sandbox, no magic globals)
And it fits in ~150 lines.
Let me show you the architecture first, then the code.
🧩 Architecture: 5 tiny pieces
1. A global registry
Just an array of test definitions.
const tests = [];
export function test(name, fn) { tests.push({ name, fn, skip: false }); }
export function testSkip(name, fn) { tests.push({ name, fn, skip: true }); }
export function testOnly(name, fn) { tests.push({ name, fn, only: true }); }
No classes, no plugins, no lifecycle hooks.
2. A runner that:
- loads test files
- runs them in parallel using
Promise.all - captures logs by temporarily overriding
console.log - prints logs only on failure
- respects
--only,--grep,--verbose, etc.
This is the entire “engine.”
3. A CLI that:
- accepts flags via
process.argv - passes them to the runner
- prints a summary
No custom argument parser—just process.argv.slice(2).
4. A tiny reporter
Just prints:
✓ name
✗ name
error message
captured logs
5. Zero magic test discovery
You explicitly pass files:
npx tinytest tests/**/*.test.js
Or define a default glob in package.json.
🧪 The MVP implementation (readable, minimal, complete)
Here’s the whole thing, conceptually:
// tinytest.js
import fs from "node:fs";
import path from "node:path";
const tests = [];
export function test(name, fn) { tests.push({ name, fn }); }
export function testSkip(name, fn) { tests.push({ name, fn, skip: true }); }
export function testOnly(name, fn) { tests.push({ name, fn, only: true }); }
export async function run(files, flags) {
// Load test files
for (const file of files) {
await import(path.resolve(file));
}
let runnable = tests;
if (tests.some(t => t.only)) {
runnable = tests.filter(t => t.only);
}
if (flags.grep) {
runnable = runnable.filter(t => t.name.includes(flags.grep));
}
const results = await Promise.all(
runnable.map(t => runOne(t, flags))
);
// Summary
const passed = results.filter(r => r.ok).length;
const failed = results.length - passed;
console.log(`\n${passed} passed, ${failed} failed`);
process.exit(failed ? 1 : 0);
}
async function runOne(t, flags) {
if (t.skip) {
console.log(`- ${t.name} (skipped)`);
return { ok: true };
}
const logs = [];
const originalLog = console.log;
console.log = (...args) => logs.push(args.join(" "));
let ok = true;
let error = null;
try {
await t.fn();
} catch (e) {
ok = false;
error = e;
}
console.log = originalLog;
if (ok) {
console.log(`✓ ${t.name}`);
} else {
console.log(`✗ ${t.name}`);
console.log(" " + error);
if (!flags.quietLogs) {
for (const line of logs) {
console.log(" log:", line);
}
}
}
if (flags.verbose) {
for (const line of logs) {
console.log(" log:", line);
}
}
return { ok, error };
}
And the CLI:
// tinytest-cli.js
import { run } from "./tinytest.js";
const args = process.argv.slice(2);
const files = args.filter(a => !a.startsWith("--"));
const flags = {
verbose: args.includes("--verbose"),
quietLogs: args.includes("--quiet-logs"),
grep: extractFlag("--grep="),
};
function extractFlag(prefix) {
const arg = args.find(a => a.startsWith(prefix));
return arg ? arg.slice(prefix.length) : null;
}
run(files, flags);
🎯 Why this is the true MVP
1. No reinvention
- Uses Node’s module loader
- Uses Node’s console
- Uses Node’s parallelism (Promise.all)
- Uses Node’s CLI args
2. Predictable
- No magic file discovery
- No hidden reporters
- No sandboxing
- No plugin system
3. Debuggable
- You can put
console.loganywhere - You can attach a debugger to the CLI
- You can print internal state easily
4. Extensible without becoming a monster
You can add:
- timeouts
- snapshots
- watch mode
- coverage (via c8)
- reporters
- test isolation
…all without rewriting the universe.
🧠 The deeper point
This is what Vitest should have been: a thin wrapper over Node, not a parallel reality.
You can build this in a day, and it will behave more predictably than Jest, Vitest, Mocha, or Ava.
No comments:
Post a Comment