Compare commits

...

4 Commits

17 changed files with 561 additions and 60 deletions

View File

@ -1,5 +1,21 @@
# Changelog # Changelog
## 2025-04-28 - 2.1.0 - feat(core)
Add new matchers and improve negation messaging
- Added expect.any() and expect.anything() matchers for enhanced object pattern matching
- Introduced new number matchers: toBeNaN(), toBeFinite(), and toBeWithinRange()
- Implemented alias toBeEmpty() for both string and array matchers
- Enhanced function matchers with toThrowErrorMatching() and toThrowErrorWithMessage()
- Improved negation messaging to provide clearer failure messages (e.g. 'Expected 5 not to be greater than 3')
- Enhanced object assertions with a toHaveOwnProperty() shorthand that outputs unified diff-style messages
## 2025-04-28 - 2.0.1 - fix(assertion-matchers)
Refactor matcher implementations to consistently use customAssertion for improved consistency and clarity.
- Updated ArrayMatchers, BooleanMatchers, DateMatchers, FunctionMatchers, NumberMatchers, ObjectMatchers, StringMatchers, and TypeMatchers to use customAssertion directly.
- Aligned Assertion class aliases to delegate to the namespaced matchers with the new customAssertion pattern.
## 2025-04-28 - 2.0.0 - BREAKING CHANGE(docs) ## 2025-04-28 - 2.0.0 - BREAKING CHANGE(docs)
Update documentation and examples to unify async and sync assertions, add custom matcher guides, and update package configuration Update documentation and examples to unify async and sync assertions, add custom matcher guides, and update package configuration

View File

