Compare commits

...

14 Commits

20 changed files with 582 additions and 143 deletions

View File

@ -1,5 +1,57 @@
# Changelog
## 2025-04-30 - 2.3.0 - feat(object-matchers)
Add object key matchers: toHaveKeys and toHaveOwnKeys; remove obsolete roadmap plan file
- Implemented toHaveKeys matcher to check for both own and inherited properties
- Implemented toHaveOwnKeys matcher to check for own properties only
- Added tests for key matchers in test/test.keys.ts
- Removed readme.plan.md as it is now obsolete
## 2025-04-29 - 2.2.2 - fix(license-files)
Remove legacy license file and add license.md to update file naming.
- Removed the old 'license' file.
- Added 'license.md' with updated file structure.
## 2025-04-29 - 2.2.1 - fix(readme)
Update usage examples and full matcher reference in README
- Removed deprecated 'expectAsync' from import example
- Fixed formatting in documentation
- Added comprehensive full matcher reference section
## 2025-04-29 - 2.2.0 - feat(generics)
Improve assertion and matcher type definitions by adding execution mode generics for better async/sync support
- Updated ts/index.ts to import and use TExecutionType in the expect function
- Modified Assertion class to use a generic execution mode (M) for improved type inference
- Revised all matcher namespaces (array, boolean, date, function, number, object, string, type) to accept the new generic parameter
- Enhanced async/sync distinction for assertion methods like resolves and rejects
## 2025-04-29 - 2.1.2 - fix(ts/index.ts)
Remove deprecated expectAsync function and advise using .resolves/.rejects on expect for async assertions
- Deleted the redundant expectAsync export in ts/index.ts
- Users should now call expect(...).resolves or expect(...).rejects for asynchronous assertions
## 2025-04-29 - 2.1.1 - fix(Assertion)
Improve chainability by fixing return types in assertion methods
- Update runCheck method to explicitly return the correct chainable type for both async and sync assertions
- Ensure customAssertion propagates the chainable Assertion instance
- Refactor internal promise handling for clarity and consistency
## 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.

View File

@ -1,4 +1,4 @@
Copyright (c) 2022 Lossless GmbH (hello@lossless.com)
Copyright (c) 2022 Task Venture Capital GmbH (hello@lossless.com)
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal

View File

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

View File

