feat(core): Add new matchers and improve negation messaging
This commit is contained in:
@ -3,6 +3,6 @@
|
||||
*/
|
||||
export const commitinfo = {
|
||||
name: '@push.rocks/smartexpect',
|
||||
version: '2.0.1',
|
||||
version: '2.1.0',
|
||||
description: 'A testing library to manage expectations in code, offering both synchronous and asynchronous assertion methods.'
|
||||
}
|
||||
|
14
ts/index.ts
14
ts/index.ts
@ -1,4 +1,4 @@
|
||||
import { Assertion } from './smartexpect.classes.assertion.js';
|
||||
import { Assertion, AnyMatcher, AnythingMatcher } from './smartexpect.classes.assertion.js';
|
||||
// import type { TMatcher } from './smartexpect.classes.assertion.js'; // unused
|
||||
|
||||
/**
|
||||
@ -24,6 +24,18 @@ export function expect<T>(value: any): Assertion<T> {
|
||||
*/
|
||||
export namespace expect {
|
||||
export const extend = Assertion.extend;
|
||||
/**
|
||||
* Matcher for a specific constructor. Passes if value is instance of given constructor.
|
||||
*/
|
||||
export function any(constructor: any) {
|
||||
return new AnyMatcher(constructor);
|
||||
}
|
||||
/**
|
||||
* Matcher for any defined value (not null or undefined).
|
||||
*/
|
||||
export function anything() {
|
||||
return new AnythingMatcher();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
@ -31,7 +31,9 @@ export class ArrayMatchers<T> {
|
||||
toContainEqual(item: T) {
|
||||
return this.assertion.customAssertion(
|
||||
(value) => (value as T[]).some((e) => plugins.fastDeepEqual(e, item)),
|
||||
`Expected array to contain equal to ${JSON.stringify(item)}`
|
||||
(value) =>
|
||||
`Expected array to contain equal to ${JSON.stringify(item)}` +
|
||||
`\nReceived: ${JSON.stringify(value, null, 2)}`
|
||||
);
|
||||
}
|
||||
|
||||
@ -55,6 +57,12 @@ export class ArrayMatchers<T> {
|
||||
`Expected array to be empty`
|
||||
);
|
||||
}
|
||||
/**
|
||||
* Alias for empty array check
|
||||
*/
|
||||
toBeEmpty() {
|
||||
return this.toBeEmptyArray();
|
||||
}
|
||||
|
||||
toHaveLengthGreaterThan(length: number) {
|
||||
return this.assertion.customAssertion(
|
||||
|
@ -26,4 +26,36 @@ export class FunctionMatchers {
|
||||
`Expected function to throw${expectedError ? ` ${expectedError}` : ''}`
|
||||
);
|
||||
}
|
||||
/**
|
||||
* Assert thrown error message matches the given regex
|
||||
*/
|
||||
toThrowErrorMatching(regex: RegExp) {
|
||||
return this.assertion.customAssertion(
|
||||
(value) => {
|
||||
try {
|
||||
(value as Function)();
|
||||
} catch (e: any) {
|
||||
return regex.test(e && e.message);
|
||||
}
|
||||
return false;
|
||||
},
|
||||
`Expected function to throw error matching ${regex}`
|
||||
);
|
||||
}
|
||||
/**
|
||||
* Assert thrown error message equals the given string
|
||||
*/
|
||||
toThrowErrorWithMessage(expectedMessage: string) {
|
||||
return this.assertion.customAssertion(
|
||||
(value) => {
|
||||
try {
|
||||
(value as Function)();
|
||||
} catch (e: any) {
|
||||
return e && e.message === expectedMessage;
|
||||
}
|
||||
return false;
|
||||
},
|
||||
`Expected function to throw error with message "${expectedMessage}"`
|
||||
);
|
||||
}
|
||||
}
|
@ -53,4 +53,31 @@ export class NumberMatchers {
|
||||
`Expected number to equal ${value}`
|
||||
);
|
||||
}
|
||||
/**
|
||||
* Checks for NaN
|
||||
*/
|
||||
toBeNaN() {
|
||||
return this.assertion.customAssertion(
|
||||
(v) => Number.isNaN(v as number),
|
||||
`Expected number to be NaN`
|
||||
);
|
||||
}
|
||||
/**
|
||||
* Checks for finite number
|
||||
*/
|
||||
toBeFinite() {
|
||||
return this.assertion.customAssertion(
|
||||
(v) => Number.isFinite(v as number),
|
||||
`Expected number to be finite`
|
||||
);
|
||||
}
|
||||
/**
|
||||
* Checks if number is within inclusive range
|
||||
*/
|
||||
toBeWithinRange(min: number, max: number) {
|
||||
return this.assertion.customAssertion(
|
||||
(v) => (v as number) >= min && (v as number) <= max,
|
||||
`Expected number to be within range ${min} - ${max}`
|
||||
);
|
||||
}
|
||||
}
|
@ -1,4 +1,4 @@
|
||||
import { Assertion } from '../smartexpect.classes.assertion.js';
|
||||
import { Assertion, AnyMatcher, AnythingMatcher } from '../smartexpect.classes.assertion.js';
|
||||
import * as plugins from '../plugins.js';
|
||||
|
||||
/**
|
||||
@ -10,21 +10,43 @@ export class ObjectMatchers<T extends object> {
|
||||
toEqual(expected: any) {
|
||||
return this.assertion.customAssertion(
|
||||
(v) => plugins.fastDeepEqual(v, expected),
|
||||
`Expected objects to be deeply equal to ${JSON.stringify(expected)}`
|
||||
(v) =>
|
||||
`Expected objects to be deeply equal to ${JSON.stringify(expected, null, 2)}` +
|
||||
`\nReceived: ${JSON.stringify(v, null, 2)}`
|
||||
);
|
||||
}
|
||||
|
||||
toMatchObject(expected: object) {
|
||||
return this.assertion.customAssertion(
|
||||
(v) => {
|
||||
const obj = v as any;
|
||||
for (const key of Object.keys(expected)) {
|
||||
if (!plugins.fastDeepEqual((v as any)[key], (expected as any)[key])) {
|
||||
const expectedVal = (expected as any)[key];
|
||||
const actualVal = obj[key];
|
||||
if (expectedVal instanceof AnyMatcher) {
|
||||
const ctor = expectedVal.expectedConstructor;
|
||||
if (ctor === Number) {
|
||||
if (typeof actualVal !== 'number') return false;
|
||||
} else if (ctor === String) {
|
||||
if (typeof actualVal !== 'string') return false;
|
||||
} else if (ctor === Boolean) {
|
||||
if (typeof actualVal !== 'boolean') return false;
|
||||
} else {
|
||||
if (!(actualVal instanceof ctor)) return false;
|
||||
}
|
||||
} else if (expectedVal instanceof AnythingMatcher) {
|
||||
if (actualVal === null || actualVal === undefined) {
|
||||
return false;
|
||||
}
|
||||
} else if (!plugins.fastDeepEqual(actualVal, expectedVal)) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
},
|
||||
`Expected object to match properties ${JSON.stringify(expected)}`
|
||||
(v) =>
|
||||
`Expected object to match properties ${JSON.stringify(expected, null, 2)}` +
|
||||
`\nReceived: ${JSON.stringify(v, null, 2)}`
|
||||
);
|
||||
}
|
||||
|
||||
@ -86,4 +108,25 @@ export class ObjectMatchers<T extends object> {
|
||||
`Expected value to be null or undefined`
|
||||
);
|
||||
}
|
||||
/**
|
||||
* Checks own property only (not inherited)
|
||||
*/
|
||||
toHaveOwnProperty(property: string, value?: any) {
|
||||
return this.assertion.customAssertion(
|
||||
(v) => {
|
||||
const obj = v as any;
|
||||
if (!Object.prototype.hasOwnProperty.call(obj, property)) {
|
||||
return false;
|
||||
}
|
||||
if (arguments.length === 2) {
|
||||
return plugins.fastDeepEqual(obj[property], value);
|
||||
}
|
||||
return true;
|
||||
},
|
||||
(v) =>
|
||||
`Expected object to have own property ${property}` +
|
||||
(value !== undefined ? ` with value ${JSON.stringify(value)}` : ``) +
|
||||
`\nReceived: ${JSON.stringify(v, null, 2)}`
|
||||
);
|
||||
}
|
||||
}
|
@ -47,4 +47,13 @@ export class StringMatchers {
|
||||
`Expected string to have length ${length}`
|
||||
);
|
||||
}
|
||||
/**
|
||||
* Alias for empty string check
|
||||
*/
|
||||
toBeEmpty() {
|
||||
return this.assertion.customAssertion(
|
||||
(value) => (value as string).length === 0,
|
||||
`Expected string to be empty`
|
||||
);
|
||||
}
|
||||
}
|
@ -18,6 +18,14 @@ import type { TMatcher, TExecutionType } from './types.js';
|
||||
/**
|
||||
* Core assertion class. Generic over the current value type T.
|
||||
*/
|
||||
/**
|
||||
* Internal matcher classes for expect.any and expect.anything
|
||||
*/
|
||||
export class AnyMatcher {
|
||||
constructor(public expectedConstructor: any) {}
|
||||
}
|
||||
export class AnythingMatcher {}
|
||||
|
||||
export class Assertion<T = unknown> {
|
||||
executionMode: TExecutionType;
|
||||
baseReference: any;
|
||||
@ -33,6 +41,8 @@ export class Assertion<T = unknown> {
|
||||
private isResolves = false;
|
||||
private failMessage: string;
|
||||
private successMessage: string;
|
||||
/** Computed negation failure message for the current assertion */
|
||||
private negativeMessage: string;
|
||||
|
||||
constructor(baseReferenceArg: any, executionModeArg: TExecutionType) {
|
||||
this.baseReference = baseReferenceArg;
|
||||
@ -131,6 +141,16 @@ export class Assertion<T = unknown> {
|
||||
.replace('{value}', formattedValue)
|
||||
.replace('{path}', drillDown || '');
|
||||
}
|
||||
/**
|
||||
* Compute a negated failure message by inserting 'not' into the positive message.
|
||||
*/
|
||||
private computeNegationMessage(message: string): string {
|
||||
const idx = message.indexOf(' to ');
|
||||
if (idx !== -1) {
|
||||
return message.slice(0, idx) + ' not' + message.slice(idx);
|
||||
}
|
||||
return 'Negated: ' + message;
|
||||
}
|
||||
|
||||
public get not() {
|
||||
this.notSetting = true;
|
||||
@ -190,12 +210,14 @@ export class Assertion<T = unknown> {
|
||||
} else {
|
||||
let isOk = false;
|
||||
try {
|
||||
runDirectOrNegated(checkFunction());
|
||||
// attempt positive assertion and expect it to throw
|
||||
checkFunction();
|
||||
} catch (e) {
|
||||
isOk = true;
|
||||
}
|
||||
if (!isOk) {
|
||||
throw new Error(this.failMessage || 'Negated assertion failed');
|
||||
const msg = this.failMessage || this.negativeMessage || 'Negated assertion failed';
|
||||
throw new Error(msg);
|
||||
}
|
||||
}
|
||||
};
|
||||
@ -252,12 +274,18 @@ export class Assertion<T = unknown> {
|
||||
|
||||
public customAssertion(
|
||||
assertionFunction: (value: any) => boolean,
|
||||
errorMessage: string
|
||||
errorMessage: string | ((value: any) => string)
|
||||
) {
|
||||
// Prepare negation message based on the positive error template, if static
|
||||
if (typeof errorMessage === 'string') {
|
||||
this.negativeMessage = this.computeNegationMessage(errorMessage);
|
||||
}
|
||||
return this.runCheck(() => {
|
||||
const value = this.getObjectToTestReference();
|
||||
if (!assertionFunction(value)) {
|
||||
throw new Error(this.failMessage || errorMessage);
|
||||
const msg = this.failMessage
|
||||
|| (typeof errorMessage === 'function' ? errorMessage(value) : errorMessage);
|
||||
throw new Error(msg);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
Reference in New Issue
Block a user