@ -1,6 +1,6 @@
{ {
"name": "@push.rocks/smartexpect", "name": "@push.rocks/smartexpect",
"version": "2.0.0", "version": "2.1.0",
"private": false, "private": false,
"description": "A testing library to manage expectations in code, offering both synchronous and asynchronous assertion methods.", "description": "A testing library to manage expectations in code, offering both synchronous and asynchronous assertion methods.",
"main": "dist_ts/index.js", "main": "dist_ts/index.js",

View File

@ -21,17 +21,35 @@ This document captures the roadmap for evolving the `expect` / `expectAsync` API
- [ ] Group matchers under `.string`, `.array`, `.number`, etc. for discoverability. - [ ] Group matchers under `.string`, `.array`, `.number`, etc. for discoverability.
## Phase 6: Jest-Style Convenience ## Phase 6: Jest-Style Convenience
- [ ] Add `.toMatchObject()`, `.toMatchSnapshot()`, `expect.any()`, `expect.anything()`, etc. - [x] Add `expect.any()` and `expect.anything()` matchers for use in `.toMatchObject()` patterns
(Snapshot matchers still TBD)
## Phase 7: Error Messages & Diffs The next items to tackle:
- [ ] Integrate a diffing library for clear failure output with colorized diffs.
## Phase 8: Nested Access Chaining 3. Improve negation (`.not`) messaging
- [ ] Provide `.at(path)` or lens-based API for deep property assertions in one go. - Today `.not` simply flips pass/fail, but the failure message isnt very descriptive. We should capture positive/negative message templates so e.g.
> expect(5).not.toBeGreaterThan(3)
emits:
"Expected 5 not to be greater than 3"
## Phase 9: Pluggable Reporters 4. Richer error output for objects/arrays
- [ ] Allow consumers to swap output format: JSON, TAP, HTML, etc. - Integrate a diff library (or extend `fast-deep-equal`) to show unified diffs between expected and actual values
## Phase 10: API Cleanup 5. More built-in matchers
- [ ] Audit and remove legacy aliases and redundant methods. - toBeNaN(), toBeFinite()
- [ ] Finalize deprecations and bump to a major version. - toBeWithinRange(min, max)
- toHaveKeys(...), toHaveOwnKeys(...)
- toThrowErrorMatching(/regex/), toThrowErrorWithMessage('…')
- string/array: toBeEmpty() alias
- object: toHaveOwnProperty() shorthand
6. TypeScript-friendliness
- Enhance `.d.ts` so editors autocomplete namespace methods (e.g. `expect(x).string.`)
- Statically type matcher arguments to catch wrong types at compile time
7. Async assertions and timeouts improvements
- Support `.not.resolves`, `.rejects.toThrow()`
- Provide clearer timeout errors (e.g. "Expected promise to resolve within …")
8. Plugin/extension API
- Formalize `Assertion.extend()` plugin API for shipping matcher bundles

32
test/test.diffOutput.ts Normal file
View File

@ -0,0 +1,32 @@
import { tap, expect as tExpect } from '@push.rocks/tapbundle';
import * as smartexpect from '../dist_ts/index.js';
tap.test('diff-like output for object.toEqual mismatch', async () => {
const a = { x: 1, y: 2 };
const b = { x: 1, y: 3 };
try {
smartexpect.expect(a).object.toEqual(b);
throw new Error('Assertion did not throw');
} catch (err: any) {
const msg: string = err.message;
tExpect(msg.includes('Expected objects to be deeply equal')).toBeTrue();
tExpect(msg.includes('Received:')).toBeTrue();
tExpect(msg.includes('"y": 2')).toBeTrue();
}
});
tap.test('diff-like output for array.toContainEqual mismatch', async () => {
const arr = [{ id: 1 }, { id: 2 }];
const item = { id: 3 };
try {
smartexpect.expect(arr).array.toContainEqual(item);
throw new Error('Assertion did not throw');
} catch (err: any) {
const msg: string = err.message;
tExpect(msg.includes('Expected array to contain equal to')).toBeTrue();
tExpect(msg.includes('Received:')).toBeTrue();
tExpect(msg.includes('"id": 1')).toBeTrue();
}
});
export default tap.start();

38
test/test.expectAny.ts Normal file
View File

@ -0,0 +1,38 @@
import { tap } from '@push.rocks/tapbundle';
import * as smartexpect from '../dist_ts/index.js';
tap.test('expect.any and expect.anything basic usage', async () => {
const obj = { a: 1, b: 'two', d: new Date() };
// Using expect.any to match types
smartexpect.expect(obj).object.toMatchObject({
a: smartexpect.expect.any(Number),
b: smartexpect.expect.any(String),
d: smartexpect.expect.any(Date),
});
// Using expect.anything to match any defined value
smartexpect.expect(obj).object.toMatchObject({
a: smartexpect.expect.anything(),
b: smartexpect.expect.anything(),
d: smartexpect.expect.anything(),
});
});
tap.test('expect.any mismatch and anything null/undefined rejection', async () => {
const obj = { a: 1, b: null };
// Mismatch for expect.any
try {
smartexpect.expect(obj).object.toMatchObject({ a: smartexpect.expect.any(String) });
throw new Error('Expected mismatch for expect.any did not throw');
} catch (err) {
// success: thrown on mismatch
}
// anything should reject null or undefined
try {
smartexpect.expect(obj).object.toMatchObject({ b: smartexpect.expect.anything() });
throw new Error('Expected anything() to reject null or undefined');
} catch (err) {
// success: thrown on null
}
});
export default tap.start();

22
test/test.negation.ts Normal file
View File

@ -0,0 +1,22 @@
import { tap, expect as tExpect } from '@push.rocks/tapbundle';
import * as smartexpect from '../dist_ts/index.js';
tap.test('negation message for numeric matcher', async () => {
try {
smartexpect.expect(5).not.toBeGreaterThan(3);
throw new Error('Assertion did not throw');
} catch (err: any) {
tExpect(err.message).toEqual('Expected number not to be greater than 3');
}
});
tap.test('negation message for string matcher', async () => {
try {
smartexpect.expect('hello').not.string.toInclude('he');
throw new Error('Assertion did not throw');
} catch (err: any) {
tExpect(err.message).toEqual('Expected string not to include "he"');
}
});
export default tap.start();

View File

@ -3,6 +3,6 @@
*/ */
export const commitinfo = { export const commitinfo = {
name: '@push.rocks/smartexpect', name: '@push.rocks/smartexpect',
version: '2.0.0', version: '2.1.0',
description: 'A testing library to manage expectations in code, offering both synchronous and asynchronous assertion methods.' description: 'A testing library to manage expectations in code, offering both synchronous and asynchronous assertion methods.'
} }

View File

@ -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 // 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 namespace expect {
export const extend = Assertion.extend; 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();
}
} }
/** /**

View File

@ -1,4 +1,5 @@
import { Assertion } from '../smartexpect.classes.assertion.js'; import { Assertion } from '../smartexpect.classes.assertion.js';
import * as plugins from '../plugins.js';
/** /**
* Namespace for array-specific matchers * Namespace for array-specific matchers
@ -7,38 +8,73 @@ export class ArrayMatchers<T> {
constructor(private assertion: Assertion<T[]>) {} constructor(private assertion: Assertion<T[]>) {}
toBeArray() { toBeArray() {
return this.assertion.toBeArray(); return this.assertion.customAssertion(
(value) => Array.isArray(value),
`Expected value to be array`
);
} }
toHaveLength(length: number) { toHaveLength(length: number) {
return this.assertion.toHaveLength(length); return this.assertion.customAssertion(
(value) => (value as T[]).length === length,
`Expected array to have length ${length}`
);
} }
toContain(item: T) { toContain(item: T) {
return this.assertion.toContain(item); return this.assertion.customAssertion(
(value) => (value as T[]).includes(item),
`Expected array to contain ${JSON.stringify(item)}`
);
} }
toContainEqual(item: T) { toContainEqual(item: T) {
return this.assertion.toContainEqual(item); return this.assertion.customAssertion(
(value) => (value as T[]).some((e) => plugins.fastDeepEqual(e, item)),
(value) =>
`Expected array to contain equal to ${JSON.stringify(item)}` +
`\nReceived: ${JSON.stringify(value, null, 2)}`
);
} }
toContainAll(items: T[]) { toContainAll(items: T[]) {
return this.assertion.toContainAll(items); return this.assertion.customAssertion(
(value) => items.every((i) => (value as T[]).includes(i)),
`Expected array to contain all ${JSON.stringify(items)}`
);
} }
toExclude(item: T) { toExclude(item: T) {
return this.assertion.toExclude(item); return this.assertion.customAssertion(
(value) => !(value as T[]).includes(item),
`Expected array to exclude ${JSON.stringify(item)}`
);
} }
toBeEmptyArray() { toBeEmptyArray() {
return this.assertion.toBeEmptyArray(); return this.assertion.customAssertion(
(value) => Array.isArray(value) && (value as T[]).length === 0,
`Expected array to be empty`
);
}
/**
* Alias for empty array check
*/
toBeEmpty() {
return this.toBeEmptyArray();
} }
toHaveLengthGreaterThan(length: number) { toHaveLengthGreaterThan(length: number) {
return this.assertion.toHaveLengthGreaterThan(length); return this.assertion.customAssertion(
(value) => (value as T[]).length > length,
`Expected array to have length greater than ${length}`
);
} }
toHaveLengthLessThan(length: number) { toHaveLengthLessThan(length: number) {
return this.assertion.toHaveLengthLessThan(length); return this.assertion.customAssertion(
(value) => (value as T[]).length < length,
`Expected array to have length less than ${length}`
);
} }
} }