@ -17,10 +17,10 @@ This will add `@push.rocks/smartexpect` to your project's dependencies. Make sur
### Getting Started
First, import `@push.rocks/smartexpect` into your TypeScript file:
First, import `@push.rocks/smartexpect` into your TypeScript file:
```typescript
import { expect, expectAsync } from '@push.rocks/smartexpect';
import { expect } from '@push.rocks/smartexpect';
```
### Synchronous Expectations
@ -254,6 +254,96 @@ expect(4).not.toBeOdd();
- Matcher functions receive the value under test (`received`) plus any arguments.
- Must return an object with `pass` (boolean) and `message` (string or function) for failure messages.
### Full Matcher Reference
Below is a comprehensive list of all matchers and utility functions available in `@push.rocks/smartexpect`.
#### Modifiers and Utilities
- `.not` Negates the next matcher in the chain.
- `.resolves` Switches to async mode, expecting the promise to resolve.
- `.rejects` Switches to async mode, expecting the promise to reject.
- `.withTimeout(ms)` Sets a timeout (in milliseconds) for async assertions.
- `.timeout(ms)` (deprecated) Alias for `.withTimeout(ms)`.
- `.property(name)` Drill into a property of an object.
- `.arrayItem(index)` Drill into an array element by index.
- `.log()` Logs the current value and assertion path for debugging.
- `.setFailMessage(message)` Override the failure message for the current assertion.
- `.setSuccessMessage(message)` Override the success message for the current assertion.
- `.customAssertion(fn, message)` Execute a custom assertion function with a message.
- `expect.extend(matchers)` Register custom matchers globally.
- `expect.any(constructor)` Matcher for values that are instances of the given constructor.
- `expect.anything()` Matcher for any defined value (not null or undefined).
#### Basic Matchers
- `.toEqual(expected)` Deep (or strict for primitives) equality.
- `.toBeTrue()` Value is strictly `true`.
- `.toBeFalse()` Value is strictly `false`.
- `.toBeTruthy()` Value is truthy.
- `.toBeFalsy()` Value is falsy.
- `.toBeNull()` Value is `null`.
- `.toBeUndefined()` Value is `undefined`.
- `.toBeNullOrUndefined()` Value is `null` or `undefined`.
- `.toBeDefined()` Value is not `undefined`.
#### Number Matchers
- `.toBeGreaterThan(value)`
- `.toBeLessThan(value)`
- `.toBeGreaterThanOrEqual(value)`
- `.toBeLessThanOrEqual(value)`
- `.toBeCloseTo(value, precision?)`
- `.toEqual(value)` Strict equality for numbers.
- `.toBeNaN()`
- `.toBeFinite()`
- `.toBeWithinRange(min, max)`
#### String Matchers
- `.toStartWith(prefix)`
- `.toEndWith(suffix)`
- `.toInclude(substring)`
- `.toMatch(regex)`
- `.toBeOneOf(arrayOfValues)`
- `.toHaveLength(length)`
- `.toBeEmpty()` Alias for empty string.
#### Array Matchers
- `.toBeArray()`
- `.toHaveLength(length)`
- `.toContain(value)`
- `.toContainEqual(value)`
- `.toContainAll(arrayOfValues)`
- `.toExclude(value)`
- `.toBeEmptyArray()`
- `.toBeEmpty()` Alias for empty array.
- `.toHaveLengthGreaterThan(length)`
- `.toHaveLengthLessThan(length)`
#### Object Matchers
- `.toEqual(expected)` Deep equality for objects.
- `.toMatchObject(partialObject)` Partial deep matching (supports `expect.any` and `expect.anything`).
- `.toBeInstanceOf(constructor)`
- `.toHaveProperty(propertyName, value?)`
- `.toHaveDeepProperty(pathArray)`
- `.toHaveOwnProperty(propertyName, value?)`
- `.toBeNull()`
- `.toBeUndefined()`
- `.toBeNullOrUndefined()`
#### Function Matchers
- `.toThrow(expectedError?)`
- `.toThrowErrorMatching(regex)`
- `.toThrowErrorWithMessage(message)`
#### Date Matchers
- `.toBeDate()`
- `.toBeBeforeDate(date)`
- `.toBeAfterDate(date)`
#### Type Matchers
- `.toBeTypeofString()`
- `.toBeTypeofNumber()`
- `.toBeTypeofBoolean()`
- `.toBeTypeOf(typeName)`
## Best Practices
- **Human-readable assertions**: The fluent API is designed to create tests that read like natural language sentences.

View File

@ -1,37 +0,0 @@
# Plan for Improving @push.rocks/smartexpect API
This document captures the roadmap for evolving the `expect` / `expectAsync` API.
## Phase 1: Unify Sync + Async
- [x] Consolidate `expect` and `expectAsync` into a single `expect()` entry point.
- [x] Introduce `.resolves` and `.rejects` chainable helpers for Promises.
- [x] Deprecate `expectAsync`, provide migration guidance.
## Phase 2: Timeout Helper
- [x] Rename or wrap the existing `.timeout(ms)` to a more intuitive `.withTimeout(ms)`.
## Phase 3: Custom Matchers
- [x] Implement `expect.extend()` API for user-defined matchers.
## Phase 4: TypeScript Typings
- [ ] Enhance generic matcher types to infer narrow types after `.property()` / `.arrayItem()`.
- [ ] Provide matcher overloads for primitive categories (string, number, array, etc.).
## Phase 5: Namespaced Matchers
- [ ] Group matchers under `.string`, `.array`, `.number`, etc. for discoverability.
## Phase 6: Jest-Style Convenience
- [ ] Add `.toMatchObject()`, `.toMatchSnapshot()`, `expect.any()`, `expect.anything()`, etc.
## Phase 7: Error Messages & Diffs
- [ ] Integrate a diffing library for clear failure output with colorized diffs.
## Phase 8: Nested Access Chaining
- [ ] Provide `.at(path)` or lens-based API for deep property assertions in one go.
## Phase 9: Pluggable Reporters
- [ ] Allow consumers to swap output format: JSON, TAP, HTML, etc.
## Phase 10: API Cleanup
- [ ] Audit and remove legacy aliases and redundant methods.
- [ ] Finalize deprecations and bump to a major version.

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();

