Skip to content

Commit

Permalink
feat: Add strict option to deepEquals matcher
Browse files Browse the repository at this point in the history
Closes #252, #257.

Co-authored-by: Paris Holley <mail@parisholley.com>
  • Loading branch information
NiGhTTraX and parisholley committed Sep 26, 2021
1 parent 979f5e0 commit c98ea56
Show file tree
Hide file tree
Showing 3 changed files with 204 additions and 4 deletions.
31 changes: 31 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,7 @@ console.log(instance(foo).bar(23)); // 'I am strong!'
- [How do I provide a function for the mock to call?](#how-do-i-provide-a-function-for-the-mock-to-call)
- [Why does accessing an unused method throw?](#why-does-accessing-an-unused-method-throw)
- [Can I spread/enumerate a mock instance?](#can-i-spreadenumerate-a-mock-instance)
- [How can I ignore `undefined` keys when setting expectations on objects?](#how-can-i-ignore-undefined-keys-when-setting-expectations-on-objects)

<!-- END doctoc generated TOC please keep comment here to allow auto update -->

Expand Down Expand Up @@ -274,6 +275,15 @@ Available matchers:
- `matches` - build your own matcher,
- `willCapture` - matches anything and stores the received value.

The following table illustrates the differences between the equality matchers:

expected | actual | `It.is` | `It.deepEquals` | `It.deepEquals({ strict: false })`
-------|----------|---------|-----------------|-----------------------------------
`"foo"` | `"bar"` | equal | equal | equal
`{ foo: "bar" }` | `{ foo: "bar" }` | not equal | equal | equal
`{ }` | `{ foo: undefined }` | not equal | not equal | equal
`new (class {})()` | `new (class {})()` | not equal | not equal | equal

Some matchers, like `isObject` and `isArray` support nesting matchers:

```typescript
Expand Down Expand Up @@ -430,3 +440,24 @@ const foo2 = { ...instance(foo) };
console.log(foo2.bar); // 42
console.log(foo2.baz); // undefined
```


### How can I ignore `undefined` keys when setting expectations on objects?

Use the `It.deepEquals` matcher explicitly inside `when` and pass `{ strict: false }`:

```ts
const fn = mock<(x: { foo: string }) => boolean>();

when(fn(It.deepEquals({ foo: "bar" }, { strict: false }))).thenReturn(true);

instance(fn)({ foo: "bar", baz: undefined }) === true
```

You can also set this behavior to be the default by using [`setDefaults`](#overriding-default-matcher):

```ts
setDefaults({
matcher: (expected) => It.deepEquals(expected, { strict: false })
});
```
137 changes: 137 additions & 0 deletions src/expectation/matcher.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,21 @@ describe('It', () => {
expect(It.deepEquals({}).matches({ foo: 'bar' })).toBeFalsy();
});

it('should match arrays with objects', () => {
expect(
It.deepEquals([{ foo: 1 }, { foo: 2 }]).matches([
{ foo: 1 },
{ foo: 2 },
])
).toBeTruthy();
expect(
It.deepEquals([{ foo: 1 }, { foo: 2 }]).matches([
{ foo: 1 },
{ foo: 3 },
])
).toBeFalsy();
});

it('should match nested objects', () => {
expect(
It.deepEquals({ foo: { bar: 'baz' } }).matches({ foo: { bar: 'baz' } })
Expand Down Expand Up @@ -121,6 +136,128 @@ describe('It', () => {
).toBeFalsy();
});

it('should not match arrays with missing indices', () => {
expect(It.deepEquals([1, 2, 3]).matches([1, undefined, 3])).toBeFalsy();
expect(It.deepEquals([1, undefined, 3]).matches([1, 2, 3])).toBeFalsy();
});

it('should not match sparse arrays with missing indices', () => {
const a = [1, 2, 3];
const b = [1];
b[2] = 3;

expect(It.deepEquals(a).matches(b)).toBeFalsy();
expect(It.deepEquals(b).matches(a)).toBeFalsy();
});

describe('non-strict', () => {
const options = { strict: false };

it('should match primitives', () => {
expect(It.deepEquals(1, options).matches(1)).toBeTruthy();
expect(It.deepEquals(1, options).matches(2)).toBeFalsy();

expect(It.deepEquals(1.0, options).matches(1.0)).toBeTruthy();
expect(It.deepEquals(1.0, options).matches(1.1)).toBeFalsy();

expect(It.deepEquals(true, options).matches(true)).toBeTruthy();
expect(It.deepEquals(true, options).matches(false)).toBeFalsy();

expect(It.deepEquals('a', options).matches('a')).toBeTruthy();
expect(It.deepEquals('a', options).matches('b')).toBeFalsy();
});

it('should match arrays', () => {
expect(
It.deepEquals([1, 2, 3], options).matches([1, 2, 3])
).toBeTruthy();
expect(
It.deepEquals([1, 2, 3], options).matches([1, 2, 4])
).toBeFalsy();
expect(It.deepEquals([1, 2, 3], options).matches([2, 3])).toBeFalsy();
});

it('should match objects', () => {
expect(
It.deepEquals({ foo: 'bar' }, options).matches({ foo: 'bar' })
).toBeTruthy();
expect(
It.deepEquals({ foo: 'bar' }, options).matches({ foo: 'baz' })
).toBeFalsy();
expect(It.deepEquals({ foo: 'bar' }, options).matches({})).toBeFalsy();
expect(It.deepEquals({}, options).matches({ foo: 'bar' })).toBeFalsy();
});

it('should match arrays with objects', () => {
expect(
It.deepEquals([{ foo: 1 }, { foo: 2 }], options).matches([
{ foo: 1 },
{ foo: 2 },
])
).toBeTruthy();
expect(
It.deepEquals([{ foo: 1 }, { foo: 2 }], options).matches([
{ foo: 1 },
{ foo: 3 },
])
).toBeFalsy();
});

it('should match objects with missing optional keys', () => {
expect(
It.deepEquals({}, options).matches({ key: undefined })
).toBeTruthy();
expect(
It.deepEquals({ key: undefined }, options).matches({})
).toBeTruthy();
});

it('should match instances of the same class', () => {
class Foo {
bar = 42;
}

expect(
It.deepEquals(new Foo(), options).matches(new Foo())
).toBeTruthy();
});

it('should match objects with different prototypes', () => {
class Foo {
bar = 42;
}

class Bar {
bar = 42;
}

expect(
It.deepEquals(new Foo(), options).matches(new Bar())
).toBeTruthy();
expect(
It.deepEquals(new Foo(), options).matches({ bar: 42 })
).toBeTruthy();
});

it('should not match arrays with missing indices', () => {
expect(
It.deepEquals([1, 2, 3], options).matches([1, undefined, 3])
).toBeFalsy();
expect(
It.deepEquals([1, undefined, 3], options).matches([1, 2, 3])
).toBeFalsy();
});

it('should not match sparse arrays with missing indices', () => {
const a = [1, 2, 3];
const b = [1];
b[2] = 3;

expect(It.deepEquals(a, options).matches(b)).toBeFalsy();
expect(It.deepEquals(b, options).matches(a)).toBeFalsy();
});
});

it('should pretty print', () => {
expectAnsilessEqual(It.deepEquals(23).toJSON(), '23');
expectAnsilessEqual(
Expand Down
40 changes: 36 additions & 4 deletions src/expectation/matcher.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,11 @@
import { printExpected } from 'jest-matcher-utils';
import isEqual from 'lodash/isEqual';
import isMatchWith from 'lodash/isMatchWith';
import {
isEqual,
isMatchWith,
isObjectLike,
isUndefined,
omitBy,
} from 'lodash';
import { printArg } from '../print';

export type Matcher = {
Expand Down Expand Up @@ -61,14 +66,41 @@ const matches = <T>(
return matcher as any;
};

const removeUndefined = (object: any): any => {
if (Array.isArray(object)) {
return object.map((x) => removeUndefined(x));
}

if (!isObjectLike(object)) {
return object;
}

return omitBy(object, isUndefined);
};

/**
* Compare values using deep equality.
*
* @param expected
* @param strict By default, this matcher will treat a missing key in an object
* and a key with the value `undefined` as not equal. It will also consider
* non `Object` instances with different constructors as not equal. Setting
* this to `false` will consider the objects in both cases as equal.
*
* @see It.is A matcher that uses strict equality.
*/
const deepEquals = <T>(expected: T): TypeMatcher<T> =>
const deepEquals = <T>(
expected: T,
{ strict = true }: { strict?: boolean } = {}
): TypeMatcher<T> =>
matches(
(actual) => isEqual(actual, expected),
(actual) => {
if (strict) {
return isEqual(actual, expected);
}

return isEqual(removeUndefined(actual), removeUndefined(expected));
},
() => printArg(expected)
);

Expand Down

0 comments on commit c98ea56

Please sign in to comment.