View File

@ -7,18 +7,30 @@ export class BooleanMatchers {
constructor(private assertion: Assertion<boolean>) {} constructor(private assertion: Assertion<boolean>) {}
toBeTrue() { toBeTrue() {
return this.assertion.toBeTrue(); return this.assertion.customAssertion(
(v) => v === true,
`Expected value to be true`
);
} }
toBeFalse() { toBeFalse() {
return this.assertion.toBeFalse(); return this.assertion.customAssertion(
(v) => v === false,
`Expected value to be false`
);
} }
toBeTruthy() { toBeTruthy() {
return this.assertion.toBeTruthy(); return this.assertion.customAssertion(
(v) => Boolean(v),
`Expected value to be truthy`
);
} }
toBeFalsy() { toBeFalsy() {
return this.assertion.toBeFalsy(); return this.assertion.customAssertion(
(v) => !v,
`Expected value to be falsy`
);
} }
} }

View File

@ -7,14 +7,23 @@ export class DateMatchers {
constructor(private assertion: Assertion<Date>) {} constructor(private assertion: Assertion<Date>) {}
toBeDate() { toBeDate() {
return this.assertion.toBeDate(); return this.assertion.customAssertion(
(v) => v instanceof Date,
`Expected value to be a Date instance`
);
} }
toBeBeforeDate(date: Date) { toBeBeforeDate(date: Date) {
return this.assertion.toBeBeforeDate(date); return this.assertion.customAssertion(
(v) => v instanceof Date && (v as Date).getTime() < date.getTime(),
`Expected date to be before ${date.toISOString()}`
);
} }
toBeAfterDate(date: Date) { toBeAfterDate(date: Date) {
return this.assertion.toBeAfterDate(date); return this.assertion.customAssertion(
(v) => v instanceof Date && (v as Date).getTime() > date.getTime(),
`Expected date to be after ${date.toISOString()}`
);
} }
} }

