micro-should
Version:
Micro testing framework with familiar syntax, multi-env ESM support & parallel execution
473 lines (431 loc) • 15.6 kB
text/typescript
/*! micro-should - MIT License (c) 2019 Paul Miller (paulmillr.com) */
/**
* Micro testing framework with familiar syntax for browsers, node and others.
* Supports fast mode (parallel), quiet mode (dot reporter), tree structures, CLI self-run auto-detection.
*/
/** A single test. */
export interface StackItem {
message: string;
test?: () => Promise<any> | any;
skip?: boolean;
only?: boolean;
prefix?: string;
childPrefix?: string;
path?: StackItem[];
beforeEach?: () => Promise<void> | void;
afterEach?: () => Promise<void> | void;
children: StackItem[];
}
export interface Options {
PRINT_TREE: boolean;
PRINT_MULTILINE: boolean;
STOP_ON_ERROR: boolean;
QUIET: boolean;
FAST: number;
}
export interface DescribeFunction {
(message: string, testFunctions: () => Promise<any> | any): void;
skip: (message: string, test: () => Promise<any> | any) => void;
}
export interface TestFunction {
(message: string, test: () => Promise<any> | any): void;
/**
* Registers test for "only" queue. When the queue is not empty,
* it would ignore all other tests. Is limited to just one registered test.
*/
only: (message: string, test: () => Promise<any> | any) => void;
/** Registers test, but skips it while running. Can be used instead of commenting out the code. */
skip: (message: string, test: () => Promise<any> | any) => void;
/**
* Runs all registered tests.
* After run, allows to run new tests without duplication: old test queue is cleaned up.
* @param forceSequential - when `true`, disables automatic parallelization even when MSHOULD_FAST=1.
* @returns resolved promise, after all tests have finished
*/
run: (forceSequential?: boolean) => Promise<number>;
/**
* Executes .run() when passed argument is equal to CLI-passed file name.
* Consider a project with 3 test files: a.test.js, b.test.js, all.js.
* all.js imports a.test.js and b.test.js.
* User runs node a.test.js; then node all.js;
* Writing `it.run()` everywhere would fail, because it would try to run same tests twice.
* However, `it.runWhen(import.meta.url)` would succeed, because it detects whether
* current file is launched from CLI and not imported.
* @example
* it.runWhen(import.meta.url)
*/
runWhen: (importMetaUrl: string) => Promise<number | undefined>;
/** Parallel version, using node:cluster. Auto-selected when env var MSHOULD_FAST=1 is set. */
runParallel: () => Promise<number>;
opts: Options;
}
export type EmptyFn = () => Promise<void> | void;
declare const console: any;
const stack: StackItem[] = [{ message: '', children: [] }];
const errorLog: string[] = [];
let onlyStack: StackItem | undefined;
let isRunning = false;
const isCli = 'process' in globalThis;
// Dumb bundlers parse code and assume we have hard dependency on "process". We don't.
// The trick (also import(mod) below) ensures parsers can't see it.
// @ts-ignore
const proc: any = isCli ? globalThis['process'] : undefined;
const opts: Options = {
PRINT_TREE: true,
PRINT_MULTILINE: true,
STOP_ON_ERROR: true,
QUIET: isCli && ['1', 'true'].includes(proc.env.MSHOULD_QUIET),
FAST: parseFast(proc.env.MSHOULD_FAST),
};
function parseFast(str: string | number): number {
if (!isCli) return 0;
let val = str === 'true' ? 1 : Number.parseInt(str as string, 10);
if (!Number.isSafeInteger(val) || val < 1 || val > 256) return 0;
return val;
}
function imp(moduleName: string): any {
return import(moduleName);
}
// String formatting utils
const _c = String.fromCharCode(27); // x1b, control code for terminal colors
const c = {
// colors
red: _c + '[31m',
green: _c + '[32m',
reset: _c + '[0m',
} as const;
// We can write 'pending' test name and then overwrite it with actual result by using ${up}.
// However, if test prints something to STDOUT, last line would get removed.
// We can wrap process.stdout also, but in that case there can be issues with non-node.js env.
// But we already use node modules for parallel cases, so maybe worth investigating.
// const up = _c + '[A';
const LEAF_N = '├─';
const LEAF_E = '│ ';
const LEAF_L = '└─';
const LEAF_S = ' ';
// With padding
// const LEAF_N = '├─ ';
// const LEAF_E = '│ ';
// const LEAF_L = '└─ ';
// const LEAF_S = ' ';
// Colorize string for terminal.
function color(colorName: keyof typeof c, title: string | number) {
return isCli ? `${c[colorName]}${title}${c.reset}` : title.toString();
}
function log(...args: (string | undefined)[]) {
if (opts.QUIET) return logQuiet(false);
// @ts-ignore
console.log(...args);
}
function logQuiet(fail = false) {
if (fail) {
proc.stderr.write(color('red', '!'));
} else {
proc.stdout.write('.');
}
}
function addToErrorLog(title = '', error: any): void {
errorLog.push(`${title} ${error?.stack ? error.stack : error}`);
// @ts-ignore
if (!opts.QUIET) console.error(error); // loud = show error now. quiet = show in the end
}
function formatPrefix(depth: number, prefix: string, isLast: boolean) {
if (depth === 0) return { prefix: '', childPrefix: '' };
return {
prefix: `${prefix}${isLast ? LEAF_L : LEAF_N}`,
childPrefix: `${prefix}${isLast ? LEAF_S : LEAF_E}`,
};
}
async function runTest(
info: StackItem,
printTree: boolean = false,
multiLine: boolean = false,
stopAtError: boolean = true
): Promise<boolean | undefined> {
if (!printTree && multiLine) log();
let title = info.message;
if (typeof info.test !== 'function') throw new Error('internal test error: invalid info.test');
let messages: string[] = [];
let onlyStackToLog: string[] = [];
let beforeEachFns: Function[] = [];
let afterEachFns: Function[] = []; // will be reversed
for (const parent of info.path!) {
messages.push(parent.message);
if (printTree && info.only) onlyStackToLog.push(`${parent.prefix}${parent.message}`);
if (parent.beforeEach) beforeEachFns.push(parent.beforeEach);
if (parent.afterEach) afterEachFns.push(parent.afterEach);
}
afterEachFns.reverse();
if (onlyStackToLog.length) onlyStackToLog.forEach((l) => log(l));
const path = messages.slice().concat(title).join('/');
// Skip is always single-line
if (multiLine && !info.skip && !opts.QUIET) {
log(printTree ? `${info.prefix}${title}: ☆` : `☆ ${path}:`);
} else if (info.skip) {
log(printTree ? `${info.prefix}${title} (skip)` : `☆ ${path} (skip)`);
return true;
}
// variables influencing state / print output:
// fail = true | false
// quiet = true | false
// printTree = true | false (true when fast mode)
// stopAtError = true | false
function formatTaskDone(fail = false, suffix = '') {
const symbol = fail ? '☓' : '✓';
const clr = fail ? 'red' : 'green';
const title_ = suffix ? [title, suffix].join('/') : title;
const full_ = suffix ? [path, suffix].join('/') : path;
return printTree
? `${info.childPrefix}` + color(clr, `${title_}: ${symbol}`)
: color(clr, `${symbol} ${full_}`);
}
// Emit
function logErrorStack(suffix: string) {
if (opts.QUIET) {
// when quiet, either stop & log trace; or log !
if (stopAtError) {
// stop, log whole path and trace
console.error();
console.error(formatTaskDone(true, suffix));
} else {
// log !, continue
logQuiet(true);
}
} else {
// when loud, log (maybe formatted) tree structure
console.error(formatTaskDone(true, suffix));
}
}
// Run beforeEach hooks from parent contexts
for (const beforeFn of beforeEachFns) {
try {
await beforeFn();
} catch (cause) {
logErrorStack('beforeEach');
// @ts-ignore
if (stopAtError) throw cause;
else addToErrorLog(`${path}/beforeEach`, cause);
return false;
}
}
// Run test task
try {
// possible to do let result = ... in the future to save test outputs
await info.test();
} catch (cause) {
logErrorStack('');
// @ts-ignore
if (stopAtError) throw cause;
else addToErrorLog(`${path}`, cause);
return false;
}
// Run afterEach hooks from parent contexts (in reverse order)
for (const afterFn of afterEachFns) {
try {
await afterFn();
} catch (cause) {
logErrorStack('afterEach');
// @ts-ignore
if (stopAtError) throw cause;
else addToErrorLog(`${path}/afterEach`, cause);
return false;
}
}
log(formatTaskDone());
return true;
}
function stackTop() {
return stack[stack.length - 1];
}
function stackPop() {
return stack.pop();
}
function stackAdd(info: { message: any; skip?: boolean }) {
const c = { ...info, children: [] };
stackTop().children.push(c);
stack.push(c);
}
function stackFlatten(elm: StackItem): StackItem[] {
const out: StackItem[] = [];
const walk = (
elm: StackItem,
depth = 0,
isLast = false,
prevPrefix = '',
path: StackItem[] = []
) => {
const { prefix, childPrefix } = formatPrefix(depth, prevPrefix, isLast);
const newElm: StackItem = { ...elm, prefix, childPrefix, path };
out.push(newElm);
path = path.concat([newElm]); // Save prefixes so we can print path in 'only' case
const chl = elm.children;
for (let i = 0; i < chl.length; i++)
walk(chl[i], depth + 1, i === chl.length - 1, childPrefix, path);
};
// Skip root
for (const child of elm.children) walk(child);
return out;
}
const describe: DescribeFunction = (message: any, fn: EmptyFn): void => {
stackAdd({ message });
fn(); // Run function in the context of current stack path
stackPop();
};
function describeSkip(message: any, _fn: EmptyFn): void {
stackAdd({ message, skip: true });
// fn();
stackPop();
}
describe.skip = describeSkip;
function beforeEach(fn: EmptyFn): void {
stackTop().beforeEach = fn;
}
function afterEach(fn: EmptyFn): void {
stackTop().afterEach = fn;
}
function register(info: StackItem) {
stackAdd(info);
stackPop(); // remove from stack since there are no children
}
function cloneAndReset() {
let items = stackFlatten(stack[0]).slice();
if (onlyStack) items = items.filter((i) => i.test === onlyStack!.test);
stack.splice(0, stack.length);
stack.push({ message: '', children: [] } as unknown as StackItem);
onlyStack = undefined;
return items;
}
// 123 tests (+quiet +fast-x8) started...
function begin(total: number, workers?: number | undefined) {
const features = [opts.QUIET ? '+quiet' : '', workers ? `+fast-x${workers}` : ''].filter(
(a) => a
);
const modes = features.length ? `(${features.join(' ')}) ` : '';
// No need to log stats when tests fit on one screen
if (total > 32) console.log(`${color('green', total.toString())} tests ${modes}started...`);
}
function finalize(total: number, startTime: number) {
isRunning = false;
console.log();
if (opts.QUIET) console.log();
const totalFailed = errorLog.length;
const sec = Math.round((Date.now() - startTime) / 1000);
const tdiff =
sec < 2 ? '' : sec < 60 ? `in ${sec} sec` : `in ${Math.floor(sec / 60)} min ${sec % 60} sec`;
if (totalFailed) {
if (opts.QUIET) {
errorLog.forEach((err) => console.error(err));
}
if (errorLog.length > 0)
throw new Error(`${errorLog.length} of ${total} tests failed ${tdiff}`);
} else {
console.log(`${color('green', total)} tests passed ${tdiff}`);
}
return total;
}
async function runTests(forceSequential = false) {
if (isRunning) throw new Error('should.run() has already been called, wait for end');
if (!forceSequential && opts.FAST) return runTestsInParallel();
isRunning = true;
const tasks = cloneAndReset();
const total = tasks.filter((i) => !!i.test).length;
begin(total);
const startTime = Date.now();
for (const test of tasks) {
if (opts.PRINT_TREE && !test.test) log(`${test.prefix}${test.message}`);
if (!test.test) continue;
await runTest(test, opts.PRINT_TREE, opts.PRINT_MULTILINE, opts.STOP_ON_ERROR);
}
return finalize(total, startTime);
}
async function runTestsWhen(importMetaUrl: string) {
if (!isCli) throw new Error('cannot be used outside of CLI');
// @ts-ignore
const { pathToFileURL } = await imp('node:url');
return importMetaUrl === pathToFileURL(proc.argv[1]).href ? runTests() : undefined;
}
// Doesn't support tree and multiline
// TODO: support beforeEach, afterEach
async function runTestsInParallel(): Promise<number> {
if (!isCli) throw new Error('must run in cli');
if ('deno' in proc.versions) return runTests(true);
const tasks = cloneAndReset().filter((i) => !!i.test); // Filter describe elements
const total = tasks.length;
const startTime = Date.now();
const runTestPar = (info: StackItem) => runTest(info, false, false, opts.STOP_ON_ERROR);
let cluster: any, err: any;
let totalW = opts.FAST;
try {
// @ts-ignore
cluster = (await imp('node:cluster')).default;
// @ts-ignore
if (totalW === 1) totalW = (await imp('node:os')).cpus().length;
} catch (error) {
err = error;
}
if (!cluster || !parseFast(totalW)) throw new Error('parallel tests are not supported: ' + err);
// the code is ran in workers
if (!cluster.isPrimary) {
proc.on('error', (err: any) => console.log('internal error:', 'child crashed?', err));
let tasksDone = 0;
const id = cluster.worker.id;
const strId = 'W' + id;
for (let i = 0; i < tasks.length; i++) {
if (i % totalW !== id - 1) continue;
await runTestPar(tasks[i]);
tasksDone++;
}
proc.send({ name: 'parallelTests', worker: strId, tasksDone, errorLog });
proc.exit();
}
// the code is ran in primary proc
return await new Promise((resolve, reject) => {
begin(total, totalW);
console.log();
const workers: any[] = [];
let tasksDone = 0;
let workersDone = 0;
cluster.on('exit', (worker: { id: any; process: { pid: any } }, code: any) => {
if (!code) return;
const msg = `Worker W${worker.id} (pid: ${worker.process.pid}) crashed with code: ${code}`;
// @ts-ignore
console.error(color('red', msg));
workers.forEach((w) => w.kill()); // Shutdown other workers
reject(new Error(msg));
});
for (let i = 0; i < totalW; i++) {
const worker = cluster.fork();
workers.push(worker);
worker.on('error', (err: any) => reject(err));
worker.on('message', (msg: { name: string; tasksDone: number; errorLog: string[] }) => {
if (!msg || msg.name !== 'parallelTests') return;
workersDone++;
tasksDone += msg.tasksDone;
msg.errorLog.forEach((item) => errorLog.push(item));
if (workersDone !== totalW) return;
if (tasksDone !== total)
return reject(new Error('internal error: not all tasks have been completed'));
// @ts-ignore
globalThis.setTimeout(() => {
resolve(finalize(total, startTime));
}, 0);
});
}
});
}
/**
* Registers test for future running.
* Would not auto-run, needs `it.run()` to be called at some point.
* See {@link TestFunction} for methods.
* @param message test title
* @param test function, may be async
*/
const it: TestFunction = (message, test) => register({ message, test, children: [] });
it.only = (message, test) => register((onlyStack = { message, test, children: [], only: true }));
it.skip = (message, test) => register({ message, test, children: [], skip: true });
it.run = runTests;
it.runWhen = runTestsWhen;
it.runParallel = runTestsInParallel;
it.opts = opts;
export { afterEach, beforeEach, describe, it, it as should };
export default it;