Compare commits
16 Commits
Author | SHA1 | Date | |
---|---|---|---|
6ea5d643db | |||
6f1326a8da | |||
f099d0f98d | |||
a3d5892a13 | |||
0d9fa72b29 | |||
8cb70b6afe | |||
81bd8bfb13 | |||
dd4037677d | |||
d0c2d04595 | |||
db49492af6 | |||
855e20a217 | |||
9b488a87a0 | |||
1847838ac3 | |||
91a3dc43d3 | |||
4eac4544a5 | |||
47458118a6 |
59
changelog.md
59
changelog.md
@ -1,5 +1,64 @@
|
||||
# Changelog
|
||||
|
||||
## 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.
|
||||
|
||||
- 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)
|
||||
Update documentation and examples to unify async and sync assertions, add custom matcher guides, and update package configuration
|
||||
|
||||
- Added packageManager field in package.json
|
||||
- Revised documentation in readme.md to use .resolves/.rejects instead of expectAsync
|
||||
- Included detailed examples for custom matchers and updated API usage
|
||||
- Added readme.plan.md outlining the future roadmap
|
||||
- Updated tests to import the built library from dist_ts
|
||||
|
||||
## 2025-03-04 - 1.6.1 - fix(build)
|
||||
Corrected package.json and workflow dependencies and resolved formatting issues in tests.
|
||||
|
||||
|
@ -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
|
@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@push.rocks/smartexpect",
|
||||
"version": "1.6.1",
|
||||
"version": "2.2.2",
|
||||
"private": false,
|
||||
"description": "A testing library to manage expectations in code, offering both synchronous and asynchronous assertion methods.",
|
||||
"main": "dist_ts/index.js",
|
||||
@ -62,5 +62,6 @@
|
||||
"onlyBuiltDependencies": [
|
||||
"mongodb-memory-server"
|
||||
]
|
||||
}
|
||||
},
|
||||
"packageManager": "pnpm@10.7.0+sha512.6b865ad4b62a1d9842b61d674a393903b871d9244954f652b8842c2b553c72176b278f64c463e52d40fff8aba385c235c8c9ecf5cc7de4fd78b8bb6d49633ab6"
|
||||
}
|
||||
|
126
readme.md
126
readme.md
@ -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
|
||||
@ -53,10 +53,10 @@ expect('hithere').toMatch(/hi/);
|
||||
|
||||
### Asynchronous Expectations
|
||||
|
||||
For asynchronous operations, use `expectAsync` to return a promise:
|
||||
For asynchronous code, use the same `expect` function with the `.resolves` or `.rejects` modifier:
|
||||
|
||||
```typescript
|
||||
import { expectAsync } from '@push.rocks/smartexpect';
|
||||
import { expect } from '@push.rocks/smartexpect';
|
||||
|
||||
const asyncStringFetcher = async (): Promise<string> => {
|
||||
return 'async string';
|
||||
@ -64,8 +64,8 @@ const asyncStringFetcher = async (): Promise<string> => {
|
||||
|
||||
const asyncTest = async () => {
|
||||
// Add a timeout to prevent hanging tests
|
||||
await expectAsync(asyncStringFetcher()).timeout(5000).toBeTypeofString();
|
||||
await expectAsync(asyncStringFetcher()).toEqual('async string');
|
||||
await expect(asyncStringFetcher()).resolves.withTimeout(5000).type.toBeTypeofString();
|
||||
await expect(asyncStringFetcher()).resolves.toEqual('async string');
|
||||
};
|
||||
|
||||
asyncTest();
|
||||
@ -230,6 +230,120 @@ expect(user.age)
|
||||
.toBeGreaterThanOrEqual(18);
|
||||
```
|
||||
|
||||
### Custom Matchers
|
||||
|
||||
You can define your own matchers via `expect.extend()`:
|
||||
|
||||
```typescript
|
||||
expect.extend({
|
||||
toBeOdd(received: number) {
|
||||
const pass = received % 2 === 1;
|
||||
return {
|
||||
pass,
|
||||
message: () =>
|
||||
`Expected ${received} ${pass ? 'not ' : ''}to be odd`,
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
// Then use your custom matcher in tests:
|
||||
expect(3).toBeOdd();
|
||||
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.
|
||||
|
55
readme.plan.md
Normal file
55
readme.plan.md
Normal file
@ -0,0 +1,55 @@
|
||||
# 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
|
||||
- [x] Add `expect.any()` and `expect.anything()` matchers for use in `.toMatchObject()` patterns
|
||||
(Snapshot matchers still TBD)
|
||||
|
||||
The next items to tackle:
|
||||
|
||||
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"
|
||||
|
||||
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
|
||||
|
||||
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
|
@ -1,25 +1,26 @@
|
||||
import { tap } from '@push.rocks/tapbundle';
|
||||
import * as smartexpect from '../ts/index.js';
|
||||
// Import the built library (dist_ts) so all matcher implementations are available
|
||||
import * as smartexpect from '../dist_ts/index.js';
|
||||
|
||||
tap.test('basic type assertions', async () => {
|
||||
// String type checks
|
||||
smartexpect.expect('hello').toBeTypeofString();
|
||||
smartexpect.expect(1).not.toBeTypeofString();
|
||||
smartexpect.expect('hello').type.toBeTypeofString();
|
||||
smartexpect.expect(1).not.type.toBeTypeofString();
|
||||
|
||||
// Boolean type checks
|
||||
smartexpect.expect(true).toBeTypeofBoolean();
|
||||
smartexpect.expect(false).toBeTypeofBoolean();
|
||||
smartexpect.expect(1).not.toBeTypeofBoolean();
|
||||
smartexpect.expect(true).type.toBeTypeofBoolean();
|
||||
smartexpect.expect(false).type.toBeTypeofBoolean();
|
||||
smartexpect.expect(1).not.type.toBeTypeofBoolean();
|
||||
|
||||
// Number type checks
|
||||
smartexpect.expect(42).toBeTypeofNumber();
|
||||
smartexpect.expect(true).not.toBeTypeofNumber();
|
||||
smartexpect.expect(42).type.toBeTypeofNumber();
|
||||
smartexpect.expect(true).not.type.toBeTypeofNumber();
|
||||
|
||||
// Generic type checks with new method
|
||||
smartexpect.expect(() => {}).toBeTypeOf('function');
|
||||
smartexpect.expect(class Test {}).toBeTypeOf('function');
|
||||
smartexpect.expect({}).toBeTypeOf('object');
|
||||
smartexpect.expect(Symbol('test')).toBeTypeOf('symbol');
|
||||
smartexpect.expect(() => {}).type.toBeTypeOf('function');
|
||||
smartexpect.expect(class Test {}).type.toBeTypeOf('function');
|
||||
smartexpect.expect({}).type.toBeTypeOf('object');
|
||||
smartexpect.expect(Symbol('test')).type.toBeTypeOf('symbol');
|
||||
});
|
||||
|
||||
tap.test('async tests', async (toolsArg) => {
|
||||
@ -27,8 +28,9 @@ tap.test('async tests', async (toolsArg) => {
|
||||
toolsArg.delayFor(1000).then(() => {
|
||||
deferred.resolve('hello');
|
||||
});
|
||||
await smartexpect.expectAsync(deferred.promise).timeout(2000).toBeTypeofString();
|
||||
await smartexpect.expectAsync(deferred.promise).not.toBeTypeofBoolean();
|
||||
// Using .resolves to test promise resolution with timeout
|
||||
await smartexpect.expect(deferred.promise).resolves.withTimeout(2000).type.toBeTypeofString();
|
||||
await smartexpect.expect(deferred.promise).resolves.not.type.toBeTypeofBoolean();
|
||||
|
||||
// Test async timeout handling
|
||||
const longOperation = toolsArg.defer();
|
||||
@ -36,38 +38,39 @@ tap.test('async tests', async (toolsArg) => {
|
||||
longOperation.resolve('completed');
|
||||
});
|
||||
try {
|
||||
await smartexpect.expectAsync(longOperation.promise).timeout(1000).toBeDefined();
|
||||
// Assert that resolution must occur within timeout
|
||||
await smartexpect.expect(longOperation.promise).resolves.withTimeout(1000).toBeDefined();
|
||||
throw new Error('Should have timed out');
|
||||
} catch (err) {
|
||||
// Expected timeout error
|
||||
// Successfully caught timeout error from .withTimeout
|
||||
console.log('Successfully caught timeout:', err.message);
|
||||
}
|
||||
});
|
||||
|
||||
tap.test('equality and matching assertions', async () => {
|
||||
// Basic equality
|
||||
smartexpect.expect('hithere').toEqual('hithere');
|
||||
smartexpect.expect('hithere').not.toEqual('hithere2');
|
||||
smartexpect.expect('hithere').object.toEqual('hithere');
|
||||
smartexpect.expect('hithere').not.object.toEqual('hithere2');
|
||||
|
||||
// Object equality
|
||||
const obj1 = { a: 1, b: { c: true } };
|
||||
const obj2 = { a: 1, b: { c: true } };
|
||||
const obj3 = { a: 1, b: { c: false } };
|
||||
smartexpect.expect(obj1).toEqual(obj2);
|
||||
smartexpect.expect(obj1).not.toEqual(obj3);
|
||||
smartexpect.expect(obj1).object.toEqual(obj2);
|
||||
smartexpect.expect(obj1).not.object.toEqual(obj3);
|
||||
|
||||
// RegExp matching
|
||||
smartexpect.expect('hithere').toMatch(/hi/);
|
||||
smartexpect.expect('hithere').toMatch(/^hithere$/);
|
||||
smartexpect.expect('hithere').not.toMatch(/ho/);
|
||||
smartexpect.expect('hithere').string.toMatch(/hi/);
|
||||
smartexpect.expect('hithere').string.toMatch(/^hithere$/);
|
||||
smartexpect.expect('hithere').not.string.toMatch(/ho/);
|
||||
|
||||
// String inclusion
|
||||
smartexpect.expect('hithere').toInclude('hit');
|
||||
smartexpect.expect('hithere').not.toInclude('missing');
|
||||
smartexpect.expect('hithere').string.toInclude('hit');
|
||||
smartexpect.expect('hithere').not.string.toInclude('missing');
|
||||
|
||||
// String start/end
|
||||
smartexpect.expect('hithere').toStartWith('hi');
|
||||
smartexpect.expect('hithere').toEndWith('ere');
|
||||
smartexpect.expect('hithere').string.toStartWith('hi');
|
||||
smartexpect.expect('hithere').string.toEndWith('ere');
|
||||
});
|
||||
|
||||
tap.test('object property assertions', async () => {
|
||||
@ -82,21 +85,21 @@ tap.test('object property assertions', async () => {
|
||||
};
|
||||
|
||||
// Basic property checks
|
||||
smartexpect.expect(testObject).toHaveProperty('topLevel');
|
||||
smartexpect.expect(testObject).toHaveProperty('topLevel', 'hello');
|
||||
smartexpect.expect(testObject).not.toHaveProperty('missing');
|
||||
smartexpect.expect(testObject).object.toHaveProperty('topLevel');
|
||||
smartexpect.expect(testObject).object.toHaveProperty('topLevel', 'hello');
|
||||
smartexpect.expect(testObject).not.object.toHaveProperty('missing');
|
||||
|
||||
// Drill-down property navigation
|
||||
smartexpect.expect(testObject).property('nested').toHaveProperty('prop', 42);
|
||||
smartexpect.expect(testObject).property('nested').object.toHaveProperty('prop', 42);
|
||||
smartexpect
|
||||
.expect(testObject)
|
||||
.property('nested')
|
||||
.property('deeplyNested')
|
||||
.property('array')
|
||||
.toBeArray();
|
||||
.array.toBeArray();
|
||||
|
||||
// Deep property checks
|
||||
smartexpect.expect(testObject).toHaveDeepProperty(['nested', 'deeplyNested', 'array']);
|
||||
smartexpect.expect(testObject).object.toHaveDeepProperty(['nested', 'deeplyNested', 'array']);
|
||||
|
||||
// Array item navigation
|
||||
smartexpect
|
||||
@ -105,18 +108,18 @@ tap.test('object property assertions', async () => {
|
||||
.property('deeplyNested')
|
||||
.property('array')
|
||||
.arrayItem(0)
|
||||
.toEqual(1);
|
||||
.number.toEqual(1); // numeric equality via number namespace
|
||||
});
|
||||
|
||||
tap.test('numeric comparison assertions', async () => {
|
||||
// Greater/less than
|
||||
smartexpect.expect(4).toBeGreaterThan(3);
|
||||
smartexpect.expect(4).toBeLessThan(5);
|
||||
smartexpect.expect(4).toBeGreaterThanOrEqual(4);
|
||||
smartexpect.expect(4).toBeLessThanOrEqual(4);
|
||||
smartexpect.expect(4).number.toBeGreaterThan(3);
|
||||
smartexpect.expect(4).number.toBeLessThan(5);
|
||||
smartexpect.expect(4).number.toBeGreaterThanOrEqual(4);
|
||||
smartexpect.expect(4).number.toBeLessThanOrEqual(4);
|
||||
|
||||
// Approximate equality
|
||||
smartexpect.expect(0.1 + 0.2).toBeCloseTo(0.3, 10);
|
||||
smartexpect.expect(0.1 + 0.2).number.toBeCloseTo(0.3, 10);
|
||||
});
|
||||
|
||||
tap.test('array assertions', async () => {
|
||||
@ -125,43 +128,43 @@ tap.test('array assertions', async () => {
|
||||
const testArray = [1, 'two', obj1, true];
|
||||
|
||||
// Basic array checks
|
||||
smartexpect.expect(testArray).toBeArray();
|
||||
smartexpect.expect(testArray).toHaveLength(4);
|
||||
smartexpect.expect(testArray).array.toBeArray();
|
||||
smartexpect.expect(testArray).array.toHaveLength(4);
|
||||
|
||||
// Content checks
|
||||
smartexpect.expect(testArray).toContain('two');
|
||||
smartexpect.expect(testArray).toContain(obj1);
|
||||
smartexpect.expect(testArray).not.toContain(obj2);
|
||||
smartexpect.expect(testArray).array.toContain('two');
|
||||
smartexpect.expect(testArray).array.toContain(obj1);
|
||||
smartexpect.expect(testArray).not.array.toContain(obj2);
|
||||
|
||||
// Array with equal items (not same reference)
|
||||
smartexpect.expect([{ a: 1 }, { b: 2 }]).toContainEqual({ a: 1 });
|
||||
smartexpect.expect([{ a: 1 }, { b: 2 }]).array.toContainEqual({ a: 1 });
|
||||
|
||||
// Multiple values
|
||||
smartexpect.expect(testArray).toContainAll([1, 'two']);
|
||||
smartexpect.expect(testArray).toExclude('missing');
|
||||
smartexpect.expect(testArray).array.toContainAll([1, 'two']);
|
||||
smartexpect.expect(testArray).array.toExclude('missing');
|
||||
|
||||
// Empty array
|
||||
smartexpect.expect([]).toBeEmptyArray();
|
||||
smartexpect.expect([]).array.toBeEmptyArray();
|
||||
|
||||
// Length comparisons
|
||||
smartexpect.expect(testArray).toHaveLengthGreaterThan(3);
|
||||
smartexpect.expect(testArray).toHaveLengthLessThan(5);
|
||||
smartexpect.expect(testArray).array.toHaveLengthGreaterThan(3);
|
||||
smartexpect.expect(testArray).array.toHaveLengthLessThan(5);
|
||||
});
|
||||
|
||||
tap.test('boolean assertions', async () => {
|
||||
// True/False
|
||||
smartexpect.expect(true).toBeTrue();
|
||||
smartexpect.expect(false).toBeFalse();
|
||||
smartexpect.expect(true).boolean.toBeTrue();
|
||||
smartexpect.expect(false).boolean.toBeFalse();
|
||||
|
||||
// Truthy/Falsy
|
||||
smartexpect.expect('something').toBeTruthy();
|
||||
smartexpect.expect(0).toBeFalsy();
|
||||
smartexpect.expect('something').boolean.toBeTruthy();
|
||||
smartexpect.expect(0).boolean.toBeFalsy();
|
||||
|
||||
// Null/Undefined
|
||||
smartexpect.expect(null).toBeNull();
|
||||
smartexpect.expect(undefined).toBeUndefined();
|
||||
smartexpect.expect(null).toBeNullOrUndefined();
|
||||
smartexpect.expect(undefined).toBeNullOrUndefined();
|
||||
smartexpect.expect(null).object.toBeNull();
|
||||
smartexpect.expect(undefined).object.toBeUndefined();
|
||||
smartexpect.expect(null).object.toBeNullOrUndefined();
|
||||
smartexpect.expect(undefined).object.toBeNullOrUndefined();
|
||||
});
|
||||
|
||||
tap.test('function assertions', async () => {
|
||||
@ -169,12 +172,12 @@ tap.test('function assertions', async () => {
|
||||
const throwingFn = () => {
|
||||
throw new Error('test error');
|
||||
};
|
||||
smartexpect.expect(throwingFn).toThrow();
|
||||
smartexpect.expect(throwingFn).toThrow(Error);
|
||||
smartexpect.expect(throwingFn).function.toThrow();
|
||||
smartexpect.expect(throwingFn).function.toThrow(Error);
|
||||
|
||||
// Function that doesn't throw
|
||||
const nonThrowingFn = () => 'safe';
|
||||
smartexpect.expect(nonThrowingFn).not.toThrow();
|
||||
smartexpect.expect(nonThrowingFn).not.function.toThrow();
|
||||
});
|
||||
|
||||
tap.test('date assertions', async () => {
|
||||
@ -182,9 +185,9 @@ tap.test('date assertions', async () => {
|
||||
const past = new Date(Date.now() - 10000);
|
||||
const future = new Date(Date.now() + 10000);
|
||||
|
||||
smartexpect.expect(now).toBeDate();
|
||||
smartexpect.expect(now).toBeAfterDate(past);
|
||||
smartexpect.expect(now).toBeBeforeDate(future);
|
||||
smartexpect.expect(now).date.toBeDate();
|
||||
smartexpect.expect(now).date.toBeAfterDate(past);
|
||||
smartexpect.expect(now).date.toBeBeforeDate(future);
|
||||
});
|
||||
|
||||
tap.test('custom assertions', async () => {
|
||||
@ -192,7 +195,7 @@ tap.test('custom assertions', async () => {
|
||||
smartexpect.expect(42).customAssertion((value) => value % 2 === 0, 'Expected number to be even');
|
||||
|
||||
// With fail message
|
||||
smartexpect.expect('test').setFailMessage('Custom fail message for assertion').toHaveLength(4);
|
||||
smartexpect.expect('test').setFailMessage('Custom fail message for assertion').string.toHaveLength(4);
|
||||
});
|
||||
|
||||
tap.test('logging and debugging', async () => {
|
||||
@ -212,7 +215,7 @@ tap.test('logging and debugging', async () => {
|
||||
.property('level2')
|
||||
.log()
|
||||
.property('value')
|
||||
.toEqual('nested value');
|
||||
.object.toEqual('nested value');
|
||||
});
|
||||
|
||||
export default tap.start();
|
||||
|
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: '1.6.1',
|
||||
version: '2.2.2',
|
||||
description: 'A testing library to manage expectations in code, offering both synchronous and asynchronous assertion methods.'
|
||||
}
|
||||
|
49
ts/index.ts
49
ts/index.ts
@ -1,12 +1,41 @@
|
||||
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
|
||||
|
||||
export const expect = (baseArg: any) => {
|
||||
const assertion = new Assertion(baseArg, 'sync');
|
||||
return assertion;
|
||||
};
|
||||
|
||||
export const expectAsync = (baseArg: any) => {
|
||||
const assertion = new Assertion(baseArg, 'async');
|
||||
return assertion;
|
||||
};
|
||||
/**
|
||||
* Primary entry point for assertions.
|
||||
* Automatically detects Promises to support async assertions.
|
||||
*/
|
||||
/**
|
||||
* The `expect` function interface. Supports custom matchers via .extend.
|
||||
*/
|
||||
/**
|
||||
* Entry point for assertions.
|
||||
* Automatically detects Promises to support async assertions.
|
||||
*/
|
||||
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, 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();
|
||||
}
|
||||
}
|
||||
|
||||
|
81
ts/namespaces/array.ts
Normal file
81
ts/namespaces/array.ts
Normal file
@ -0,0 +1,81 @@
|
||||
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, M extends TExecutionType> {
|
||||
constructor(private assertion: Assertion<T[], M>) {}
|
||||
|
||||
toBeArray() {
|
||||
return this.assertion.customAssertion(
|
||||
(value) => Array.isArray(value),
|
||||
`Expected value to be array`
|
||||
);
|
||||
}
|
||||
|
||||
toHaveLength(length: number) {
|
||||
return this.assertion.customAssertion(
|
||||
(value) => (value as T[]).length === length,
|
||||
`Expected array to have length ${length}`
|
||||
);
|
||||
}
|
||||
|
||||
toContain(item: T) {
|
||||
return this.assertion.customAssertion(
|
||||
(value) => (value as T[]).includes(item),
|
||||
`Expected array to contain ${JSON.stringify(item)}`
|
||||
);
|
||||
}
|
||||
|
||||
toContainEqual(item: T) {
|
||||
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[]) {
|
||||
return this.assertion.customAssertion(
|
||||
(value) => items.every((i) => (value as T[]).includes(i)),
|
||||
`Expected array to contain all ${JSON.stringify(items)}`
|
||||
);
|
||||
}
|
||||
|
||||
toExclude(item: T) {
|
||||
return this.assertion.customAssertion(
|
||||
(value) => !(value as T[]).includes(item),
|
||||
`Expected array to exclude ${JSON.stringify(item)}`
|
||||
);
|
||||
}
|
||||
|
||||
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) {
|
||||
return this.assertion.customAssertion(
|
||||
(value) => (value as T[]).length > length,
|
||||
`Expected array to have length greater than ${length}`
|
||||
);
|
||||
}
|
||||
|
||||
toHaveLengthLessThan(length: number) {
|
||||
return this.assertion.customAssertion(
|
||||
(value) => (value as T[]).length < length,
|
||||
`Expected array to have length less than ${length}`
|
||||
);
|
||||
}
|
||||
}
|
37
ts/namespaces/boolean.ts
Normal file
37
ts/namespaces/boolean.ts
Normal file
@ -0,0 +1,37 @@
|
||||
import { Assertion } from '../smartexpect.classes.assertion.js';
|
||||
import type { TExecutionType } from '../types.js';
|
||||
|
||||
/**
|
||||
* Namespace for boolean-specific matchers
|
||||
*/
|
||||
export class BooleanMatchers<M extends TExecutionType> {
|
||||
constructor(private assertion: Assertion<boolean, M>) {}
|
||||
|
||||
toBeTrue() {
|
||||
return this.assertion.customAssertion(
|
||||
(v) => v === true,
|
||||
`Expected value to be true`
|
||||
);
|
||||
}
|
||||
|
||||
toBeFalse() {
|
||||
return this.assertion.customAssertion(
|
||||
(v) => v === false,
|
||||
`Expected value to be false`
|
||||
);
|
||||
}
|
||||
|
||||
toBeTruthy() {
|
||||
return this.assertion.customAssertion(
|
||||
(v) => Boolean(v),
|
||||
`Expected value to be truthy`
|
||||
);
|
||||
}
|
||||
|
||||
toBeFalsy() {
|
||||
return this.assertion.customAssertion(
|
||||
(v) => !v,
|
||||
`Expected value to be falsy`
|
||||
);
|
||||
}
|
||||
}
|
30
ts/namespaces/date.ts
Normal file
30
ts/namespaces/date.ts
Normal file
@ -0,0 +1,30 @@
|
||||
import { Assertion } from '../smartexpect.classes.assertion.js';
|
||||
import type { TExecutionType } from '../types.js';
|
||||
|
||||
/**
|
||||
* Namespace for date-specific matchers
|
||||
*/
|
||||
export class DateMatchers<M extends TExecutionType> {
|
||||
constructor(private assertion: Assertion<Date, M>) {}
|
||||
|
||||
toBeDate() {
|
||||
return this.assertion.customAssertion(
|
||||
(v) => v instanceof Date,
|
||||
`Expected value to be a Date instance`
|
||||
);
|
||||
}
|
||||
|
||||
toBeBeforeDate(date: 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) {
|
||||
return this.assertion.customAssertion(
|
||||
(v) => v instanceof Date && (v as Date).getTime() > date.getTime(),
|
||||
`Expected date to be after ${date.toISOString()}`
|
||||
);
|
||||
}
|
||||
}
|
62
ts/namespaces/function.ts
Normal file
62
ts/namespaces/function.ts
Normal file
@ -0,0 +1,62 @@
|
||||
import { Assertion } from '../smartexpect.classes.assertion.js';
|
||||
import type { TExecutionType } from '../types.js';
|
||||
|
||||
/**
|
||||
* Namespace for function-specific matchers
|
||||
*/
|
||||
export class FunctionMatchers<M extends TExecutionType> {
|
||||
constructor(private assertion: Assertion<Function, M>) {}
|
||||
|
||||
toThrow(expectedError?: any) {
|
||||
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}"`
|
||||
);
|
||||
}
|
||||
}
|
8
ts/namespaces/index.ts
Normal file
8
ts/namespaces/index.ts
Normal file
@ -0,0 +1,8 @@
|
||||
export { StringMatchers } from './string.js';
|
||||
export { ArrayMatchers } from './array.js';
|
||||
export { NumberMatchers } from './number.js';
|
||||
export { BooleanMatchers } from './boolean.js';
|
||||
export { ObjectMatchers } from './object.js';
|
||||
export { FunctionMatchers } from './function.js';
|
||||
export { DateMatchers } from './date.js';
|
||||
export { TypeMatchers } from './type.js';
|
84
ts/namespaces/number.ts
Normal file
84
ts/namespaces/number.ts
Normal file
@ -0,0 +1,84 @@
|
||||
import { Assertion } from '../smartexpect.classes.assertion.js';
|
||||
import type { TExecutionType } from '../types.js';
|
||||
|
||||
/**
|
||||
* Namespace for number-specific matchers
|
||||
*/
|
||||
export class NumberMatchers<M extends TExecutionType> {
|
||||
constructor(private assertion: Assertion<number, M>) {}
|
||||
|
||||
toBeGreaterThan(value: number) {
|
||||
return this.assertion.customAssertion(
|
||||
(v) => (v as number) > value,
|
||||
`Expected number to be greater than ${value}`
|
||||
);
|
||||
}
|
||||
|
||||
toBeLessThan(value: number) {
|
||||
return this.assertion.customAssertion(
|
||||
(v) => (v as number) < value,
|
||||
`Expected number to be less than ${value}`
|
||||
);
|
||||
}
|
||||
|
||||
toBeGreaterThanOrEqual(value: number) {
|
||||
return this.assertion.customAssertion(
|
||||
(v) => (v as number) >= value,
|
||||
`Expected number to be greater than or equal to ${value}`
|
||||
);
|
||||
}
|
||||
|
||||
toBeLessThanOrEqual(value: number) {
|
||||
return this.assertion.customAssertion(
|
||||
(v) => (v as number) <= value,
|
||||
`Expected number to be less than or equal to ${value}`
|
||||
);
|
||||
}
|
||||
|
||||
toBeCloseTo(value: number, precision?: number) {
|
||||
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 */
|
||||
toEqual(value: number) {
|
||||
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}`
|
||||
);
|
||||
}
|
||||
}
|
133
ts/namespaces/object.ts
Normal file
133
ts/namespaces/object.ts
Normal file
@ -0,0 +1,133 @@
|
||||
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, M extends TExecutionType> {
|
||||
constructor(private assertion: Assertion<T, M>) {}
|
||||
|
||||
toEqual(expected: any) {
|
||||
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) {
|
||||
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) {
|
||||
return this.assertion.customAssertion(
|
||||
(v) => (v as any) instanceof constructor,
|
||||
`Expected object to be instance of ${constructor.name || constructor}`
|
||||
);
|
||||
}
|
||||
|
||||
toHaveProperty(property: string, value?: any) {
|
||||
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[]) {
|
||||
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() {
|
||||
return this.assertion.customAssertion(
|
||||
(v) => v === null,
|
||||
`Expected value to be null`
|
||||
);
|
||||
}
|
||||
|
||||
toBeUndefined() {
|
||||
return this.assertion.customAssertion(
|
||||
(v) => v === undefined,
|
||||
`Expected value to be undefined`
|
||||
);
|
||||
}
|
||||
|
||||
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)}`
|
||||
);
|
||||
}
|
||||
}
|
60
ts/namespaces/string.ts
Normal file
60
ts/namespaces/string.ts
Normal file
@ -0,0 +1,60 @@
|
||||
import { Assertion } from '../smartexpect.classes.assertion.js';
|
||||
import type { TExecutionType } from '../types.js';
|
||||
|
||||
/**
|
||||
* Namespace for string-specific matchers
|
||||
*/
|
||||
export class StringMatchers<M extends TExecutionType> {
|
||||
constructor(private assertion: Assertion<string, M>) {}
|
||||
|
||||
toStartWith(prefix: string) {
|
||||
return this.assertion.customAssertion(
|
||||
(value) => (value as string).startsWith(prefix),
|
||||
`Expected string to start with "${prefix}"`
|
||||
);
|
||||
}
|
||||
|
||||
toEndWith(suffix: string) {
|
||||
return this.assertion.customAssertion(
|
||||
(value) => (value as string).endsWith(suffix),
|
||||
`Expected string to end with "${suffix}"`
|
||||
);
|
||||
}
|
||||
|
||||
toInclude(substring: string) {
|
||||
return this.assertion.customAssertion(
|
||||
(value) => (value as string).includes(substring),
|
||||
`Expected string to include "${substring}"`
|
||||
);
|
||||
}
|
||||
|
||||
toMatch(regex: RegExp) {
|
||||
return this.assertion.customAssertion(
|
||||
(value) => regex.test(value as string),
|
||||
`Expected string to match ${regex}`
|
||||
);
|
||||
}
|
||||
|
||||
toBeOneOf(values: string[]) {
|
||||
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 */
|
||||
toHaveLength(length: number) {
|
||||
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`
|
||||
);
|
||||
}
|
||||
}
|
44
ts/namespaces/type.ts
Normal file
44
ts/namespaces/type.ts
Normal file
@ -0,0 +1,44 @@
|
||||
import { Assertion } from '../smartexpect.classes.assertion.js';
|
||||
import type { TExecutionType } from '../types.js';
|
||||
|
||||
/**
|
||||
* Namespace for type-based matchers
|
||||
*/
|
||||
export class TypeMatchers<M extends TExecutionType> {
|
||||
constructor(private assertion: Assertion<any, M>) {}
|
||||
|
||||
toBeTypeofString() {
|
||||
return this.assertion.customAssertion(
|
||||
(v) => typeof v === 'string',
|
||||
`Expected type to be 'string'`
|
||||
);
|
||||
}
|
||||
|
||||
toBeTypeofNumber() {
|
||||
return this.assertion.customAssertion(
|
||||
(v) => typeof v === 'number',
|
||||
`Expected type to be 'number'`
|
||||
);
|
||||
}
|
||||
|
||||
toBeTypeofBoolean() {
|
||||
return this.assertion.customAssertion(
|
||||
(v) => typeof v === 'boolean',
|
||||
`Expected type to be 'boolean'`
|
||||
);
|
||||
}
|
||||
|
||||
toBeTypeOf(typeName: string) {
|
||||
return this.assertion.customAssertion(
|
||||
(v) => typeof v === typeName,
|
||||
`Expected type to be '${typeName}'`
|
||||
);
|
||||
}
|
||||
|
||||
toBeDefined() {
|
||||
return this.assertion.customAssertion(
|
||||
(v) => v !== undefined,
|
||||
`Expected value to be defined`
|
||||
);
|
||||
}
|
||||
}
|
@ -1,9 +1,7 @@
|
||||
import * as smartdelay from '@push.rocks/smartdelay';
|
||||
import * as smartpromise from '@push.rocks/smartpromise';
|
||||
|
||||
export { smartdelay, smartpromise };
|
||||
|
||||
// third party scope
|
||||
// third party utilities
|
||||
import fastDeepEqual from 'fast-deep-equal';
|
||||
|
||||
export { fastDeepEqual };
|
@ -1,21 +1,79 @@
|
||||
import * as plugins from './smartexpect.plugins.js';
|
||||
import * as plugins from './plugins.js';
|
||||
import {
|
||||
StringMatchers,
|
||||
ArrayMatchers,
|
||||
NumberMatchers,
|
||||
BooleanMatchers,
|
||||
ObjectMatchers,
|
||||
FunctionMatchers,
|
||||
DateMatchers,
|
||||
TypeMatchers,
|
||||
} from './namespaces/index.js';
|
||||
/**
|
||||
* Definition of a custom matcher function.
|
||||
* Should return an object with `pass` and optional `message`.
|
||||
*/
|
||||
import type { TMatcher, TExecutionType } from './types.js';
|
||||
|
||||
export type TExecutionType = 'sync' | 'async';
|
||||
/**
|
||||
* 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;
|
||||
export class Assertion<T = unknown, M extends TExecutionType = 'sync'> {
|
||||
executionMode: M;
|
||||
baseReference: any;
|
||||
propertyDrillDown: Array<string | number> = [];
|
||||
|
||||
private notSetting = false;
|
||||
private timeoutSetting = 0;
|
||||
/** Registry of user-defined custom matchers */
|
||||
private static customMatchers: Record<string, TMatcher> = {};
|
||||
/** Flag for Promise rejection assertions */
|
||||
private isRejects = false;
|
||||
/** Flag for Promise resolution assertions (default for async) */
|
||||
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;
|
||||
}
|
||||
/**
|
||||
* Register custom matchers to be available on all assertions.
|
||||
* @param matchers An object whose keys are matcher names and values are matcher functions.
|
||||
*/
|
||||
public static extend(matchers: Record<string, TMatcher>): void {
|
||||
for (const [name, fn] of Object.entries(matchers)) {
|
||||
if ((Assertion.prototype as any)[name]) {
|
||||
throw new Error(`Cannot extend. Matcher '${name}' already exists on Assertion.`);
|
||||
}
|
||||
// store in registry
|
||||
Assertion.customMatchers[name] = fn;
|
||||
// add method to prototype
|
||||
(Assertion.prototype as any)[name] = function (...args: any[]) {
|
||||
return this.runCheck(() => {
|
||||
const received = this.getObjectToTestReference();
|
||||
const result = fn(received, ...args);
|
||||
const pass = result.pass;
|
||||
const msg = result.message;
|
||||
if (!pass) {
|
||||
const message = typeof msg === 'function' ? msg() : msg;
|
||||
throw new Error(message || `Custom matcher '${name}' failed`);
|
||||
}
|
||||
});
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
private getObjectToTestReference() {
|
||||
let returnObjectToTestReference = this.baseReference;
|
||||
@ -83,13 +141,57 @@ 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;
|
||||
return this;
|
||||
}
|
||||
/**
|
||||
* Assert that a Promise resolves.
|
||||
*/
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
/**
|
||||
* 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;
|
||||
}
|
||||
|
||||
/**
|
||||
* @deprecated use `.withTimeout(ms)` instead for clarity
|
||||
* Set a timeout (in ms) for async assertions (Promise must settle before timeout).
|
||||
*/
|
||||
public timeout(millisArg: number) {
|
||||
// eslint-disable-next-line no-console
|
||||
console.warn('[DEPRECATED] .timeout() is deprecated. Use .withTimeout(ms)');
|
||||
this.timeoutSetting = millisArg;
|
||||
return this;
|
||||
}
|
||||
/**
|
||||
* Set a timeout (in ms) for async assertions (Promise must settle before timeout).
|
||||
*/
|
||||
public withTimeout(millisArg: number) {
|
||||
this.timeoutSetting = millisArg;
|
||||
return this;
|
||||
}
|
||||
@ -104,28 +206,34 @@ export class Assertion {
|
||||
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();
|
||||
if (!(this.baseReference instanceof Promise)) {
|
||||
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)}`));
|
||||
} else {
|
||||
return done.promise;
|
||||
}
|
||||
if (this.timeoutSetting) {
|
||||
plugins.smartdelay.delayFor(this.timeoutSetting).then(() => {
|
||||
if (done.status === 'pending') {
|
||||
@ -133,598 +241,84 @@ export class Assertion {
|
||||
}
|
||||
});
|
||||
}
|
||||
this.baseReference.then((promiseResultArg: any) => {
|
||||
this.baseReference = promiseResultArg;
|
||||
done.resolve(runDirectOrNegated(checkFunction));
|
||||
});
|
||||
}
|
||||
return done.promise;
|
||||
} else {
|
||||
return runDirectOrNegated(checkFunction);
|
||||
}
|
||||
}
|
||||
|
||||
public toBeDefined() {
|
||||
return this.runCheck(() => {
|
||||
if (this.getObjectToTestReference() === undefined) {
|
||||
throw new Error(
|
||||
this.createErrorMessage('Expected value{path} to be defined, but got undefined')
|
||||
);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
public toBeTypeofString() {
|
||||
return this.runCheck(() => {
|
||||
const value = this.getObjectToTestReference();
|
||||
if (typeof value !== 'string') {
|
||||
throw new Error(
|
||||
this.createErrorMessage(`Expected value{path} to be of type string, but got ${typeof value}`)
|
||||
);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
public toBeTypeofNumber() {
|
||||
return this.runCheck(() => {
|
||||
const value = this.getObjectToTestReference();
|
||||
if (typeof value !== 'number') {
|
||||
throw new Error(
|
||||
this.createErrorMessage(`Expected value{path} to be of type number, but got ${typeof value}`)
|
||||
);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
public toBeTypeofBoolean() {
|
||||
return this.runCheck(() => {
|
||||
const value = this.getObjectToTestReference();
|
||||
if (typeof value !== 'boolean') {
|
||||
throw new Error(
|
||||
this.createErrorMessage(`Expected value{path} to be of type boolean, but got ${typeof value}`)
|
||||
);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
public toBeTypeOf(expectedType: string) {
|
||||
return this.runCheck(() => {
|
||||
const value = this.getObjectToTestReference();
|
||||
const actualType = typeof value;
|
||||
if (actualType !== expectedType) {
|
||||
throw new Error(
|
||||
this.createErrorMessage(`Expected value{path} to be of type ${expectedType}, but got ${actualType}`)
|
||||
);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
public toEqual(comparisonObject: any) {
|
||||
return this.runCheck(() => {
|
||||
const value = this.getObjectToTestReference();
|
||||
const result = plugins.fastDeepEqual(value, comparisonObject);
|
||||
if (!result) {
|
||||
throw new Error(
|
||||
this.createErrorMessage(`Expected value{path} to equal ${this.formatValue(comparisonObject)}`)
|
||||
);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
public toMatch(comparisonObject: RegExp) {
|
||||
return this.runCheck(() => {
|
||||
const value = this.getObjectToTestReference();
|
||||
const result = comparisonObject.test(value);
|
||||
if (!result) {
|
||||
throw new Error(
|
||||
this.createErrorMessage(`Expected value{path} to match regex ${comparisonObject}`)
|
||||
);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
public toBeTrue() {
|
||||
return this.runCheck(() => {
|
||||
const value = this.getObjectToTestReference();
|
||||
const result = typeof value === 'boolean' && value === true;
|
||||
if (!result) {
|
||||
throw new Error(
|
||||
this.createErrorMessage(`Expected value{path} to be true, but got ${this.formatValue(value)}`)
|
||||
);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
public toBeFalse() {
|
||||
return this.runCheck(() => {
|
||||
const value = this.getObjectToTestReference();
|
||||
const result = typeof value === 'boolean' && value === false;
|
||||
if (!result) {
|
||||
throw new Error(
|
||||
this.createErrorMessage(`Expected value{path} to be false, but got ${this.formatValue(value)}`)
|
||||
);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
public toBeInstanceOf(classArg: any) {
|
||||
return this.runCheck(() => {
|
||||
const value = this.getObjectToTestReference();
|
||||
const result = value instanceof classArg;
|
||||
if (!result) {
|
||||
throw new Error(
|
||||
this.createErrorMessage(`Expected value{path} to be an instance of ${classArg.name || 'provided class'}`)
|
||||
);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
public toHaveProperty(propertyArg: string, equalsArg?: any) {
|
||||
return this.runCheck(() => {
|
||||
const obj = this.getObjectToTestReference();
|
||||
if (!obj || !(propertyArg in obj)) {
|
||||
throw new Error(
|
||||
this.createErrorMessage(`Expected value{path} to have property '${propertyArg}'`)
|
||||
);
|
||||
}
|
||||
if (equalsArg !== undefined) {
|
||||
if (obj[propertyArg] !== equalsArg) {
|
||||
throw new Error(
|
||||
this.createErrorMessage(
|
||||
`Expected property '${propertyArg}' of value{path} to equal ${this.formatValue(equalsArg)}, but got ${this.formatValue(obj[propertyArg])}`
|
||||
)
|
||||
);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
public toHaveDeepProperty(properties: string[]) {
|
||||
return this.runCheck(() => {
|
||||
let obj = this.getObjectToTestReference();
|
||||
let currentPath = '';
|
||||
|
||||
for (const property of properties) {
|
||||
if (currentPath) {
|
||||
currentPath += `.${property}`;
|
||||
} else {
|
||||
currentPath = property;
|
||||
}
|
||||
|
||||
if (!obj || !(property in obj)) {
|
||||
throw new Error(
|
||||
this.createErrorMessage(`Expected value{path} to have property at path '${currentPath}'`)
|
||||
);
|
||||
}
|
||||
obj = obj[property];
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
public toBeGreaterThan(numberArg: number) {
|
||||
return this.runCheck(() => {
|
||||
const value = this.getObjectToTestReference();
|
||||
const result = value > numberArg;
|
||||
if (!result) {
|
||||
throw new Error(
|
||||
this.createErrorMessage(`Expected value{path} to be greater than ${numberArg}, but got ${this.formatValue(value)}`)
|
||||
);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
public toBeLessThan(numberArg: number) {
|
||||
return this.runCheck(() => {
|
||||
const value = this.getObjectToTestReference();
|
||||
const result = value < numberArg;
|
||||
if (!result) {
|
||||
throw new Error(
|
||||
this.createErrorMessage(`Expected value{path} to be less than ${numberArg}, but got ${this.formatValue(value)}`)
|
||||
);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
public toBeNull() {
|
||||
return this.runCheck(() => {
|
||||
const value = this.getObjectToTestReference();
|
||||
const result = value === null;
|
||||
if (!result) {
|
||||
throw new Error(
|
||||
this.createErrorMessage(`Expected value{path} to be null, but got ${this.formatValue(value)}`)
|
||||
);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
public toBeUndefined() {
|
||||
return this.runCheck(() => {
|
||||
const value = this.getObjectToTestReference();
|
||||
const result = value === undefined;
|
||||
if (!result) {
|
||||
throw new Error(
|
||||
this.createErrorMessage(`Expected value{path} to be undefined, but got ${this.formatValue(value)}`)
|
||||
);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
public toBeNullOrUndefined() {
|
||||
return this.runCheck(() => {
|
||||
const value = this.getObjectToTestReference();
|
||||
const result = value === null || value === undefined;
|
||||
if (!result) {
|
||||
throw new Error(
|
||||
this.createErrorMessage(`Expected value{path} to be null or undefined, but got ${this.formatValue(value)}`)
|
||||
);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Array checks
|
||||
|
||||
public toContain(itemArg: any) {
|
||||
return this.runCheck(() => {
|
||||
const value = this.getObjectToTestReference();
|
||||
const result = Array.isArray(value) && value.includes(itemArg);
|
||||
if (!result) {
|
||||
throw new Error(
|
||||
this.createErrorMessage(`Expected array{path} to contain ${this.formatValue(itemArg)}`)
|
||||
);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
public toBeEmptyArray() {
|
||||
return this.runCheck(() => {
|
||||
const value = this.getObjectToTestReference();
|
||||
if (!Array.isArray(value)) {
|
||||
throw new Error(
|
||||
this.createErrorMessage(`Expected value{path} to be an array, but got ${typeof value}`)
|
||||
);
|
||||
}
|
||||
if (value.length !== 0) {
|
||||
throw new Error(
|
||||
this.createErrorMessage(`Expected array{path} to be empty, but it has ${value.length} elements`)
|
||||
);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
public toContainAll(values: any[]) {
|
||||
return this.runCheck(() => {
|
||||
const arr = this.getObjectToTestReference();
|
||||
if (!Array.isArray(arr)) {
|
||||
throw new Error(
|
||||
this.createErrorMessage(`Expected value{path} to be an array, but got ${typeof arr}`)
|
||||
);
|
||||
}
|
||||
|
||||
const missing = values.filter(v => !arr.includes(v));
|
||||
if (missing.length > 0) {
|
||||
throw new Error(
|
||||
this.createErrorMessage(`Expected array{path} to contain all values ${this.formatValue(values)}, but missing: ${this.formatValue(missing)}`)
|
||||
);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
public toExclude(value: any) {
|
||||
return this.runCheck(() => {
|
||||
const arr = this.getObjectToTestReference();
|
||||
if (!Array.isArray(arr)) {
|
||||
throw new Error(
|
||||
this.createErrorMessage(`Expected value{path} to be an array, but got ${typeof arr}`)
|
||||
);
|
||||
}
|
||||
if (arr.includes(value)) {
|
||||
throw new Error(
|
||||
this.createErrorMessage(`Expected array{path} to exclude ${this.formatValue(value)}, but it was found`)
|
||||
);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
public toStartWith(itemArg: any) {
|
||||
return this.runCheck(() => {
|
||||
const value = this.getObjectToTestReference();
|
||||
const result = typeof value === 'string' && value.startsWith(itemArg);
|
||||
if (!result) {
|
||||
throw new Error(
|
||||
this.createErrorMessage(`Expected string{path} to start with "${itemArg}", but got "${value}"`)
|
||||
);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
public toEndWith(itemArg: any) {
|
||||
return this.runCheck(() => {
|
||||
const value = this.getObjectToTestReference();
|
||||
const result = typeof value === 'string' && value.endsWith(itemArg);
|
||||
if (!result) {
|
||||
throw new Error(
|
||||
this.createErrorMessage(`Expected string{path} to end with "${itemArg}", but got "${value}"`)
|
||||
);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
public toBeOneOf(values: any[]) {
|
||||
return this.runCheck(() => {
|
||||
const value = this.getObjectToTestReference();
|
||||
const result = values.includes(value);
|
||||
if (!result) {
|
||||
throw new Error(
|
||||
this.createErrorMessage(`Expected value{path} to be one of ${this.formatValue(values)}, but got ${this.formatValue(value)}`)
|
||||
);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
public toHaveLength(length: number) {
|
||||
return this.runCheck(() => {
|
||||
const obj = this.getObjectToTestReference();
|
||||
if (typeof obj.length !== 'number') {
|
||||
throw new Error(
|
||||
this.createErrorMessage(`Expected value{path} to have a length property, but it doesn't`)
|
||||
);
|
||||
}
|
||||
if (obj.length !== length) {
|
||||
throw new Error(
|
||||
this.createErrorMessage(`Expected value{path} to have length ${length}, but got length ${obj.length}`)
|
||||
);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
public toBeCloseTo(value: number, precision = 2) {
|
||||
return this.runCheck(() => {
|
||||
const actual = this.getObjectToTestReference();
|
||||
const difference = Math.abs(actual - value);
|
||||
const epsilon = Math.pow(10, -precision) / 2;
|
||||
if (difference > epsilon) {
|
||||
throw new Error(
|
||||
this.createErrorMessage(`Expected value{path} to be close to ${value} (within ${epsilon}), but the difference was ${difference}`)
|
||||
);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
public toThrow(expectedError?: any) {
|
||||
return this.runCheck(() => {
|
||||
const fn = this.getObjectToTestReference();
|
||||
if (typeof fn !== 'function') {
|
||||
throw new Error(
|
||||
this.createErrorMessage(`Expected value{path} to be a function, but got ${typeof fn}`)
|
||||
);
|
||||
}
|
||||
|
||||
let thrown = false;
|
||||
let error: any;
|
||||
|
||||
if (this.isRejects) {
|
||||
(this.baseReference as Promise<any>).then(
|
||||
(res: any) => {
|
||||
done.reject(new Error(`Expected Promise to reject but it resolved with ${this.formatValue(res)}`));
|
||||
},
|
||||
(err: any) => {
|
||||
this.baseReference = err;
|
||||
try {
|
||||
fn();
|
||||
} catch (e) {
|
||||
thrown = true;
|
||||
error = e;
|
||||
if (expectedError && !(e instanceof expectedError)) {
|
||||
throw new Error(
|
||||
this.createErrorMessage(`Expected function{path} to throw ${expectedError.name}, but it threw ${e.constructor.name}`)
|
||||
runDirectOrNegated(checkFunction);
|
||||
done.resolve(this);
|
||||
} catch (e: any) {
|
||||
done.reject(e);
|
||||
}
|
||||
}
|
||||
);
|
||||
} else {
|
||||
(this.baseReference as Promise<any>).then(
|
||||
(res: any) => {
|
||||
this.baseReference = res;
|
||||
try {
|
||||
runDirectOrNegated(checkFunction);
|
||||
done.resolve(this);
|
||||
} catch (e: any) {
|
||||
done.reject(e);
|
||||
}
|
||||
},
|
||||
(err: any) => {
|
||||
done.reject(err);
|
||||
}
|
||||
);
|
||||
}
|
||||
// return a promise resolving to this for chaining
|
||||
return done.promise.then(() => this) as any;
|
||||
}
|
||||
// sync: run and return this for chaining
|
||||
runDirectOrNegated(checkFunction);
|
||||
return this as any;
|
||||
}
|
||||
|
||||
if (!thrown) {
|
||||
throw new Error(
|
||||
this.createErrorMessage(`Expected function{path} to throw, but it didn't throw any error`)
|
||||
);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
public toBeTruthy() {
|
||||
return this.runCheck(() => {
|
||||
const value = this.getObjectToTestReference();
|
||||
if (!value) {
|
||||
throw new Error(
|
||||
this.createErrorMessage(`Expected value{path} to be truthy, but got ${this.formatValue(value)}`)
|
||||
);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
public toBeFalsy() {
|
||||
return this.runCheck(() => {
|
||||
const value = this.getObjectToTestReference();
|
||||
if (value) {
|
||||
throw new Error(
|
||||
this.createErrorMessage(`Expected value{path} to be falsy, but got ${this.formatValue(value)}`)
|
||||
);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
public toBeGreaterThanOrEqual(numberArg: number) {
|
||||
return this.runCheck(() => {
|
||||
const value = this.getObjectToTestReference();
|
||||
if (value < numberArg) {
|
||||
throw new Error(
|
||||
this.createErrorMessage(`Expected value{path} to be greater than or equal to ${numberArg}, but got ${value}`)
|
||||
);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
public toBeLessThanOrEqual(numberArg: number) {
|
||||
return this.runCheck(() => {
|
||||
const value = this.getObjectToTestReference();
|
||||
if (value > numberArg) {
|
||||
throw new Error(
|
||||
this.createErrorMessage(`Expected value{path} to be less than or equal to ${numberArg}, but got ${value}`)
|
||||
);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
public toMatchObject(objectArg: object) {
|
||||
return this.runCheck(() => {
|
||||
const value = this.getObjectToTestReference();
|
||||
const matchResult = plugins.fastDeepEqual(value, objectArg);
|
||||
if (!matchResult) {
|
||||
throw new Error(
|
||||
this.createErrorMessage(`Expected value{path} to match ${this.formatValue(objectArg)}`)
|
||||
);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
public toContainEqual(value: any) {
|
||||
return this.runCheck(() => {
|
||||
const arr = this.getObjectToTestReference();
|
||||
if (!Array.isArray(arr)) {
|
||||
throw new Error(
|
||||
this.createErrorMessage(`Expected value{path} to be an array, but got ${typeof arr}`)
|
||||
);
|
||||
}
|
||||
|
||||
const found = arr.some((item: any) => plugins.fastDeepEqual(item, value));
|
||||
if (!found) {
|
||||
throw new Error(
|
||||
this.createErrorMessage(`Expected array{path} to contain an item equal to ${this.formatValue(value)}`)
|
||||
);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
public toBeArray() {
|
||||
return this.runCheck(() => {
|
||||
const value = this.getObjectToTestReference();
|
||||
if (!Array.isArray(value)) {
|
||||
throw new Error(
|
||||
this.createErrorMessage(`Expected value{path} to be an array, but got ${typeof value}`)
|
||||
);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
public toInclude(substring: string) {
|
||||
return this.runCheck(() => {
|
||||
const value = this.getObjectToTestReference();
|
||||
if (typeof value !== 'string') {
|
||||
throw new Error(
|
||||
this.createErrorMessage(`Expected value{path} to be a string, but got ${typeof value}`)
|
||||
);
|
||||
}
|
||||
if (!value.includes(substring)) {
|
||||
throw new Error(
|
||||
this.createErrorMessage(`Expected string{path} to include "${substring}", but it doesn't`)
|
||||
);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
public toHaveLengthGreaterThan(length: number) {
|
||||
return this.runCheck(() => {
|
||||
const obj = this.getObjectToTestReference();
|
||||
if (typeof obj.length !== 'number') {
|
||||
throw new Error(
|
||||
this.createErrorMessage(`Expected value{path} to have a length property, but it doesn't`)
|
||||
);
|
||||
}
|
||||
if (obj.length <= length) {
|
||||
throw new Error(
|
||||
this.createErrorMessage(`Expected value{path} to have length greater than ${length}, but got length ${obj.length}`)
|
||||
);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
public toHaveLengthLessThan(length: number) {
|
||||
return this.runCheck(() => {
|
||||
const obj = this.getObjectToTestReference();
|
||||
if (typeof obj.length !== 'number') {
|
||||
throw new Error(
|
||||
this.createErrorMessage(`Expected value{path} to have a length property, but it doesn't`)
|
||||
);
|
||||
}
|
||||
if (obj.length >= length) {
|
||||
throw new Error(
|
||||
this.createErrorMessage(`Expected value{path} to have length less than ${length}, but got length ${obj.length}`)
|
||||
);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
public toBeDate() {
|
||||
return this.runCheck(() => {
|
||||
const value = this.getObjectToTestReference();
|
||||
if (!(value instanceof Date)) {
|
||||
throw new Error(
|
||||
this.createErrorMessage(`Expected value{path} to be a Date, but got ${value.constructor ? value.constructor.name : typeof value}`)
|
||||
);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
public toBeBeforeDate(date: Date) {
|
||||
return this.runCheck(() => {
|
||||
const value = this.getObjectToTestReference();
|
||||
if (!(value instanceof Date)) {
|
||||
throw new Error(
|
||||
this.createErrorMessage(`Expected value{path} to be a Date, but got ${value.constructor ? value.constructor.name : typeof value}`)
|
||||
);
|
||||
}
|
||||
if (value >= date) {
|
||||
throw new Error(
|
||||
this.createErrorMessage(`Expected date{path} to be before ${date.toISOString()}, but got ${value.toISOString()}`)
|
||||
);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
public toBeAfterDate(date: Date) {
|
||||
return this.runCheck(() => {
|
||||
const value = this.getObjectToTestReference();
|
||||
if (!(value instanceof Date)) {
|
||||
throw new Error(
|
||||
this.createErrorMessage(`Expected value{path} to be a Date, but got ${value.constructor ? value.constructor.name : typeof value}`)
|
||||
);
|
||||
}
|
||||
if (value <= date) {
|
||||
throw new Error(
|
||||
this.createErrorMessage(`Expected date{path} to be after ${date.toISOString()}, but got ${value.toISOString()}`)
|
||||
);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 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;
|
||||
}
|
||||
|
||||
/**
|
||||
* Drill into a property
|
||||
* Drill into a property of an object.
|
||||
* @param propertyName Name of the property to navigate into.
|
||||
* @returns Assertion of the property type.
|
||||
*/
|
||||
public property(propertyNameArg: string) {
|
||||
this.propertyDrillDown.push(propertyNameArg);
|
||||
return this;
|
||||
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], M>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Drill into an array index
|
||||
* Drill into an array element by index.
|
||||
* @param index Index of the array item.
|
||||
* @returns Assertion of the element type.
|
||||
*/
|
||||
public arrayItem(indexArg: number) {
|
||||
// Save the number (instead of "[index]")
|
||||
this.propertyDrillDown.push(indexArg);
|
||||
return this;
|
||||
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, M>;
|
||||
}
|
||||
|
||||
public log() {
|
||||
@ -733,4 +327,81 @@ export class Assertion {
|
||||
console.log(`Path: ${this.formatDrillDown() || '(root)'}`);
|
||||
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
|
||||
/** String-specific matchers */
|
||||
public get string(): StringMatchers<M> {
|
||||
return new StringMatchers<M>(this as Assertion<string, M>);
|
||||
}
|
||||
/** Array-specific matchers */
|
||||
public get array(): ArrayMatchers<any, M> {
|
||||
return new ArrayMatchers<any, M>(this as Assertion<any[], M>);
|
||||
}
|
||||
/** Number-specific matchers */
|
||||
public get number(): NumberMatchers<M> {
|
||||
return new NumberMatchers<M>(this as Assertion<number, M>);
|
||||
}
|
||||
/** Boolean-specific matchers */
|
||||
public get boolean(): BooleanMatchers<M> {
|
||||
return new BooleanMatchers<M>(this as Assertion<boolean, M>);
|
||||
}
|
||||
/** Object-specific matchers */
|
||||
public get object(): ObjectMatchers<any, M> {
|
||||
return new ObjectMatchers<any, M>(this as Assertion<object, M>);
|
||||
}
|
||||
/** Function-specific matchers */
|
||||
public get function(): FunctionMatchers<M> {
|
||||
return new FunctionMatchers<M>(this as Assertion<Function, M>);
|
||||
}
|
||||
/** Date-specific matchers */
|
||||
public get date(): DateMatchers<M> {
|
||||
return new DateMatchers<M>(this as Assertion<Date, M>);
|
||||
}
|
||||
/** Type-based matchers */
|
||||
public get type(): TypeMatchers<M> {
|
||||
return new TypeMatchers<M>(this as Assertion<any, M>);
|
||||
}
|
||||
}
|
13
ts/types.ts
Normal file
13
ts/types.ts
Normal file
@ -0,0 +1,13 @@
|
||||
/**
|
||||
* Common types for smartexpect
|
||||
*/
|
||||
/** Execution mode: sync or async */
|
||||
export type TExecutionType = 'sync' | 'async';
|
||||
/**
|
||||
* Definition of a custom matcher function.
|
||||
* Should return an object with `pass` and optional `message`.
|
||||
*/
|
||||
export type TMatcher = (
|
||||
received: any,
|
||||
...args: any[]
|
||||
) => { pass: boolean; message?: string | (() => string) };
|
Loading…
x
Reference in New Issue
Block a user