View File

@ -7,6 +7,55 @@ export class FunctionMatchers {
constructor(private assertion: Assertion<Function>) {} constructor(private assertion: Assertion<Function>) {}
toThrow(expectedError?: any) { toThrow(expectedError?: any) {
return this.assertion.toThrow(expectedError); return this.assertion.customAssertion(
(value) => {
let threw = false;
try {
(value as Function)();
} catch (e: any) {
threw = true;
if (expectedError) {
if (typeof expectedError === 'function') {
return e instanceof expectedError;
}
return e === expectedError;
}
}
return threw;
},
`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}"`
);
} }
} }

View File

@ -7,26 +7,77 @@ export class NumberMatchers {
constructor(private assertion: Assertion<number>) {} constructor(private assertion: Assertion<number>) {}
toBeGreaterThan(value: number) { toBeGreaterThan(value: number) {
return this.assertion.toBeGreaterThan(value); return this.assertion.customAssertion(
(v) => (v as number) > value,
`Expected number to be greater than ${value}`
);
} }
toBeLessThan(value: number) { toBeLessThan(value: number) {
return this.assertion.toBeLessThan(value); return this.assertion.customAssertion(
(v) => (v as number) < value,
`Expected number to be less than ${value}`
);
} }
toBeGreaterThanOrEqual(value: number) { toBeGreaterThanOrEqual(value: number) {
return this.assertion.toBeGreaterThanOrEqual(value); return this.assertion.customAssertion(
(v) => (v as number) >= value,
`Expected number to be greater than or equal to ${value}`
);
} }
toBeLessThanOrEqual(value: number) { toBeLessThanOrEqual(value: number) {
return this.assertion.toBeLessThanOrEqual(value); return this.assertion.customAssertion(
(v) => (v as number) <= value,
`Expected number to be less than or equal to ${value}`
);
} }
toBeCloseTo(value: number, precision?: number) { toBeCloseTo(value: number, precision?: number) {
return this.assertion.toBeCloseTo(value, precision); return this.assertion.customAssertion(
(v) => {
const num = v as number;
const p = precision !== undefined ? precision : 2;
const diff = Math.abs(num - value);
const tolerance = 0.5 * Math.pow(10, -p);
return diff <= tolerance;
},
`Expected number to be close to ${value} within precision ${precision ?? 2}`
);
} }
/** Equality check for numbers */ /** Equality check for numbers */
toEqual(value: number) { toEqual(value: number) {
return this.assertion.toEqual(value); return this.assertion.customAssertion(
(v) => (v as number) === value,
`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}`
);
} }
} }

