[Bug]: `structuredClone` fails on `toStrictEqual`
Version
29.4.0
Steps to reproduce
- Clone my repository at https://github.com/JamieMagee/jest-structuredclone-strictequal/blob/master/index.spec.js
-
npm install -
npm test
Expected behavior
I expect to see objects cloned with structuredClone to pass toStrictEqual
Actual behavior
The values do not pass toStrictEqual (but do pass toEqual).
jest βΊ toStrictEqual βΊ structured clone
expect(received).toStrictEqual(expected) // deep equality
Expected: {"value": "test"}
Received: serializes to the same string
6 | describe('toStrictEqual', () => {
7 | it('structured clone', () => {
> 8 | expect(structuredClone(value)).toStrictEqual(value);
| ^
9 | });
10 | it('JSON clone', () => {
11 | expect(JSON.parse(JSON.stringify(value))).toStrictEqual(value);
at Object.toStrictEqual (index.spec.js:8:44)
However JSON.parse(JSON.stringify()) does pass toStrictEqual
Additional context
The test output can be seen in this GitHub Actions run.
Environment
System:
OS: Linux 6.2 NixOS 23.05 (Stoat) 23.05 (Stoat)
CPU: (32) x64 AMD Ryzen 9 7950X 16-Core Processor
Binaries:
Node: 19.7.0 - /run/current-system/sw/bin/node
Yarn: 1.22.19 - /run/current-system/sw/bin/yarn
npm: 9.5.0 - /run/current-system/sw/bin/npm
I was playing with assert and expect:
import assert from "node:assert";
import { expect } from "@jest/globals";
// equality
assert.deepEqual(JSON.parse(JSON.stringify(value)), value); // pass
expect(JSON.parse(JSON.stringify(value))).toEqual(value); // pass
assert.deepEqual(structuredClone(value), value); // pass
expect(structuredClone(value)).toEqual(value); // pass
// strict equality
assert.deepStrictEqual(JSON.parse(JSON.stringify(value)), value); // pass
expect(JSON.parse(JSON.stringify(value))).toStrictEqual(value); // pass
assert.deepStrictEqual(structuredClone(value), value); // fail !!!
expect(structuredClone(value)).toStrictEqual(value); // fail !!!
Hm.. if Node and Expect agrees, that is correct behaviour. Or did I miss something?
Might be this is the explanation:
Object.getPrototypeOf(value) === Object.getPrototypeOf(JSON.parse(JSON.stringify(value))) // true
Object.getPrototypeOf(value) === Object.getPrototypeOf(structuredClone(value)) // false
Okay, it looks like this might be a limitation of structuredClone and we should use toEqual instead. From MDN (emphasis mine):
Certain object properties are not preserved:
- The lastIndex property of RegExp objects is not preserved.
- Property descriptors, setters, getters, and similar metadata-like features are not duplicated. For example, if an object is marked readonly with a property descriptor, it will be read/write in the duplicate, since that's the default.
- The prototype chain is not walked or duplicated.
My gut feeling is that I would not want to stop using .toStrictEqual(value) but would prefer another solution than to start using .toEqual(value).
.toStrictEqual(value) verifies a lot of other nice things. From https://jestjs.io/docs/expect#tostrictequalvalue:
- keys with
undefinedproperties are checked, e.g.{a: undefined, b: 2}will not equal{b: 2};undefineditems are taken into account, e.g.[2]will not equal[2, undefined];- array sparseness is checked, e.g.
[, 1]will not equal[undefined, 1];- object types are checked, e.g. a class instance with fields
aandbwill not equal a literal object with fieldsaandb.
I wish to not loose all these checks when using structuredClone().
I am not sure of the solution. One option could be to in a major version (i.e. a breaking change) omitting .toStrictEqual(value) to check for the prototype. Another option would be a new function.
Any other options we have?
Indeed it would be useful to be able to use .toStrictEqual() in this case as well. What about: .toStrictEqual(value, {skipPrototypeCheck: true}) or similar option?
@mrazauskas that is one solution. It kind of goes against other APIs at https://jestjs.io/docs/expect. Few function have a second argument. And if they do, it is not an object (possibly for readability?).
If a second argument is the way to go I think this is nicer: .toStrictEqual(value, skipPrototypeCheck?) . Could/would be nice to add to the docs for the argument skipPrototypeCheck that it can be used when calling code that uses structuredClone(). Just as a common example.
I am not sure if @SimenB has more input on how a new API to cover this should look. Argument, new function or something else.
Not sure how you define readability. A bag of options is easier to extend in the future and is understandable without looking at the docs:
.toStrictEqual(value, {skipPrototypeCheck: true, skipUndefinedCheck: false})
.toStrictEqual(value, true, false)
.toStrictEqual(value, {skipUndefinedCheck: false})
.toStrictEqual(value, undefined, false)
Yeah, I agree it is subjective.
Would a new toBeClone() add too many similar APIs?
Great idea! I could see .toBeClone() implemented in Jest Extended next to .toBeFrozen() and .toBeSealed().
I guess prototype should be somehow replaced for both objects before sending them into equals() and that is it. Or?
Selfish wish would be for it to be part of the core Jest expect functions, since we avoid Jest Extended as to not complicate users with too many options. But that is entirely subjective, as much is π One reason to keep it on the core Jest Expect lib would be that structuredClone() is a standard/global API that exists on the global object.
As for the question about equals() I do not know the details how the comparison is done internally. In general, replacing the prototype is considered bad and not great for performance, see https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Object/setPrototypeOf. Not sure if that detail adds any value to how equals() works?
By the way, I was trying to implement a custom matcher and came to this idea:
const value = { test: 123 };
const received = structuredClone(value);
expect(received).toStrictEqual(structuredClone(value)); // pass !!!
This issue is stale because it has been open 30 days with no activity. Remove stale label or comment or this will be closed in 30 days.
I suppose this is still a great feature to have.
Hi there ! I just stumbled upon the same issue, and I agree that, given structuredClone is now part of the standard library and more and more codebases, there should be an appropriate test for it.
One particular sample that should pass IMO is the following:
describe('foo', () => {
test('date check', () => {
const date = new Date();
expect(structuredClone(date)).toStrictEqual(date);
});
});
This currently fails with
expect(received).toStrictEqual(expected) // deep equality
Expected: 2023-05-23T13:12:11.576Z
Received: serializes to the same string
This is key to me because using the JSON.parse(JSON.stringify(...)) technique does not accommodate nested dates objects (they are serialised to strings), where structuredClone does.
I don't really get what Jest sees to differentiate the original and the clone in the date exemple above, aren't the prototypes the same?
@Marchelune if you read this thread and https://github.com/jestjs/jest/issues/14011#issuecomment-1473947328 and also the comment after it, it answers the question.
@thernstig I did read that and I saw the prototypes aren't the same but it still doesn't explain why it is so.
@thernstig it specifically says The prototype chain is not walked or duplicated. which means after structuredClone the new object has no object sets for its prototype, meaning it cannot be the same. Does that not explain it?
This issue is stale because it has been open 30 days with no activity. Remove stale label or comment or this will be closed in 30 days.
Unstale
This issue is stale because it has been open 30 days with no activity. Remove stale label or comment or this will be closed in 30 days.
Feels important enough to keep alive as I suspect modern JavaScript will use structuredClone
As already mentioned, the solution is simple:
const value = { test: 123 };
const received = structuredClone(value);
expect(received).toStrictEqual(structuredClone(value)); // pass !!!
A new .toBeClone() matcher is not a good idea, because it will be passing with values wich arenβt clones. There is no mechanism to check that. I think above example reads well, declares the intent and does the right job. No need to overthink.
@mrazauskas close this thread then? Or is it a docs issue?
Narrowed this down to typeEquality:
https://github.com/jestjs/jest/blob/0fd5b1c37555f485c56a6ad2d6b010a72204f9f6/packages/expect-utils/src/utils.ts#L372-L387
Specifically, the a.constructor === b.constructor check fails, because of https://github.com/jestjs/jest/issues/2549. This has been reported before: https://github.com/jestjs/jest/issues/14074 This is an unwanted side effect of this old PR: https://github.com/jestjs/jest/pull/7005 Dropping isolation is not an option, so I think the only solution is to improve typeEquality. Maybe a lookup table can be introduced when isolating the modules, mapping "fake" constructors to the originals? So the comparison would be this:
(originals[a.constructor] ?? a.constructor) === (originals[b.constructor] ?? b.constructor)
Alternatively, something could be done about the isolation code, preserving the constructor, maybe?
Hm.. if Node and Expect agrees, that is correct behaviour. Or did I miss something?
It seems @mrazauskas did miss something:
const assert = require("node:assert");
const util = require("node:util");
const values = ["string", 1234, [], [1, 2, 3], {}, { 0: 0 }, new Date()];
const errors = [];
console.log("assert.deepStrictEqual(value, structuredClone(value))");
for (const value of values) {
try {
assert.deepStrictEqual(value, structuredClone(value));
console.log(` PASS (value=${util.inspect(value)})`);
} catch (error) {
console.log(` FAIL (value=${util.inspect(value)})`);
errors.push(error);
}
}
for (const error of errors) {
console.error(error.message);
}
Running this with node directly passes all asserts.
This is inconsistent with what jest does, even worse, it seems to disagree with it's own assert:
const assert = require("node:assert");
const values = ["string", 1234, [], [1, 2, 3, 4], {}, { 0: 0 }, new Date()];
describe("expect(structuredClone(value)).toStrictEqual(value)", () => {
test.each(values)("value=%s", (value) => {
expect(structuredClone(value)).toStrictEqual(value);
});
});
describe("assert.deepStrictEqual(value, structuredClone(value))", () => {
test.each(values)("value=%s", (value) => {
assert.deepStrictEqual(value, structuredClone(value));
});
});
FAIL ./jest.test.js
expect(structuredClone(value)).toStrictEqual(value)
β value=string (2 ms)
β value=1234
β value=[]
β value=[ 1, 2, 3, 4 ] (1 ms)
β value={} (2 ms)
β value={ '0': 0 } (1 ms)
β value=2023-08-16T08:02:14.163Z
assert.deepStrictEqual(value, structuredClone(value))
β value=string (1 ms)
β value=1234
β value=[] (3 ms)
β value=[ 1, 2, 3, 4 ]
β value={}
β value={ '0': 0 } (1 ms)
β value=2023-08-16T08:02:14.163Z (1 ms)
@Grub4K Thanks. Good catch!
Seems like this is simply #2549. Using jest-light-runner solves the problem. Reference: https://github.com/jestjs/jest/issues/2549#issuecomment-1098071474
With jest-light-runner I got:
PASS tests/quick.test.js
expect(structuredClone(value)).toStrictEqual(value)
β value=string (1 ms)
β value=1234 (0 ms)
β value=[] (0 ms)
β value=[ 1, 2, 3, 4 ] (0 ms)
β value={} (0 ms)
β value={ '0': 0 } (0 ms)
β value=2023-08-16T10:11:58.274Z (0 ms)
assert.deepStrictEqual(value, structuredClone(value))
β value=string (0 ms)
β value=1234 (0 ms)
β value=[] (0 ms)
β value=[ 1, 2, 3, 4 ] (0 ms)
β value={} (0 ms)
β value={ '0': 0 } (0 ms)
β value=2023-08-16T10:11:58.274Z (0 ms)
Would be interesting to try this out wider.
This issue is stale because it has been open 30 days with no activity. Remove stale label or comment or this will be closed in 30 days.
@mrazauskas sorry for asking, but I am a bit confused as to the conclusion of this now. Is the suggestion for everyone to switch to jest-light-runner. Considering that Node assert also handles this.
This issue is stale because it has been open 30 days with no activity. Remove stale label or comment or this will be closed in 30 days.
Comment. Still a bug.