Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

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

### Fixes

- `[expect-utils, expect]` Let `toEqual` and `toStrictEqual` compare only the properties an object declares via `SERIALIZABLE_PROPERTIES`, matching what the diff printer already does ([#16461](https://github.com/jestjs/jest/pull/16461))

### Chore & Maintenance

## 30.5.2
Expand Down
4 changes: 2 additions & 2 deletions docs/ExpectAPI.md
Original file line number Diff line number Diff line change
Expand Up @@ -1912,7 +1912,7 @@ Only set custom properties. Overwriting built-in state properties (such as `curr

### `SERIALIZABLE_PROPERTIES`

Serializable properties is a set of properties that are considered serializable by Jest. This set is used to determine if a property should be serializable or not. If an object has a property that is not in this set, it is considered not serializable and will not be printed in error messages.
Serializable properties is a set of properties that are considered serializable by Jest. This set is used to determine if a property should be serializable or not. If an object has a property that is not in this set, it is considered not serializable and will not be printed in error messages. Equality matchers such as `toEqual` and `toStrictEqual` only compare the properties of this set as well.

You can add your own properties to this set to make sure that your objects are printed correctly. For example, if you have a `Volume` class, and you want to make sure that only the `amount` and `unit` properties are printed, you can add it to `SERIALIZABLE_PROPERTIES`:

Expand All @@ -1935,7 +1935,7 @@ Volume.prototype[SERIALIZABLE_PROPERTIES] = ['amount', 'unit'];
expect(new Volume(1, 'L')).toEqual(new Volume(10, 'L'));
```

This will print only the `amount` and `unit` properties in the error message, ignoring the `label` property.
This will print only the `amount` and `unit` properties in the error message, ignoring the `label` property. `toEqual` and `toStrictEqual` only compare the `amount` and `unit` properties as well.

```bash
expect(received).toEqual(expected) // deep equality
Expand Down
146 changes: 146 additions & 0 deletions packages/expect-utils/src/__tests__/jasmineUtils.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,146 @@
/**
* Copyright (c) Meta Platforms, Inc. and affiliates.
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*
*/

import {equals} from '../jasmineUtils';
import {iterableEquality, sparseArrayEquality, typeEquality} from '../utils';

// The same symbol as the one exported by `jest-matcher-utils`, looked up by key
// so that `@jest/expect-utils` does not have to depend on that package.
// https://github.com/jestjs/jest/issues/15073
const SERIALIZABLE_PROPERTIES = Symbol.for('@jest/serializableProperties');

const setSerializableProperties = (obj: object, keys: unknown): void => {
(obj as Record<symbol, unknown>)[SERIALIZABLE_PROPERTIES] = keys;
};

class Volume {
amount: number;
unit: string;
internalState: string;

constructor(amount: number, unit: string, internalState: string) {
this.amount = amount;
this.unit = unit;
this.internalState = internalState;
}
}

setSerializableProperties(Volume.prototype, ['amount', 'unit']);

class UntaggedVolume {
amount: number;
internalState: string;

constructor(amount: number, internalState: string) {
this.amount = amount;
this.internalState = internalState;
}
}

describe('equals() and SERIALIZABLE_PROPERTIES', () => {
test('ignores properties which are not serializable', () => {
expect(equals(new Volume(10, 'L', 'a'), new Volume(10, 'L', 'b'))).toBe(
true,
);
});

test('compares properties which are serializable', () => {
expect(equals(new Volume(10, 'L', 'a'), new Volume(20, 'L', 'a'))).toBe(
false,
);
expect(equals(new Volume(10, 'L', 'a'), new Volume(10, 'mL', 'a'))).toBe(
false,
);
});

test('is unchanged when SERIALIZABLE_PROPERTIES is not set', () => {
const a = new UntaggedVolume(10, 'a');
const b = new UntaggedVolume(10, 'b');

expect(equals(a, b)).toBe(false);
expect(equals(a, new UntaggedVolume(10, 'a'))).toBe(true);
expect(equals({amount: 10}, {amount: 10, internalState: 'a'})).toBe(false);
});

test('is unchanged when SERIALIZABLE_PROPERTIES is not an array', () => {
class Malformed {
amount: number;
internalState: string;

constructor(amount: number, internalState: string) {
this.amount = amount;
this.internalState = internalState;
}
}

setSerializableProperties(Malformed.prototype, 'amount');

expect(equals(new Malformed(10, 'a'), new Malformed(10, 'b'))).toBe(false);
});

test('supports declaring the properties on the instance itself', () => {
const a = {amount: 10, internalState: 'a'};
const b = {amount: 10, internalState: 'b'};

setSerializableProperties(a, ['amount']);
setSerializableProperties(b, ['amount']);

expect(equals(a, b)).toBe(true);
});

test('compares an object with declared properties against a plain object', () => {
expect(equals(new Volume(10, 'L', 'a'), {amount: 10, unit: 'L'})).toBe(
true,
);
expect(equals(new Volume(10, 'mL', 'a'), {amount: 10, unit: 'L'})).toBe(
false,
);
});

test('applies to objects nested in other structures', () => {
const a = new Volume(10, 'L', 'a');
const b = new Volume(10, 'L', 'b');

expect(equals({volume: a}, {volume: b})).toBe(true);
expect(equals([a], [b])).toBe(true);
expect(
equals(new Map([['volume', a]]), new Map([['volume', b]]), [
iterableEquality,
]),
).toBe(true);
expect(equals(new Set([a]), new Set([b]), [iterableEquality])).toBe(true);
expect(equals({volume: a}, {volume: new Volume(20, 'L', 'b')})).toBe(false);
});

test('applies when strict checking is enabled', () => {
const testers = [iterableEquality, typeEquality, sparseArrayEquality];

expect(
equals(new Volume(10, 'L', 'a'), new Volume(10, 'L', 'b'), testers, true),
).toBe(true);
expect(
equals(new Volume(10, 'L', 'a'), new Volume(20, 'L', 'b'), testers, true),
).toBe(false);
});

test('does not apply to arrays and typed arrays', () => {
const a = [1, 2];
const b = [1, 3];
const c = new Uint8Array([1, 2]);
const d = new Uint8Array([1, 3]);

setSerializableProperties(a, []);
setSerializableProperties(b, []);
setSerializableProperties(c, []);
setSerializableProperties(d, []);

// An empty list must not stop the items from being compared.
expect(equals(a, b)).toBe(false);
expect(equals(c, d)).toBe(false);
});
});
61 changes: 59 additions & 2 deletions packages/expect-utils/src/jasmineUtils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,29 @@ WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.

import type {Tester, TesterContext} from './types';

// `jest-matcher-utils` exports this symbol to let an object declare which of its
// properties are meant to be serialized, i.e. which ones are relevant when it is
// printed. It is looked up with `Symbol.for` so that both packages agree on the
// very same symbol without `@jest/expect-utils` depending on `jest-matcher-utils`.
// https://github.com/jestjs/jest/issues/15073
const SERIALIZABLE_PROPERTIES = Symbol.for('@jest/serializableProperties');

// Objects with their own comparison semantics must keep ignoring
// `SERIALIZABLE_PROPERTIES`: `Map`s and `Set`s are compared by `iterableEquality`,
// arrays by their items, `Error`s by their message, and so on.
const isBuiltInObject = (obj: object): boolean =>
Array.isArray(obj) ||
ArrayBuffer.isView(obj) ||
obj instanceof ArrayBuffer ||
obj instanceof Date ||
obj instanceof Error ||
obj instanceof Map ||
obj instanceof Promise ||
obj instanceof RegExp ||
obj instanceof Set ||
obj instanceof WeakMap ||
obj instanceof WeakSet;

export type EqualsFunction = (
a: unknown,
b: unknown,
Expand Down Expand Up @@ -207,20 +230,54 @@ function eq(
}

function keys(obj: object, hasKey: (obj: object, key: string) => boolean) {
const serializableProperties = getSerializableProperties(obj);
const keys = [];
for (const key in obj) {
if (hasKey(obj, key)) {
if (hasKey(obj, key) && isSerializableKey(key, serializableProperties)) {
keys.push(key);
}
}
return [
...keys,
...Object.getOwnPropertySymbols(obj).filter(
symbol => Object.getOwnPropertyDescriptor(obj, symbol)!.enumerable,
symbol =>
Object.getOwnPropertyDescriptor(obj, symbol)!.enumerable &&
isSerializableKey(symbol, serializableProperties),
),
];
}

function isSerializableKey(
key: string | symbol,
serializableProperties: Array<string | symbol> | undefined,
) {
return (
serializableProperties === undefined || serializableProperties.includes(key)
);
}

// Mirrors `getSerializableProperties` of `jest-matcher-utils`: an object is
// compared by all of its own enumerable properties unless it declares a list of
// `SERIALIZABLE_PROPERTIES`, in which case only the listed ones take part in the
// comparison. The declaration is inherited, so declaring it on a class prototype
// applies to every instance of that class.
function getSerializableProperties(
obj: object,
): Array<string | symbol> | undefined {
const serializableProperties: unknown = (obj as Record<string | symbol, any>)[
SERIALIZABLE_PROPERTIES
];

if (!Array.isArray(serializableProperties) || isBuiltInObject(obj)) {
return;
}

return serializableProperties.filter(
(key): key is string | symbol =>
typeof key === 'string' || typeof key === 'symbol',
);
}

function hasKey(obj: any, key: string | symbol) {
return Object.prototype.hasOwnProperty.call(obj, key);
}
Expand Down
48 changes: 48 additions & 0 deletions packages/expect/src/__tests__/serializableProperties.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
/**
* Copyright (c) Meta Platforms, Inc. and affiliates.
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*/

import {expect, test} from '@jest/globals';
import {SERIALIZABLE_PROPERTIES} from 'jest-matcher-utils';

class Volume {
amount: number;
unit: string;
internalState: string;

constructor(amount: number, unit: string, internalState: string) {
this.amount = amount;
this.unit = unit;
this.internalState = internalState;
}
}

// @ts-expect-error: Testing purpose
Volume.prototype[SERIALIZABLE_PROPERTIES] = ['amount', 'unit'];

test('toEqual only compares the serializable properties', () => {
expect(new Volume(10, 'L', 'a')).toEqual(new Volume(10, 'L', 'b'));
expect(new Volume(10, 'L', 'a')).not.toEqual(new Volume(20, 'L', 'a'));
});

test('toStrictEqual only compares the serializable properties', () => {
expect(new Volume(10, 'L', 'a')).toStrictEqual(new Volume(10, 'L', 'b'));
expect(new Volume(10, 'L', 'a')).not.toStrictEqual(new Volume(20, 'L', 'a'));
});

test('the serializable properties apply to nested values', () => {
expect({volume: new Volume(10, 'L', 'a')}).toEqual({
volume: new Volume(10, 'L', 'b'),
});
expect([new Volume(10, 'L', 'a')]).toEqual([new Volume(10, 'L', 'b')]);
});

test('toEqual is unchanged for objects without SERIALIZABLE_PROPERTIES', () => {
expect({amount: 10, internalState: 'a'}).not.toEqual({
amount: 10,
internalState: 'b',
});
});
Loading