View File

@ -1,4 +1,5 @@
import { Assertion } from '../smartexpect.classes.assertion.js'; import { Assertion, AnyMatcher, AnythingMatcher } from '../smartexpect.classes.assertion.js';
import * as plugins from '../plugins.js';
/** /**
* Namespace for object-specific matchers * Namespace for object-specific matchers
@ -7,33 +8,125 @@ export class ObjectMatchers<T extends object> {
constructor(private assertion: Assertion<T>) {} constructor(private assertion: Assertion<T>) {}
toEqual(expected: any) { toEqual(expected: any) {
return this.assertion.toEqual(expected); return this.assertion.customAssertion(
(v) => plugins.fastDeepEqual(v, expected),
(v) =>
`Expected objects to be deeply equal to ${JSON.stringify(expected, null, 2)}` +
`\nReceived: ${JSON.stringify(v, null, 2)}`
);
} }
toMatchObject(expected: object) { toMatchObject(expected: object) {
return this.assertion.toMatchObject(expected); return this.assertion.customAssertion(
(v) => {
const obj = v as any;
for (const key of Object.keys(expected)) {
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;
},
(v) =>
`Expected object to match properties ${JSON.stringify(expected, null, 2)}` +
`\nReceived: ${JSON.stringify(v, null, 2)}`
);
} }
toBeInstanceOf(constructor: any) { toBeInstanceOf(constructor: any) {
return this.assertion.toBeInstanceOf(constructor); return this.assertion.customAssertion(
(v) => (v as any) instanceof constructor,
`Expected object to be instance of ${constructor.name || constructor}`
);
} }
toHaveProperty(property: string, value?: any) { toHaveProperty(property: string, value?: any) {
return this.assertion.toHaveProperty(property, value); return this.assertion.customAssertion(
(v) => {
const obj = v as any;
if (!(property in obj)) {
return false;
}
if (arguments.length === 2) {
return plugins.fastDeepEqual(obj[property], value);
}
return true;
},
`Expected object to have property ${property}${value !== undefined ? ` with value ${JSON.stringify(value)}` : ''}`
);
} }
toHaveDeepProperty(path: string[]) { toHaveDeepProperty(path: string[]) {
return this.assertion.toHaveDeepProperty(path); return this.assertion.customAssertion(
(v) => {
let obj: any = v;
for (const key of path) {
if (obj == null || !(key in obj)) {
return false;
}
obj = obj[key];
}
return true;
},
`Expected object to have deep property path ${JSON.stringify(path)}`
);
} }
toBeNull() { toBeNull() {
return this.assertion.toBeNull(); return this.assertion.customAssertion(
(v) => v === null,
`Expected value to be null`
);
} }
toBeUndefined() { toBeUndefined() {
return this.assertion.toBeUndefined(); return this.assertion.customAssertion(
(v) => v === undefined,
`Expected value to be undefined`
);
} }
toBeNullOrUndefined() { toBeNullOrUndefined() {
return this.assertion.toBeNullOrUndefined(); return this.assertion.customAssertion(
(v) => v === null || v === undefined,
`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)}`
);
} }
} }

View File

@ -7,26 +7,53 @@ export class StringMatchers {
constructor(private assertion: Assertion<string>) {} constructor(private assertion: Assertion<string>) {}
toStartWith(prefix: string) { toStartWith(prefix: string) {
return this.assertion.toStartWith(prefix); return this.assertion.customAssertion(
(value) => (value as string).startsWith(prefix),
`Expected string to start with "${prefix}"`
);
} }
toEndWith(suffix: string) { toEndWith(suffix: string) {
return this.assertion.toEndWith(suffix); return this.assertion.customAssertion(
(value) => (value as string).endsWith(suffix),
`Expected string to end with "${suffix}"`
);
} }
toInclude(substring: string) { toInclude(substring: string) {
return this.assertion.toInclude(substring); return this.assertion.customAssertion(
(value) => (value as string).includes(substring),
`Expected string to include "${substring}"`
);
} }
toMatch(regex: RegExp) { toMatch(regex: RegExp) {
return this.assertion.toMatch(regex); return this.assertion.customAssertion(
(value) => regex.test(value as string),
`Expected string to match ${regex}`
);
} }
toBeOneOf(values: string[]) { toBeOneOf(values: string[]) {
return this.assertion.toBeOneOf(values); return this.assertion.customAssertion(
(value) => (values as string[]).includes(value as string),
`Expected string to be one of ${JSON.stringify(values)}`
);
} }
/** Length check for strings */ /** Length check for strings */
toHaveLength(length: number) { toHaveLength(length: number) {
return this.assertion.toHaveLength(length); return this.assertion.customAssertion(
(value) => (value as string).length === length,
`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`
);
} }
} }

