Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We鈥檒l occasionally send you account related emails.

Already on GitHub? Sign in to your account

[Feature] Add support for custom equality testers #13654

Merged
merged 30 commits into from Jan 3, 2023
Merged
Show file tree
Hide file tree
Changes from 15 commits
Commits
Show all changes
30 commits
Select commit Hold shift + click to select a range
63d7186
Add customEqualityTesters support to toEqual
andrewiggins Dec 1, 2022
8f1daca
Add support for custom testers in iterableEquality
andrewiggins Dec 1, 2022
45243cd
Add customTester support to toContainEqual and toHaveProperty
andrewiggins Dec 1, 2022
cb73a20
Add customTester support toStrictEqual
andrewiggins Dec 1, 2022
ce6aca4
Add customTester support to toMatchObject
andrewiggins Dec 1, 2022
b5684ce
Add customTesters to asymmetric matchers
andrewiggins Dec 1, 2022
2fa1336
Add customTesters to spy matchers
andrewiggins Dec 1, 2022
e414bcc
Add test for custom matcher
andrewiggins Dec 1, 2022
944934f
Clean up new tests a bit
andrewiggins Dec 1, 2022
8a8bc17
Add support for customTesters to matcher recommendations in errors
andrewiggins Dec 1, 2022
1ee862f
Give custom testers higher priority over built-in testers
andrewiggins Dec 1, 2022
3961c37
Add custom testers to getObjectSubset
andrewiggins Dec 1, 2022
7bdcded
Add CHANGELOG entry
andrewiggins Dec 1, 2022
144f351
Fix customEqualityTesters TS errors
andrewiggins Dec 1, 2022
1c9b629
Update packages/expect/src/__tests__/customEqualityTesters.test.ts
andrewiggins Dec 2, 2022
9998279
Change API to addEqualityTesters
andrewiggins Dec 16, 2022
9fcf9b5
Get customTesters from matcherContext
andrewiggins Dec 16, 2022
a791dcb
Rename customTesters to filteredCustomTesters
andrewiggins Dec 16, 2022
32d0e38
Add type tests for new API
andrewiggins Dec 16, 2022
05eca9f
Merge branch 'main' into custom-tester-extensions
SimenB Jan 1, 2023
b72796b
Merge branch 'main' into custom-tester-extensions
andrewiggins Jan 1, 2023
c9dc27c
Add docs, pt. 1
andrewiggins Jan 2, 2023
d22295b
Reorganize code to obsolete eslint ignore
andrewiggins Jan 2, 2023
8591181
Convert custom equal testers tests to use Volume object matching docs
andrewiggins Jan 2, 2023
e88fff2
Finish out ExpectAPI docs and add recursive equality tester test
andrewiggins Jan 2, 2023
bed9a1e
reorganize
SimenB Jan 2, 2023
15e509c
link in changelog
SimenB Jan 2, 2023
96de10d
link section
SimenB Jan 2, 2023
dea4949
Expose equals function on tester context
andrewiggins Jan 3, 2023
90afadb
Re-export Tester and TesterContext in expect package
andrewiggins Jan 3, 2023
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
2 changes: 2 additions & 0 deletions CHANGELOG.md
Expand Up @@ -2,6 +2,8 @@

### Features

- `[expect, @jest/expect-utils]` Support custom equality testers

### Fixes

### Chore & Maintenance
Expand Down
2 changes: 1 addition & 1 deletion packages/expect-utils/src/jasmineUtils.ts
Expand Up @@ -76,7 +76,7 @@ function eq(
}

