feat(core): Add new matchers and improve negation messaging
This commit is contained in:
		
							
								
								
									
										10
									
								
								changelog.md
									
									
									
									
									
								
							
							
						
						
									
										10
									
								
								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. | ||||
|  | ||||
|   | ||||
| @@ -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. | ||||
| 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 | ||||
							
								
								
									
										32
									
								
								test/test.diffOutput.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										32
									
								
								test/test.diffOutput.ts
									
									
									
									
									
										Normal 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
									
								
							
							
						
						
									
										38
									
								
								test/test.expectAny.ts
									
									
									
									
									
										Normal 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
									
								
							
							
						
						
									
										22
									
								
								test/test.negation.ts
									
									
									
									
									
										Normal 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(); | ||||
| @@ -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