View File

@ -7,22 +7,37 @@ export class TypeMatchers {
constructor(private assertion: Assertion<any>) {} constructor(private assertion: Assertion<any>) {}
toBeTypeofString() { toBeTypeofString() {
return this.assertion.toBeTypeofString(); return this.assertion.customAssertion(
(v) => typeof v === 'string',
`Expected type to be 'string'`
);
} }
toBeTypeofNumber() { toBeTypeofNumber() {
return this.assertion.toBeTypeofNumber(); return this.assertion.customAssertion(
(v) => typeof v === 'number',
`Expected type to be 'number'`
);
} }
toBeTypeofBoolean() { toBeTypeofBoolean() {
return this.assertion.toBeTypeofBoolean(); return this.assertion.customAssertion(
(v) => typeof v === 'boolean',
`Expected type to be 'boolean'`
);
} }
toBeTypeOf(typeName: string) { toBeTypeOf(typeName: string) {
return this.assertion.toBeTypeOf(typeName); return this.assertion.customAssertion(
(v) => typeof v === typeName,
`Expected type to be '${typeName}'`
);
} }
toBeDefined() { toBeDefined() {
return this.assertion.toBeDefined(); return this.assertion.customAssertion(
(v) => v !== undefined,
`Expected value to be defined`
);
} }
} }

View File