51
test/test.keys.ts Normal file
View File

@ -0,0 +1,51 @@
import { tap, expect as tExpect } from '@push.rocks/tapbundle';
import * as smartexpect from '../dist_ts/index.js';
// Positive: toHaveKeys should match both own and inherited properties
tap.test('toHaveKeys includes own and inherited properties', async () => {
class Parent { a = 1; }
class Child extends Parent { b = 2; }
const child = new Child();
smartexpect.expect(child).object.toHaveKeys(['a', 'b']);
});
// Positive: toHaveOwnKeys should match only own properties
tap.test('toHaveOwnKeys includes only own properties', async () => {
class Parent { a = 1; }
class Child extends Parent { b = 2; }
const child = new Child();
smartexpect.expect(child).object.toHaveOwnKeys(['b']);
});
// Negative: toHaveKeys should fail if any key is missing
tap.test('toHaveKeys fails when keys are missing', async () => {
const obj = { a: 1 };
try {
smartexpect.expect(obj).object.toHaveKeys(['a', 'b']);
throw new Error('Assertion did not throw');
} catch (err: any) {
tExpect(err.message.includes('Expected object to have keys')).toBeTrue();
}
});
// Negative: toHaveOwnKeys should fail when own keys are missing (inherited keys not counted)
tap.test('toHaveOwnKeys fails when own keys are missing', async () => {
// 'a' is inherited via the prototype, not an own property
class Parent {}
Parent.prototype.a = 1;
class Child extends Parent {
constructor() {
super();
this.b = 2;
}
}
const child = new Child();
try {
smartexpect.expect(child).object.toHaveOwnKeys(['a', 'b']);
throw new Error('Assertion did not throw');
} catch (err: any) {
tExpect(err.message.includes('Expected object to have own keys')).toBeTrue();
}
});
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 = {
name: '@push.rocks/smartexpect',
version: '2.0.1',
version: '2.3.0',
description: 'A testing library to manage expectations in code, offering both synchronous and asynchronous assertion methods.'
}

View File

@ -1,4 +1,5 @@
import { Assertion } from './smartexpect.classes.assertion.js';
import { Assertion, AnyMatcher, AnythingMatcher } from './smartexpect.classes.assertion.js';
import type { TExecutionType } from './types.js';
// import type { TMatcher } from './smartexpect.classes.assertion.js'; // unused
/**
@ -12,32 +13,29 @@ import { Assertion } from './smartexpect.classes.assertion.js';
* Entry point for assertions.
* Automatically detects Promises to support async assertions.
*/
export function expect<T>(value: Promise<T>): Assertion<T>;
export function expect<T>(value: T): Assertion<T>;
export function expect<T>(value: any): Assertion<T> {
export function expect<T>(value: Promise<T>): Assertion<T, 'async'>;
export function expect<T>(value: T): Assertion<T, 'sync'>;
export function expect<T>(value: any): Assertion<T, TExecutionType> {
const isThenable = value != null && typeof (value as any).then === 'function';
const mode: 'sync' | 'async' = isThenable ? 'async' : 'sync';
return new Assertion<T>(value, mode);
return new Assertion<T, TExecutionType>(value, mode);
}
/**
* Register custom matchers.
*/
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();
}
}
/**
* @deprecated Use `expect(...)` with `.resolves` or `.rejects` instead.
*/
/**
* @deprecated Use `expect(...)` with `.resolves` or `.rejects` instead.
*/
/**
* @deprecated Use `expect(...)` with `.resolves` or `.rejects` instead.
*/
export const expectAsync = (baseArg: any) => {
// eslint-disable-next-line no-console
console.warn('[DEPRECATED] expectAsync() is deprecated. Use expect(...).resolves / .rejects');
return new Assertion<any>(baseArg, 'async');
};

View File

