@travetto/test
Version:
Declarative test framework
288 lines (266 loc) • 10.5 kB
text/typescript
import assert from 'node:assert';
import { isPromise } from 'node:util/types';
import { AppError, Class, castTo, castKey, asConstructable } from '@travetto/runtime';
import { ThrowableError, TestConfig, Assertion } from '../model/test';
import { AssertCapture, CaptureAssert } from './capture';
import { AssertUtil } from './util';
import { ASSERT_FN_OPERATOR, OP_MAPPING } from './types';
type StringFields<T> = {
[K in Extract<keyof T, string>]:
(T[K] extends string ? K : never)
}[Extract<keyof T, string>];
const isClass = (e: unknown): e is Class => e === Error || e === AppError || Object.getPrototypeOf(e) !== Object.getPrototypeOf(Function);
/**
* Check assertion
*/
export class AssertCheck {
/**
* Check a given assertion
* @param assertion The basic assertion information
* @param positive Is the check positive or negative
* @param args The arguments passed in
*/
static check(assertion: CaptureAssert, positive: boolean, ...args: unknown[]): void {
let fn = assertion.operator;
assertion.operator = ASSERT_FN_OPERATOR[fn];
// Determine text based on positivity
const common: Record<string, string> = {
state: positive ? 'should' : 'should not'
};
// Invert check for negative
const assertFn = positive ? assert : (x: unknown, msg?: string): unknown => assert(!x, msg);
// Check fn to call
if (fn === 'fail') {
if (args.length > 1) {
[assertion.actual, assertion.expected, assertion.message, assertion.operator] = castTo(args);
} else {
[assertion.message] = castTo(args);
}
} else if (/throw|reject/i.test(fn)) {
assertion.operator = fn;
if (typeof args[1] !== 'string') {
[, assertion.expected, assertion.message] = castTo(args);
} else {
[, assertion.message] = castTo(args);
}
} else if (fn === 'ok' || fn === 'assert') {
fn = assertion.operator = 'ok';
[assertion.actual, assertion.message] = castTo(args);
assertion.expected = { toClean: (): string => positive ? 'truthy' : 'falsy' };
common.state = 'should be';
} else if (fn === 'includes') {
assertion.operator = fn;
[assertion.actual, assertion.expected, assertion.message] = castTo(args);
} else if (fn === 'instanceof') {
assertion.operator = fn;
[assertion.actual, assertion.expected, assertion.message] = castTo(args);
assertion.actual = asConstructable(assertion.actual)?.constructor;
} else { // Handle unknown
assertion.operator = fn ?? '';
[assertion.actual, assertion.expected, assertion.message] = castTo(args);
}
try {
// Clean actual/expected
if (assertion.actual !== undefined) {
assertion.actual = AssertUtil.cleanValue(assertion.actual);
}
if (assertion.expected !== undefined) {
assertion.expected = AssertUtil.cleanValue(assertion.expected);
}
const [actual, expected, message]: [unknown, unknown, string] = castTo(args);
// Actually run the assertion
switch (fn) {
case 'includes': assertFn(castTo<unknown[]>(actual).includes(expected), message); break;
case 'test': assertFn(castTo<RegExp>(expected).test(castTo(actual)), message); break;
case 'instanceof': assertFn(actual instanceof castTo<Class>(expected), message); break;
case 'in': assertFn(castTo<string>(actual) in castTo<object>(expected), message); break;
case 'lessThan': assertFn(castTo<number>(actual) < castTo<number>(expected), message); break;
case 'lessThanEqual': assertFn(castTo<number>(actual) <= castTo<number>(expected), message); break;
case 'greaterThan': assertFn(castTo<number>(actual) > castTo<number>(expected), message); break;
case 'greaterThanEqual': assertFn(castTo<number>(actual) >= castTo<number>(expected), message); break;
case 'ok': assertFn(...castTo<Parameters<typeof assertFn>>(args)); break;
default:
if (fn && assert[castKey<typeof assert>(fn)]) { // Assert call
if (/not/i.test(fn)) {
common.state = 'should not';
}
assert[castTo<'ok'>(fn)].apply(null, castTo(args));
}
}
// Pushing on not error
AssertCapture.add(assertion);
} catch (err) {
// On error, produce the appropriate error message
if (err instanceof assert.AssertionError) {
if (!assertion.message) {
assertion.message = (OP_MAPPING[fn] ?? '{state} be {expected}');
}
assertion.message = assertion.message
.replace(/[{]([A-Za-z]+)[}]/g, (a, k: StringFields<Assertion>) => common[k] || assertion[k]!)
.replace(/not not/g, ''); // Handle double negatives
assertion.error = err;
err.message = assertion.message;
AssertCapture.add(assertion);
}
throw err;
}
}
/**
* Check a given error
* @param shouldThrow Should the test throw anything
* @param err The provided error
*/
static checkError(shouldThrow: ThrowableError | undefined, err: Error | string | undefined): Error | undefined {
if (!shouldThrow) { // If we shouldn't be throwing anything, we are good
return;
} else if (!err) {
return new assert.AssertionError({ message: 'Expected to throw an error, but got nothing' });
} else if (typeof shouldThrow === 'string') {
if (!(err instanceof Error ? err.message : err).includes(shouldThrow)) {
const actual = err instanceof Error ? `'${err.message}'` : `'${err}'`;
return new assert.AssertionError({
message: `Expected error containing text '${shouldThrow}', but got ${actual}`,
actual,
expected: shouldThrow
});
}
} else if (shouldThrow instanceof RegExp) {
if (!shouldThrow.test(typeof err === 'string' ? err : err.message)) {
const actual = err instanceof Error ? `'${err.message}'` : `'${err}'`;
return new assert.AssertionError({
message: `Expected error with message matching '${shouldThrow.source}', but got ${actual}`,
actual,
expected: shouldThrow.source
});
}
} else if (isClass(shouldThrow)) {
if (!(err instanceof shouldThrow)) {
return new assert.AssertionError({
message: `Expected to throw ${shouldThrow.name}, but got ${err}`,
actual: (err ?? 'nothing'),
expected: shouldThrow.name
});
}
} else if (typeof shouldThrow === 'function') {
try {
const res = shouldThrow(err);
if (res === false) {
return new assert.AssertionError({ message: `Checking "${shouldThrow.name}" indicated an invalid error`, actual: err });
} else if (typeof res === 'string') {
return new assert.AssertionError({ message: res, actual: err });
}
} catch (checkErr) {
if (checkErr instanceof assert.AssertionError) {
return checkErr;
} else {
return new assert.AssertionError({ message: `Checking "${shouldThrow.name}" threw an error`, actual: checkErr });
}
}
}
}
static #onError(
positive: boolean,
message: string | undefined,
err: unknown,
missed: Error | undefined,
shouldThrow: ThrowableError | undefined,
assertion: CaptureAssert
): void {
if (!(err instanceof Error)) {
err = new Error(`${err}`);
}
if (!(err instanceof Error)) {
throw err;
}
if (positive) {
missed = new assert.AssertionError({ message: 'Error thrown, but expected no errors' });
missed.stack = err.stack;
}
const resolvedErr = (missed && err) ?? this.checkError(shouldThrow, err);
if (resolvedErr) {
assertion.message = message || missed?.message || resolvedErr.message;
throw (assertion.error = resolvedErr);
}
}
/**
* Check the throw, doesNotThrow behavior of an assertion
* @param assertion The basic assertion information
* @param positive Is the test positive or negative
* @param action Function to run
* @param shouldThrow Should this action throw
* @param message Message to share on failure
*/
static checkThrow(
assertion: CaptureAssert,
positive: boolean,
action: Function,
shouldThrow?: ThrowableError,
message?: string
): void {
let missed: Error | undefined;
try {
action();
if (!positive) {
if (typeof shouldThrow === 'function') {
shouldThrow = shouldThrow.name;
}
throw (missed = new assert.AssertionError({ message: `No error thrown, but expected ${shouldThrow ?? 'an error'}`, expected: shouldThrow ?? 'an error' }));
}
} catch (err) {
this.#onError(positive, message, err, missed, shouldThrow, assertion);
} finally {
AssertCapture.add(assertion);
}
}
/**
* Check the rejects, doesNotReject behavior of an assertion
* @param assertion Basic assertion information
* @param positive Is the test positive or negative
* @param action Async function to run
* @param shouldThrow Should this action reject
* @param message Message to share on failure
*/
static async checkThrowAsync(
assertion: CaptureAssert,
positive: boolean,
action: Function | Promise<unknown>,
shouldThrow?: ThrowableError,
message?: string
): Promise<void> {
let missed: Error | undefined;
try {
if (isPromise(action)) {
await action;
} else {
await action();
}
if (!positive) {
if (typeof shouldThrow === 'function') {
shouldThrow = shouldThrow.name;
}
throw (missed = new assert.AssertionError({ message: `No error thrown, but expected ${shouldThrow ?? 'an error'}`, expected: shouldThrow ?? 'an error' }));
}
} catch (err) {
this.#onError(positive, message, err, missed, shouldThrow, assertion);
} finally {
AssertCapture.add(assertion);
}
}
/**
* Look for any unhandled exceptions
*/
static checkUnhandled(test: TestConfig, err: Error | assert.AssertionError): void {
let line = AssertUtil.getPositionOfError(err, test.sourceImport ?? test.import).line;
if (line === 1) {
line = test.lineStart;
}
AssertCapture.add({
import: test.import,
line,
operator: 'throws',
error: err,
message: err.message,
text: ('operator' in err ? err.operator : '') || '(uncaught)'
});
}
}