From 9b488a87a0e03591c9a0b14a539d9970ecd1c74f Mon Sep 17 00:00:00 2001 From: Philipp Kunz Date: Mon, 28 Apr 2025 20:42:58 +0000 Subject: [PATCH] feat(core): Add new matchers and improve negation messaging --- changelog.md | 10 ++++++ readme.plan.md | 38 +++++++++++++++------ test/test.diffOutput.ts | 32 ++++++++++++++++++ test/test.expectAny.ts | 38 +++++++++++++++++++++ test/test.negation.ts | 22 +++++++++++++ ts/00_commitinfo_data.ts | 2 +- ts/index.ts | 14 +++++++- ts/namespaces/array.ts | 10 +++++- ts/namespaces/function.ts | 32 ++++++++++++++++++ ts/namespaces/number.ts | 27 +++++++++++++++ ts/namespaces/object.ts | 51 ++++++++++++++++++++++++++--- ts/namespaces/string.ts | 9 +++++ ts/smartexpect.classes.assertion.ts | 36 +++++++++++++++++--- 13 files changed, 300 insertions(+), 21 deletions(-) create mode 100644 test/test.diffOutput.ts create mode 100644 test/test.expectAny.ts create mode 100644 test/test.negation.ts diff --git a/changelog.md b/changelog.md index c952098..12062be 100644 --- a/changelog.md +++ b/changelog.md @@ -1,5 +1,15 @@ # 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. diff --git a/readme.plan.md b/readme.plan.md index 3198157..d1dfa5e 100644 --- a/readme.plan.md +++ b/readme.plan.md @@ -21,17 +21,35 @@ This document captures the roadmap for evolving the `expect` / `expectAsync` API - [ ] Group matchers under `.string`, `.array`, `.number`, etc. for discoverability. ## 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 -- [ ] Integrate a diffing library for clear failure output with colorized diffs. +The next items to tackle: -## Phase 8: Nested Access Chaining -- [ ] Provide `.at(path)` or lens-based API for deep property assertions in one go. +3. Improve negation (`.not`) messaging + - Today `.not` simply flips pass/fail, but the failure message isn’t 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 -- [ ] Allow consumers to swap output format: JSON, TAP, HTML, etc. +4. Richer error output for objects/arrays + - Integrate a diff library (or extend `fast-deep-equal`) to show unified diffs between expected and actual values -## Phase 10: API Cleanup -- [ ] Audit and remove legacy aliases and redundant methods. -- [ ] Finalize deprecations and bump to a major version. \ No newline at end of file +5. More built-in matchers + - toBeNaN(), toBeFinite() + - 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 \ No newline at end of file diff --git a/test/test.diffOutput.ts b/test/test.diffOutput.ts new file mode 100644 index 0000000..056e654 --- /dev/null +++ b/test/test.diffOutput.ts @@ -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(); \ No newline at end of file diff --git a/test/test.expectAny.ts b/test/test.expectAny.ts new file mode 100644 index 0000000..9c41341 --- /dev/null +++ b/test/test.expectAny.ts @@ -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(); \ No newline at end of file diff --git a/test/test.negation.ts b/test/test.negation.ts new file mode 100644 index 0000000..85dd0c0 --- /dev/null +++ b/test/test.negation.ts @@ -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(); \ No newline at end of file diff --git a/ts/00_commitinfo_data.ts b/ts/00_commitinfo_data.ts index a3d68d1..cf906af 100644 --- a/ts/00_commitinfo_data.ts +++ b/ts/00_commitinfo_data.ts @@ -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.' } diff --git a/ts/index.ts b/ts/index.ts index 66f3f2b..8afcf4a 100644 --- a/ts/index.ts +++ b/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(value: any): Assertion { */ 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(); + } } /** diff --git a/ts/namespaces/array.ts b/ts/namespaces/array.ts index b8015a1..0758e5a 100644 --- a/ts/namespaces/array.ts +++ b/ts/namespaces/array.ts @@ -31,7 +31,9 @@ export class ArrayMatchers { 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 { `Expected array to be empty` ); } + /** + * Alias for empty array check + */ + toBeEmpty() { + return this.toBeEmptyArray(); + } toHaveLengthGreaterThan(length: number) { return this.assertion.customAssertion( diff --git a/ts/namespaces/function.ts b/ts/namespaces/function.ts index 5dd7b96..f4faa0b 100644 --- a/ts/namespaces/function.ts +++ b/ts/namespaces/function.ts @@ -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}"` + ); + } } \ No newline at end of file diff --git a/ts/namespaces/number.ts b/ts/namespaces/number.ts index 378d3fb..922e6d9 100644 --- a/ts/namespaces/number.ts +++ b/ts/namespaces/number.ts @@ -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}` + ); + } } \ No newline at end of file diff --git a/ts/namespaces/object.ts b/ts/namespaces/object.ts index 5bb2715..96db200 100644 --- a/ts/namespaces/object.ts +++ b/ts/namespaces/object.ts @@ -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 { 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 { `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)}` + ); + } } \ No newline at end of file diff --git a/ts/namespaces/string.ts b/ts/namespaces/string.ts index 33387ff..b0e4be9 100644 --- a/ts/namespaces/string.ts +++ b/ts/namespaces/string.ts @@ -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` + ); + } } \ No newline at end of file diff --git a/ts/smartexpect.classes.assertion.ts b/ts/smartexpect.classes.assertion.ts index 3bc639e..84590ed 100644 --- a/ts/smartexpect.classes.assertion.ts +++ b/ts/smartexpect.classes.assertion.ts @@ -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 { executionMode: TExecutionType; baseReference: any; @@ -33,6 +41,8 @@ export class Assertion { 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 { .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 { } 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 { 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); } }); }