for (let i = 0; i < customTesters.length; i++) {
const customTesterResult = customTesters[i](a, b);
const customTesterResult = customTesters[i](a, b, customTesters);
if (customTesterResult !== undefined) {
return customTesterResult;
}
Expand Down
6 changes: 5 additions & 1 deletion packages/expect-utils/src/types.ts
Expand Up @@ -6,4 +6,8 @@
*
*/

export type Tester = (a: any, b: any) => boolean | undefined;
export type Tester = (
a: any,
b: any,
customTesters: Array<Tester>,
andrewiggins marked this conversation as resolved.
Show resolved Hide resolved
SimenB marked this conversation as resolved.
Show resolved Hide resolved
) => boolean | undefined;
59 changes: 40 additions & 19 deletions packages/expect-utils/src/utils.ts
Expand Up @@ -16,6 +16,7 @@ import {
isImmutableUnorderedSet,
} from './immutableUtils';
import {equals, isA} from './jasmineUtils';
import type {Tester} from './types';

type GetPath = {
hasEndProp?: boolean;
Expand Down Expand Up @@ -102,20 +103,27 @@ export const getPath = (
export const getObjectSubset = (
object: any,
subset: any,
customTesters: Array<Tester> = [],
seenReferences: WeakMap<object, boolean> = new WeakMap(),
): any => {
/* 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: any, i: number) =>
getObjectSubset(object[i], sub),
getObjectSubset(object[i], sub, customTesters),
);
}
} else if (object instanceof Date) {
return object;
} else if (isObject(object) && isObject(subset)) {
if (equals(object, subset, [iterableEquality, subsetEquality])) {
if (
equals(object, subset, [
...customTesters,
iterableEquality,
subsetEquality,
])
) {
// Avoid unnecessary copy which might return Object instead of subclass.
return subset;
}
Expand All @@ -128,7 +136,12 @@ export const getObjectSubset = (
.forEach(key => {
trimmed[key] = seenReferences.has(object[key])
? seenReferences.get(object[key])
: getObjectSubset(object[key], subset[key], seenReferences);
: getObjectSubset(
object[key],
subset[key],
customTesters,
seenReferences,
);
});

if (Object.keys(trimmed).length > 0) {
Expand All @@ -147,6 +160,7 @@ const hasIterator = (object: any) =>
export const iterableEquality = (
a: any,
b: any,
customTesters: Array<Tester> = [],
/* eslint-enable @typescript-eslint/explicit-module-boundary-types */
aStack: Array<any> = [],
bStack: Array<any> = [],
Expand Down Expand Up @@ -178,7 +192,12 @@ export const iterableEquality = (
bStack.push(b);

const iterableEqualityWithStack = (a: any, b: any) =>
iterableEquality(a, b, [...aStack], [...bStack]);
iterableEquality(a, b, [...customTesters], [...aStack], [...bStack]);

customTesters = [
andrewiggins marked this conversation as resolved.
Show resolved Hide resolved
...customTesters.filter(t => t !== iterableEquality),
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

why the check? deserver's a code comment i think

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

is it to prevent infinite recursion in case somebody would manage to add the build-in iterableEquality (somehow imported it directly) into the customTesters array in configuration?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yup - except here all testers are passed in the full array of custom testers, including themselves. So currently it is guaranteed you'll be called with yourself in the custom testers array.

I could also do the filtering in the custom testers loop in equals so a tester is never called for itself, but I wasn't sure that was worth since not every tester would need this since not every tester is recursive, but maybe it would make the API easier to work with.

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ye, not sure about the additional filtering...

iterableEqualityWithStack,
];

if (a.size !== undefined) {
if (a.size !== b.size) {
Expand All @@ -189,7 +208,7 @@ export const iterableEquality = (
if (!b.has(aValue)) {
let has = false;
for (const bValue of b) {
const isEqual = equals(aValue, bValue, [iterableEqualityWithStack]);
const isEqual = equals(aValue, bValue, customTesters);
if (isEqual === true) {
has = true;
}
Expand All @@ -213,19 +232,15 @@ export const iterableEquality = (
for (const aEntry of a) {
if (
!b.has(aEntry[0]) ||
!equals(aEntry[1], b.get(aEntry[0]), [iterableEqualityWithStack])
!equals(aEntry[1], b.get(aEntry[0]), customTesters)
) {
let has = false;
for (const bEntry of b) {
const matchedKey = equals(aEntry[0], bEntry[0], [
iterableEqualityWithStack,
]);
const matchedKey = equals(aEntry[0], bEntry[0], customTesters);

let matchedValue = false;
if (matchedKey === true) {
matchedValue = equals(aEntry[1], bEntry[1], [
iterableEqualityWithStack,
]);
matchedValue = equals(aEntry[1], bEntry[1], customTesters);
}
if (matchedValue === true) {
has = true;
Expand All @@ -249,10 +264,7 @@ export const iterableEquality = (

for (const aValue of a) {
const nextB = bIterator.next();
if (
nextB.done ||
!equals(aValue, nextB.value, [iterableEqualityWithStack])
) {
if (nextB.done || !equals(aValue, nextB.value, customTesters)) {
return false;
}
}
Expand Down Expand Up @@ -290,7 +302,10 @@ const isObjectWithKeys = (a: any) =>
export const subsetEquality = (
object: unknown,
subset: unknown,
customTesters: Array<Tester> = [],
): boolean | undefined => {
const filteredCustomTesters = customTesters.filter(t => t !== subsetEquality);

// 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.
Expand All @@ -304,15 +319,15 @@ export const subsetEquality = (
return Object.keys(subset).every(key => {
if (isObjectWithKeys(subset[key])) {
if (seenReferences.has(subset[key])) {
return equals(object[key], subset[key], [iterableEquality]);
return equals(object[key], subset[key], filteredCustomTesters);
}
seenReferences.set(subset[key], true);
}
const result =
object != null &&
hasPropertyInObject(object, key) &&
equals(object[key], subset[key], [
iterableEquality,
...filteredCustomTesters,
subsetEqualityWithContext(seenReferences),
]);
// The main goal of using seenReference is to avoid circular node on tree.
Expand Down Expand Up @@ -366,6 +381,7 @@ export const arrayBufferEquality = (
export const sparseArrayEquality = (
a: unknown,
b: unknown,
customTesters: Array<Tester> = [],
): boolean | undefined => {
if (!Array.isArray(a) || !Array.isArray(b)) {
return undefined;
Expand All @@ -375,7 +391,12 @@ export const sparseArrayEquality = (
const aKeys = Object.keys(a);
const bKeys = Object.keys(b);
return (
equals(a, b, [iterableEquality, typeEquality], true) && equals(aKeys, bKeys)
equals(
a,
b,
customTesters.filter(t => t !== sparseArrayEquality),
true,
) && equals(aKeys, bKeys)
);
};

Expand Down
@@ -0,0 +1,17 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP

exports[`with custom equality testers toMatchObject error shows special objects as equal 1`] = `
"<dim>expect(</intensity><red>received</color><dim>).</intensity>toMatchObject<dim>(</intensity><green>expected</color><dim>)</intensity>

<green>- Expected - 1</color>
<red>+ Received + 1</color>

<dim> Object {</intensity>
<green>- "a": 2,</color>
<red>+ "a": 1,</color>
<dim> "b": Object {</intensity>
<dim> "$$special": Symbol(special test object type),</intensity>
<dim> "value": "2",</intensity>
<dim> },</intensity>
<dim> }</intensity>"
`;