UNPKG

@travetto/test

Version:

Declarative test framework

288 lines (266 loc) 10.5 kB
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)' }); } }