@ -1,11 +1,12 @@
import { Assertion } from '../smartexpect.classes.assertion.js';
import * as plugins from '../plugins.js';
import type { TExecutionType } from '../types.js';
/**
* Namespace for array-specific matchers
*/
export class ArrayMatchers<T> {
constructor(private assertion: Assertion<T[]>) {}
export class ArrayMatchers<T, M extends TExecutionType> {
constructor(private assertion: Assertion<T[], M>) {}
toBeArray() {
return this.assertion.customAssertion(
@ -31,7 +32,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 +58,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(

View File

@ -1,10 +1,11 @@
import { Assertion } from '../smartexpect.classes.assertion.js';
import type { TExecutionType } from '../types.js';
/**
* Namespace for boolean-specific matchers
*/
export class BooleanMatchers {
constructor(private assertion: Assertion<boolean>) {}
export class BooleanMatchers<M extends TExecutionType> {
constructor(private assertion: Assertion<boolean, M>) {}
toBeTrue() {
return this.assertion.customAssertion(

View File

@ -1,10 +1,11 @@
import { Assertion } from '../smartexpect.classes.assertion.js';
import type { TExecutionType } from '../types.js';
/**
* Namespace for date-specific matchers
*/
export class DateMatchers {
constructor(private assertion: Assertion<Date>) {}
export class DateMatchers<M extends TExecutionType> {
constructor(private assertion: Assertion<Date, M>) {}
toBeDate() {
return this.assertion.customAssertion(

View File

@ -1,10 +1,11 @@
import { Assertion } from '../smartexpect.classes.assertion.js';
import type { TExecutionType } from '../types.js';
/**
* Namespace for function-specific matchers
*/
export class FunctionMatchers {
constructor(private assertion: Assertion<Function>) {}
export class FunctionMatchers<M extends TExecutionType> {
constructor(private assertion: Assertion<Function, M>) {}
toThrow(expectedError?: any) {
return this.assertion.customAssertion(
@ -26,4 +27,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}"`
);
}
}

View File

@ -1,10 +1,11 @@
import { Assertion } from '../smartexpect.classes.assertion.js';
import type { TExecutionType } from '../types.js';
/**
* Namespace for number-specific matchers
*/
export class NumberMatchers {
constructor(private assertion: Assertion<number>) {}
export class NumberMatchers<M extends TExecutionType> {
constructor(private assertion: Assertion<number, M>) {}
toBeGreaterThan(value: number) {
return this.assertion.customAssertion(
@ -53,4 +54,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}`
);
}
}

View File

@ -1,30 +1,53 @@
import { Assertion } from '../smartexpect.classes.assertion.js';
import { Assertion, AnyMatcher, AnythingMatcher } from '../smartexpect.classes.assertion.js';
import type { TExecutionType } from '../types.js';
import * as plugins from '../plugins.js';
/**
* Namespace for object-specific matchers
*/
export class ObjectMatchers<T extends object> {
constructor(private assertion: Assertion<T>) {}
export class ObjectMatchers<T extends object, M extends TExecutionType> {
constructor(private assertion: Assertion<T, M>) {}
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 +109,51 @@ 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)}`
);
}
/**
* Assert object has the given keys (including inherited properties).
* @param keys Array of keys to check for presence.
*/
public toHaveKeys(keys: string[]) {
return this.assertion.customAssertion(
(v) => keys.every((key) => key in (v as any)),
(v) =>
`Expected object to have keys ${JSON.stringify(keys)}` +
`\nReceived keys: ${JSON.stringify(Object.keys(v), null, 2)}`
);
}
/**
* Assert object has the given own keys (excluding inherited properties).
* @param keys Array of own keys to check.
*/
public toHaveOwnKeys(keys: string[]) {
return this.assertion.customAssertion(
(v) => keys.every((key) => Object.prototype.hasOwnProperty.call(v as any, key)),
(v) =>
`Expected object to have own keys ${JSON.stringify(keys)}` +
`\nReceived own keys: ${JSON.stringify(Object.keys(v), null, 2)}`
);
}
}

View File

@ -1,10 +1,11 @@
import { Assertion } from '../smartexpect.classes.assertion.js';
import type { TExecutionType } from '../types.js';
/**
* Namespace for string-specific matchers
*/
export class StringMatchers {
constructor(private assertion: Assertion<string>) {}
export class StringMatchers<M extends TExecutionType> {
constructor(private assertion: Assertion<string, M>) {}
toStartWith(prefix: string) {
return this.assertion.customAssertion(
@ -47,4 +48,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`
);
}
}

View File

@ -1,10 +1,11 @@
import { Assertion } from '../smartexpect.classes.assertion.js';
import type { TExecutionType } from '../types.js';
/**
* Namespace for type-based matchers
*/
export class TypeMatchers {
constructor(private assertion: Assertion<any>) {}
export class TypeMatchers<M extends TExecutionType> {
constructor(private assertion: Assertion<any, M>) {}
toBeTypeofString() {
return this.assertion.customAssertion(

View File

@ -18,8 +18,16 @@ import type { TMatcher, TExecutionType } from './types.js';
/**
* Core assertion class. Generic over the current value type T.
*/
export class Assertion<T = unknown> {
executionMode: TExecutionType;
/**
* Internal matcher classes for expect.any and expect.anything
*/
export class AnyMatcher {
constructor(public expectedConstructor: any) {}
}
export class AnythingMatcher {}
export class Assertion<T = unknown, M extends TExecutionType = 'sync'> {
executionMode: M;
baseReference: any;
propertyDrillDown: Array<string | number> = [];
@ -33,8 +41,10 @@ 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) {
constructor(baseReferenceArg: any, executionModeArg: M) {
this.baseReference = baseReferenceArg;
this.executionMode = executionModeArg;
}
@ -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;
@ -139,20 +159,23 @@ export class Assertion<T = unknown> {
/**
* Assert that a Promise resolves.
*/
public get resolves(): this {
this.isResolves = true;
this.isRejects = false;
this.executionMode = 'async';
return this;
/**
* Switch to async (resolve) mode. Subsequent matchers return Promises.
*/
public get resolves(): Assertion<T, 'async'> {
return new Assertion<T, 'async'>(this.baseReference, 'async');
}
/**
* Assert that a Promise rejects.
*/
public get rejects(): this {
this.isRejects = true;
this.isResolves = false;
this.executionMode = 'async';
return this;
/**
* Switch to async (reject) mode. Subsequent matchers return Promises.
*/
public get rejects(): Assertion<T, 'async'> {
const a = new Assertion<T, 'async'>(this.baseReference, 'async');
// mark to expect rejection
(a as any).isRejects = true;
return a;
}
/**
@ -183,25 +206,29 @@ export class Assertion<T = unknown> {
return this;
}
private runCheck(checkFunction: () => any) {
// Internal check runner: returns Promise in async mode, else sync Assertion
// Internal check runner; returns Promise or this at runtime, but typed via customAssertion
private runCheck(checkFunction: () => any): any {
const runDirectOrNegated = (checkFunction: () => any) => {
if (!this.notSetting) {
return checkFunction();
} 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);
}
}
};
if (this.executionMode === 'async') {
const done = plugins.smartpromise.defer();
const done = plugins.smartpromise.defer<Assertion<T, M>>();
const isThenable = this.baseReference && typeof (this.baseReference as any).then === 'function';
if (!isThenable) {
done.reject(new Error(`Expected a Promise but received: ${this.formatValue(this.baseReference)}`));
@ -219,47 +246,59 @@ export class Assertion<T = unknown> {
(res: any) => {
done.reject(new Error(`Expected Promise to reject but it resolved with ${this.formatValue(res)}`));
},
(err: any) => {
this.baseReference = err;
try {
const ret = runDirectOrNegated(checkFunction);
done.resolve(ret);
} catch (e: any) {
done.reject(e);
(err: any) => {
this.baseReference = err;
try {
runDirectOrNegated(checkFunction);
done.resolve(this);
} catch (e: any) {
done.reject(e);
}
}
}
);
} else {
(this.baseReference as Promise<any>).then(
(res: any) => {
this.baseReference = res;
try {
const ret = runDirectOrNegated(checkFunction);
done.resolve(ret);
} catch (e: any) {
done.reject(e);
}
},
(res: any) => {
this.baseReference = res;
try {
runDirectOrNegated(checkFunction);
done.resolve(this);
} catch (e: any) {
done.reject(e);
}
},
(err: any) => {
done.reject(err);
}
);
}
return done.promise;
// return a promise resolving to this for chaining
return done.promise.then(() => this) as any;
}
return runDirectOrNegated(checkFunction);
// sync: run and return this for chaining
runDirectOrNegated(checkFunction);
return this as any;
}
/**
* Execute a custom assertion. Returns a Promise in async mode, else returns this.
*/
public customAssertion(
assertionFunction: (value: any) => boolean,
errorMessage: string
) {
errorMessage: string | ((value: any) => string)
): M extends 'async' ? Promise<Assertion<T, M>> : Assertion<T, M> {
// 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);
}
});
}) as any;
}
/**
@ -267,9 +306,9 @@ export class Assertion<T = unknown> {
* @param propertyName Name of the property to navigate into.
* @returns Assertion of the property type.
*/
public property<K extends keyof NonNullable<T>>(propertyName: K): Assertion<NonNullable<T>[K]> {
public property<K extends keyof NonNullable<T>>(propertyName: K): Assertion<NonNullable<T>[K], M> {
this.propertyDrillDown.push(propertyName as string);
return this as unknown as Assertion<NonNullable<T>[K]>;
return this as unknown as Assertion<NonNullable<T>[K], M>;
}
/**
@ -277,9 +316,9 @@ export class Assertion<T = unknown> {
* @param index Index of the array item.
* @returns Assertion of the element type.
*/
public arrayItem(index: number): Assertion<T extends Array<infer U> ? U : unknown> {
public arrayItem(index: number): Assertion<T extends Array<infer U> ? U : unknown, M> {
this.propertyDrillDown.push(index);
return this as unknown as Assertion<T extends Array<infer U> ? U : unknown>;
return this as unknown as Assertion<T extends Array<infer U> ? U : unknown, M>;
}
public log() {
@ -331,37 +370,38 @@ export class Assertion<T = unknown> {
public toBeTypeofBoolean() { return this.type.toBeTypeofBoolean(); }
public toBeTypeOf(typeName: string) { return this.type.toBeTypeOf(typeName); }
public toBeDefined() { return this.type.toBeDefined(); }
// Namespaced matcher accessors
/** String-specific matchers */
public get string() {
return new StringMatchers(this as Assertion<string>);
public get string(): StringMatchers<M> {
return new StringMatchers<M>(this as Assertion<string, M>);
}
/** Array-specific matchers */
public get array() {
return new ArrayMatchers<any>(this as Assertion<any[]>);
public get array(): ArrayMatchers<any, M> {
return new ArrayMatchers<any, M>(this as Assertion<any[], M>);
}
/** Number-specific matchers */
public get number() {
return new NumberMatchers(this as Assertion<number>);
public get number(): NumberMatchers<M> {
return new NumberMatchers<M>(this as Assertion<number, M>);
}
/** Boolean-specific matchers */
public get boolean() {
return new BooleanMatchers(this as Assertion<boolean>);
public get boolean(): BooleanMatchers<M> {
return new BooleanMatchers<M>(this as Assertion<boolean, M>);
}
/** Object-specific matchers */
public get object() {
return new ObjectMatchers<any>(this as Assertion<object>);
public get object(): ObjectMatchers<any, M> {
return new ObjectMatchers<any, M>(this as Assertion<object, M>);
}
/** Function-specific matchers */
public get function() {
return new FunctionMatchers(this as Assertion<Function>);
public get function(): FunctionMatchers<M> {
return new FunctionMatchers<M>(this as Assertion<Function, M>);
}
/** Date-specific matchers */
public get date() {
return new DateMatchers(this as Assertion<Date>);
public get date(): DateMatchers<M> {
return new DateMatchers<M>(this as Assertion<Date, M>);
}
/** Type-based matchers */
public get type() {
return new TypeMatchers(this as Assertion<any>);
public get type(): TypeMatchers<M> {
return new TypeMatchers<M>(this as Assertion<any, M>);
}
}