@ -18,6 +18,14 @@ import type { TMatcher, TExecutionType } from './types.js';
/** /**
* Core assertion class. Generic over the current value type T. * 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> { export class Assertion<T = unknown> {
executionMode: TExecutionType; executionMode: TExecutionType;
baseReference: any; baseReference: any;
@ -33,6 +41,8 @@ export class Assertion<T = unknown> {
private isResolves = false; private isResolves = false;
private failMessage: string; private failMessage: string;
private successMessage: string; private successMessage: string;
/** Computed negation failure message for the current assertion */
private negativeMessage: string;
constructor(baseReferenceArg: any, executionModeArg: TExecutionType) { constructor(baseReferenceArg: any, executionModeArg: TExecutionType) {
this.baseReference = baseReferenceArg; this.baseReference = baseReferenceArg;
@ -131,6 +141,16 @@ export class Assertion<T = unknown> {
.replace('{value}', formattedValue) .replace('{value}', formattedValue)
.replace('{path}', drillDown || ''); .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() { public get not() {
this.notSetting = true; this.notSetting = true;
@ -190,12 +210,14 @@ export class Assertion<T = unknown> {
} else { } else {
let isOk = false; let isOk = false;
try { try {
runDirectOrNegated(checkFunction()); // attempt positive assertion and expect it to throw
checkFunction();
} catch (e) { } catch (e) {
isOk = true; isOk = true;
} }
if (!isOk) { 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( public customAssertion(
assertionFunction: (value: any) => boolean, 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(() => { return this.runCheck(() => {
const value = this.getObjectToTestReference(); const value = this.getObjectToTestReference();
if (!assertionFunction(value)) { if (!assertionFunction(value)) {
throw new Error(this.failMessage || errorMessage); const msg = this.failMessage
|| (typeof errorMessage === 'function' ? errorMessage(value) : errorMessage);
throw new Error(msg);
} }
}); });
} }
@ -288,6 +316,49 @@ export class Assertion<T = unknown> {
console.log(`Path: ${this.formatDrillDown() || '(root)'}`); console.log(`Path: ${this.formatDrillDown() || '(root)'}`);
return this; return this;
} }
// Direct (flat) matcher aliases
public toEqual(expected: any) {
return this.customAssertion(
(v) => plugins.fastDeepEqual(v, expected),
`Expected value to equal ${JSON.stringify(expected)}`
);
}
public toBeTrue() { return this.boolean.toBeTrue(); }
public toBeFalse() { return this.boolean.toBeFalse(); }
public toBeTruthy() { return this.boolean.toBeTruthy(); }
public toBeFalsy() { return this.boolean.toBeFalsy(); }
public toThrow(expectedError?: any) { return this.function.toThrow(expectedError); }
public toBeGreaterThan(value: number) { return this.number.toBeGreaterThan(value); }
public toBeLessThan(value: number) { return this.number.toBeLessThan(value); }
public toBeGreaterThanOrEqual(value: number) { return this.number.toBeGreaterThanOrEqual(value); }
public toBeLessThanOrEqual(value: number) { return this.number.toBeLessThanOrEqual(value); }
public toBeCloseTo(value: number, precision?: number) { return this.number.toBeCloseTo(value, precision); }
public toBeArray() { return this.array.toBeArray(); }
public toContain(item: any) { return this.array.toContain(item); }
public toContainEqual(item: any) { return this.array.toContainEqual(item); }
public toContainAll(items: any[]) { return this.array.toContainAll(items); }
public toExclude(item: any) { return this.array.toExclude(item); }
public toBeEmptyArray() { return this.array.toBeEmptyArray(); }
public toStartWith(prefix: string) { return this.string.toStartWith(prefix); }
public toEndWith(suffix: string) { return this.string.toEndWith(suffix); }
public toInclude(substring: string) { return this.string.toInclude(substring); }
public toMatch(regex: RegExp) { return this.string.toMatch(regex); }
public toBeOneOf(values: any[]) { return this.string.toBeOneOf(values as string[]); }
public toHaveProperty(property: string, value?: any) { return this.object.toHaveProperty(property, value); }
public toMatchObject(expected: object) { return this.object.toMatchObject(expected); }
public toBeInstanceOf(constructor: any) { return this.object.toBeInstanceOf(constructor); }
public toHaveDeepProperty(path: string[]) { return this.object.toHaveDeepProperty(path); }
public toBeNull() { return this.object.toBeNull(); }
public toBeUndefined() { return this.object.toBeUndefined(); }
public toBeNullOrUndefined() { return this.object.toBeNullOrUndefined(); }
public toBeDate() { return this.date.toBeDate(); }
public toBeBeforeDate(date: Date) { return this.date.toBeBeforeDate(date); }
public toBeAfterDate(date: Date) { return this.date.toBeAfterDate(date); }
public toBeTypeofString() { return this.type.toBeTypeofString(); }
public toBeTypeofNumber() { return this.type.toBeTypeofNumber(); }
public toBeTypeofBoolean() { return this.type.toBeTypeofBoolean(); }
public toBeTypeOf(typeName: string) { return this.type.toBeTypeOf(typeName); }
public toBeDefined() { return this.type.toBeDefined(); }
// Namespaced matcher accessors // Namespaced matcher accessors
/** String-specific matchers */ /** String-specific matchers */
public get string() { public get string() {