Skip to content

Commit e05e2e4

Browse files
committed
feat: add .toBeJsonMatching(expectation) matcher
1 parent 877341d commit e05e2e4

File tree

9 files changed

+507
-8
lines changed

9 files changed

+507
-8
lines changed

README.md

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -111,6 +111,7 @@ If you've come here to help contribute - Thanks! Take a look at the [contributin
111111
- [.toBeString()](#tobestring)
112112
- [.toBeHexadecimal(string)](#tobehexadecimal)
113113
- [.toBeDateString(string)](#tobedatestringstring)
114+
- [.toBeJsonMatching(expectation)](#tobejsonmatchingexpectation)
114115
- [.toEqualCaseInsensitive(string)](#toequalcaseinsensitivestring)
115116
- [.toStartWith(prefix)](#tostartwithprefix)
116117
- [.toEndWith(suffix)](#toendwithsuffix)
@@ -1029,6 +1030,28 @@ test('passes when value is a valid toBeDateString', () => {
10291030
});
10301031
```
10311032
1033+
### .toBeJsonMatching(expectation)
1034+
1035+
Use `.toBeJsonMatching` to check that a string is the JSON representation of a JavaScript object that matches a subset of the properties of the expectation. It will match received JSON representations of objects with properties that are **not** in the expectation.
1036+
1037+
```js
1038+
test('passes when given JSON string matches expectation', () => {
1039+
const data = JSON.stringify({ a: 42, b: 'foo', c: { d: 'hello', e: 'world' }, f: [7, { g: 'bar', h: 'baz' }] });
1040+
1041+
expect(data).toBeJsonMatching({});
1042+
expect(data).toBeJsonMatching({ a: 42 });
1043+
expect(data).toBeJsonMatching({ a: 42, b: 'foo' });
1044+
expect(data).toBeJsonMatching({ a: 42, b: 'foo', c: {} });
1045+
expect(data).toBeJsonMatching({ a: 42, b: 'foo', c: { d: 'hello' } });
1046+
expect(data).toBeJsonMatching({ a: 42, b: 'foo', c: { d: 'hello', e: 'world' } });
1047+
expect(data).toBeJsonMatching({ a: 42, b: 'foo', c: { d: 'hello', e: 'world' }, f: [] });
1048+
expect(data).toBeJsonMatching({ a: 42, b: 'foo', c: { d: 'hello', e: 'world' }, f: [7] });
1049+
expect(data).toBeJsonMatching({ a: 42, b: 'foo', c: { d: 'hello', e: 'world' }, f: [7, {}] });
1050+
expect(data).toBeJsonMatching({ a: 42, b: 'foo', c: { d: 'hello', e: 'world' }, f: [7, { g: 'bar' }] });
1051+
expect(data).toBeJsonMatching({ a: 42, b: 'foo', c: { d: 'hello', e: 'world' }, f: [7, { g: 'bar', h: 'baz' }] });
1052+
});
1053+
```
1054+
10321055
#### .toEqualCaseInsensitive(string)
10331056
10341057
Use `.toEqualCaseInsensitive` when checking if a string is equal (===) to another ignoring the casing of both strings.

src/matchers/index.js

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ export { toBeFrozen } from './toBeFrozen';
2020
export { toBeFunction } from './toBeFunction';
2121
export { toBeHexadecimal } from './toBeHexadecimal';
2222
export { toBeInteger } from './toBeInteger';
23+
export { toBeJsonMatching } from './toBeJsonMatching';
2324
export { toBeNaN } from './toBeNaN';
2425
export { toBeNegative } from './toBeNegative';
2526
export { toBeNil } from './toBeNil';

src/matchers/toBeJsonMatching.js

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
import { matchesObject, tryParseJSON } from '../utils';
2+
3+
export function toBeJsonMatching(actual, expected) {
4+
const { printExpected, printReceived, matcherHint } = this.utils;
5+
6+
const passMessage =
7+
matcherHint('.not.toBeJsonMatching') +
8+
'\n\n' +
9+
'Expected input to not be a JSON string containing:\n' +
10+
` ${printExpected(expected)}\n` +
11+
'Received:\n' +
12+
` ${printReceived(typeof tryParseJSON(actual) !== 'undefined' ? tryParseJSON(actual) : actual)}`;
13+
14+
const failMessage =
15+
matcherHint('.toBeJsonMatching') +
16+
'\n\n' +
17+
'Expected input to be a JSON string containing:\n' +
18+
` ${printExpected(expected)}\n` +
19+
'Received:\n' +
20+
` ${printReceived(typeof tryParseJSON(actual) !== 'undefined' ? tryParseJSON(actual) : actual)}`;
21+
22+
const pass =
23+
typeof actual === 'string' &&
24+
typeof tryParseJSON(actual) !== 'undefined' &&
25+
matchesObject(this.equals, tryParseJSON(actual), expected);
26+
27+
return { pass, message: () => (pass ? passMessage : failMessage) };
28+
}

src/matchers/toPartiallyContain.js

Lines changed: 2 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { containsEntry } from '../utils';
1+
import { partiallyContains } from '../utils';
22

33
export function toPartiallyContain(actual, expected) {
44
const { printReceived, printExpected, matcherHint } = this.utils;
@@ -19,12 +19,7 @@ export function toPartiallyContain(actual, expected) {
1919
'Received:\n' +
2020
` ${printReceived(actual)}`;
2121

22-
const pass =
23-
Array.isArray(actual) &&
24-
Array.isArray([expected]) &&
25-
[expected].every(partial =>
26-
actual.some(value => Object.entries(partial).every(entry => containsEntry(this.equals, value, entry))),
27-
);
22+
const pass = partiallyContains(this.equals, actual, [expected]);
2823

2924
return { pass, message: () => (pass ? passMessage : failMessage) };
3025
}

src/utils/index.js

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,3 +12,50 @@ export const isJestMockOrSpy = value => {
1212

1313
export const containsEntry = (equals, obj, [key, value]) =>
1414
obj.hasOwnProperty && Object.prototype.hasOwnProperty.call(obj, key) && equals(obj[key], value);
15+
16+
export const partiallyContains = (equals, actual, expected) =>
17+
Array.isArray(actual) &&
18+
Array.isArray(expected) &&
19+
expected.every(partial =>
20+
actual.some(value => {
21+
if (typeof partial !== 'object' || partial === null) {
22+
return equals(value, partial);
23+
}
24+
if (Array.isArray(partial)) {
25+
return partiallyContains(equals, value, partial);
26+
}
27+
return Object.entries(partial).every(entry => containsEntry(equals, value, entry));
28+
}),
29+
);
30+
31+
export const matchesObject = (equals, actual, expected) => {
32+
if (equals(actual, expected)) {
33+
return true;
34+
}
35+
if (Array.isArray(actual) || Array.isArray(expected)) {
36+
return partiallyContains(equals, actual, expected);
37+
}
38+
if (typeof actual === 'object' && typeof expected === 'object' && expected !== null) {
39+
return Object.getOwnPropertyNames(expected).every(name => {
40+
if (equals(actual[name], expected[name])) {
41+
return true;
42+
}
43+
if (Array.isArray(actual[name]) || Array.isArray(expected[name])) {
44+
return partiallyContains(equals, actual[name], expected[name]);
45+
}
46+
if (typeof actual[name] === 'object' && typeof expected[name] === 'object' && expected[name] !== null) {
47+
return matchesObject(equals, actual[name], expected[name]);
48+
}
49+
return false;
50+
});
51+
}
52+
return false;
53+
};
54+
55+
export const tryParseJSON = input => {
56+
try {
57+
return JSON.parse(input);
58+
} catch {
59+
return undefined;
60+
}
61+
};

0 commit comments

Comments
 (0)