Skip to content

Commit

Permalink
feat(matcher): Add .toHaveBodyMatchObject matcher (#33)
Browse files Browse the repository at this point in the history
Use `.toHaveBodyMatchObject` when checking if the response body matches to the expected body

The implementation of the matcher is similar to the implementation of [expect's toMatchObject](https://jestjs.io/docs/en/expect#tomatchobjectobject)

except only valid JSON values or asymmetric matchers are supported in the expected body.

`undefined` values in the expected body mean that the response body should not contain the key at all (not even with a null value)
  • Loading branch information
rluvaton authored Aug 25, 2024
1 parent a63b32d commit 36763ab
Show file tree
Hide file tree
Showing 18 changed files with 3,581 additions and 30 deletions.
30 changes: 28 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -83,6 +83,7 @@ Additional expect matchers for http clients (e.g. Axios), supports `jest`, `vite
- [.toHaveNetworkAuthenticationRequiredStatus()](#tohavenetworkauthenticationrequiredstatus)
- [.toHaveHeader(`<header name>`[, `<header value>`])](#tohaveheaderheader-name-header-value)
- [.toHaveBodyEquals(`<body>`)](#tohavebodyequalsbody)
- [.toHaveBodyMatchObject(`<body>`)](#tohavebodymatchobjectbody)


## Installation
Expand Down Expand Up @@ -1359,8 +1360,8 @@ Use `.toHaveBodyEquals` when checking if response body is equal to the expected
```js
test('passes when response body match the expected body', async () => {
const response = await axios.get('https://httpstat.us/200');
expect(response).toHaveBodyEquals('200 OK');
const response = await axios.get('https://httpstat.us/200');
expect(response).toHaveBodyEquals('200 OK');
});

test('passes when using .not.toHaveBodyEquals() with different body', async () => {
Expand All @@ -1369,6 +1370,31 @@ test('passes when using .not.toHaveBodyEquals() with different body', async () =
});
```
#### .toHaveBodyMatchObject(`<body>`)
Use `.toHaveBodyMatchObject` when checking if response body match to the expected body
The implementation of the matcher is similar to the implementation of [expect's toMatchObject](https://jestjs.io/docs/en/expect#tomatchobjectobject)
except only valid JSON values or asymmetric matchers are supported in the expected body.
`undefined` values in the expected body means that the response body should not contain the key at all (not even with null value)
```js
test('passes when response body match the expected body', async () => {
const response = await axios.get('https://some-api.com');
expect(response).toHaveBodyMatchObject({
name: 'John Doe',
});
});

test('passes when using .not.toHaveBodyMatchObject() with different body', async () => {
const response = await axios.get('https://some-api.com');
expect(response).not.toHaveBodyMatchObject({
name: 'hello',
});
});
```
## LICENSE
Expand Down
2 changes: 2 additions & 0 deletions src/matchers/data/index.js
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
const { toHaveBodyEquals } = require('./toHaveBodyEquals');
const { toHaveBodyMatchObject } = require('./toHaveBodyMatchObject');

module.exports = {
toHaveBodyEquals,
toHaveBodyMatchObject,
};
31 changes: 3 additions & 28 deletions src/matchers/data/toHaveBodyEquals.js
Original file line number Diff line number Diff line change
@@ -1,31 +1,6 @@
const { getMatchingAdapter } = require('../../http-clients');
const { printDebugInfo } = require('../../utils/get-debug-info');

/**
*
* @param {HttpClientAdapter} adapter
*/
function getJSONBody(adapter) {
const body = adapter.getBody();

if (typeof body === 'string') {
try {
return JSON.parse(body);
} catch (e) {
return null;
}
}

if (Buffer.isBuffer(body)) {
try {
return JSON.parse(body.toString());
} catch (e) {
return null;
}
}

return typeof body === 'object' ? body : null;
}
const { getJSONBody } = require('../../utils/json-body');
const { getMatchingAdapter } = require('../../http-clients');

/**
* @this {import('expect').MatcherUtils}
Expand All @@ -38,7 +13,7 @@ function toHaveBodyEquals(actual, expectedValue) {

// Headers are case-insensitive
const contentTypeHeaderValue = Object.entries(headers).find(([name]) => name.toLowerCase() === 'content-type')?.[1];
const isJson = contentTypeHeaderValue.toLowerCase().includes('application/json');
const isJson = contentTypeHeaderValue?.toLowerCase().includes('application/json');

let body = adapter.getBody();

Expand Down
88 changes: 88 additions & 0 deletions src/matchers/data/toHaveBodyMatchObject.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
const { getMatchingAdapter } = require('../../http-clients');
const { printDebugInfo } = require('../../utils/get-debug-info');
const { getJSONBody } = require('../../utils/json-body');
const { jsonEquals } = require('../../utils/matchings/equals-json-body');
const { jsonSubsetEquality, getJsonObjectSubset } = require('../../utils/matchings/equality-testers');

/**
* @this {import('expect').MatcherUtils}
*/
function toHaveBodyMatchObject(actual, expectedValue) {
const { matcherHint, printExpected, printDiffOrStringify, printReceived } = this.utils;

if (typeof expectedValue !== 'object' || expectedValue === null) {
throw new Error('toHaveBodyMatchObject expects non-null object as the expected');
}

const adapter = getMatchingAdapter(actual);
const headers = adapter.getHeaders();

// Headers are case-insensitive
const contentTypeHeaderValue = Object.entries(headers).find(([name]) => name.toLowerCase() === 'content-type')?.[1];
const isJson = contentTypeHeaderValue?.toLowerCase().includes('application/json');

let body = adapter.getBody();

let pass = false;

if (isJson) {
body = getJSONBody(adapter);

// This implementation taken from the `expect`, the code for the `toMatchObject` matcher
// https://github.com/jestjs/jest/blob/bd1c6db7c15c23788ca3e09c919138e48dd3b28a/packages/expect/src/matchers.ts#L895C1-L951C5

// Does not add iterator equality check as it JSON only support array and not custom iterable
// Custom implementation of equals and subset equality is added
// as we need to not allow non-json values in the expected object
// and undefined keys in the expected object mean that the key should not be present in the response body
pass = jsonEquals(body, expectedValue, [...this.customTesters, jsonSubsetEquality]);
}

return {
pass,
message: () => {
// .not
if (pass) {
// If we pass the body must be json

return [
matcherHint('.not.toHaveBodyMatchObject', 'received', 'expected'),
'',
`Expected request to not have data:`,
printExpected(expectedValue),
...(this.utils.stringify(printExpected) !== this.utils.stringify(body)
? ['', `Received: ${printReceived(body)}`]
: []),
'',
printDebugInfo(adapter, { omitBody: true }),
].join('\n');
}

if (!isJson) {
return [
matcherHint('.toHaveBodyMatchObject', 'received', 'expected'),
'',
`Expected response to have json body`,
'',
printDebugInfo(adapter),
].join('\n');
}

return [
matcherHint('.toHaveBodyMatchObject', 'received', 'expected'),
'',
printDiffOrStringify(
expectedValue,
getJsonObjectSubset(body, expectedValue, this.customTesters),
'Expected value',
'Received value',
this.expand !== false,
),
'',
printDebugInfo(adapter, { omitBody: true }),
].join('\n');
},
};
}

module.exports = { toHaveBodyMatchObject };
27 changes: 27 additions & 0 deletions src/utils/json-body.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
/**
*
* @param {HttpClientAdapter} adapter
*/
function getJSONBody(adapter) {
const body = adapter.getBody();

if (typeof body === 'string') {
try {
return JSON.parse(body);
} catch {
return null;
}
}

if (Buffer.isBuffer(body)) {
try {
return JSON.parse(body.toString());
} catch {
return null;
}
}

return typeof body === 'object' ? body : null;
}

module.exports = { getJSONBody };
173 changes: 173 additions & 0 deletions src/utils/matchings/equality-testers.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,173 @@
// Taken from `expect-utils` package
// From https://github.com/jestjs/jest/blob/bd1c6db7c15c23788ca3e09c919138e48dd3b28a/packages/expect-utils/src/utils.ts

const { jsonEquals } = require('./equals-json-body');

function isObject(a) {
return a !== null && typeof a === 'object';
}

/**
* Retrieves an object's keys for evaluation by getObjectSubset. This evaluates
* the prototype chain for string keys but not for non-enumerable symbols.
* (Otherwise, it could find values such as a Set or Map's Symbol.toStringTag,
* with unexpected results.)
*
*
* Taken from @jest/expect-utils
* https://github.com/jestjs/jest/blob/bd1c6db7c15c23788ca3e09c919138e48dd3b28a/packages/expect-utils/src/utils.ts#L46-L57
*/
function getObjectKeys(object) {
return [
...Object.keys(object),
...Object.getOwnPropertySymbols(object).filter((s) => Object.getOwnPropertyDescriptor(object, s)?.enumerable),
];
}

/**
* Checks if `hasOwnProperty(object, key)` up the prototype chain, stopping at `Object.prototype`.
*
* Taken from @jest/expect-utils
* https://github.com/jestjs/jest/blob/bd1c6db7c15c23788ca3e09c919138e48dd3b28a/packages/expect-utils/src/utils.ts#L28C1-L45C1
*/
function hasPropertyInObject(object, key) {
const shouldTerminate = !object || typeof object !== 'object' || object === Object.prototype;

if (shouldTerminate) {
return false;
}

return Object.prototype.hasOwnProperty.call(object, key) || hasPropertyInObject(Object.getPrototypeOf(object), key);
}

/**
* Taken from @jest/expect-utils
* https://github.com/jestjs/jest/blob/bd1c6db7c15c23788ca3e09c919138e48dd3b28a/packages/expect-utils/src/utils.ts#L112C1-L165C3
*
* Strip properties from object that are not present in the subset. Useful for
* printing the diff for toMatchObject() without adding unrelated noise.
*
* @param isJson
* @param {import('expect').MatcherUtils} matcherUtils
* @param object
* @param subset
* @param customTesters
* @param seenReferences
*/
function getJsonObjectSubset(object, subset, customTesters = [], seenReferences = new WeakMap()) {
/* eslint-enable @typescript-eslint/explicit-module-boundary-types */
if (Array.isArray(object)) {
if (Array.isArray(subset) && subset.length === object.length) {
// The map method returns correct subclass of subset.
return subset.map((sub, i) => getJsonObjectSubset(object[i], sub, customTesters));
}
} else if (object instanceof Date) {
return object;
} else if (isObject(object) && isObject(subset)) {
if (jsonEquals(object, subset, [...customTesters, jsonSubsetEquality])) {
// Avoid unnecessary copy which might return Object instead of subclass.
return subset;
}

const trimmed = {};
seenReferences.set(object, trimmed);

for (const key of getObjectKeys(object).filter((key) => hasPropertyInObject(subset, key))) {
trimmed[key] = seenReferences.has(object[key])
? seenReferences.get(object[key])
: getJsonObjectSubset(object[key], subset[key], customTesters, seenReferences);
}

if (getObjectKeys(trimmed).length > 0) {
return trimmed;
}
}
return object;
}

function isObjectWithKeys(a) {
return (
isObject(a) &&
!(a instanceof Error) &&
!Array.isArray(a) &&
!(a instanceof Date) &&
!(a instanceof Set) &&
!(a instanceof Map)
);
}

/**
* Subset equality for valid JSON objects.
* @param {unknown} object
* @param {unknown} subset
* @param {import('expect').Tester[]} customTesters
* @returns {undefined|*}
*/
function jsonSubsetEquality(object, subset, customTesters = []) {
const filteredCustomTesters = customTesters.filter((t) => t !== jsonSubsetEquality);

// subsetEquality needs to keep track of the references
// it has already visited to avoid infinite loops in case
// there are circular references in the subset passed to it.

function subsetEqualityWithContext(seenReferencesObject = new WeakSet(), seenReferencesSubset = new WeakSet()) {
function tester(object, subset) {
if (!isObjectWithKeys(subset)) {
return undefined;
}
if (typeof object === 'object' && object !== null) {
if (seenReferencesObject.has(object)) {
return false;
}
seenReferencesObject.add(object);
}
if (typeof subset === 'object' && subset !== null) {
if (seenReferencesSubset.has(subset)) {
return false;
}
seenReferencesSubset.add(subset);
}

const matchResult = getObjectKeys(subset).every((key) => {
let result;

if (object == null) {
return false;
}

// If subset[key] is undefined, than the object should not have the key
if (subset[key] === undefined) {
return !hasPropertyInObject(object, key);
}

result =
hasPropertyInObject(object, key) &&
jsonEquals(object[key], subset[key], [
...filteredCustomTesters,
subsetEqualityWithContext(seenReferencesObject, seenReferencesSubset),
]);

return result;
});

// The main goal of using seenReference is to avoid circular node on tree.
// It will only happen within a parent and its child, not a node and nodes next to it (same level)
// We should keep the reference for a parent and its child only
// Thus we should delete the reference immediately so that it doesn't interfere
// other nodes within the same level on tree.
if (typeof object === 'object' && object !== null) {
seenReferencesObject.delete(object);
}
if (typeof subset === 'object' && subset !== null) {
seenReferencesSubset.delete(subset);
}
return matchResult;
}

return tester;
}

return subsetEqualityWithContext()(object, subset);
}

module.exports = { getJsonObjectSubset, jsonSubsetEquality };
Loading

0 comments on commit 36763ab

Please sign in to comment.