jest icon indicating copy to clipboard operation
jest copied to clipboard

[Bug]: `structuredClone` fails on `toStrictEqual`

Open JamieMagee opened this issue 3 years ago β€’ 86 comments

Version

29.4.0

Steps to reproduce

  1. Clone my repository at https://github.com/JamieMagee/jest-structuredclone-strictequal/blob/master/index.spec.js
  2. npm install
  3. 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

JamieMagee avatar Mar 16 '23 03:03 JamieMagee

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?

mrazauskas avatar Mar 17 '23 14:03 mrazauskas

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

mrazauskas avatar Mar 17 '23 14:03 mrazauskas

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.

JamieMagee avatar Mar 17 '23 16:03 JamieMagee

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 undefined properties are checked, e.g. {a: undefined, b: 2} will not equal {b: 2};
  • undefined items 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 a and b will not equal a literal object with fields a and b.

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?

thernstig avatar Apr 01 '23 10:04 thernstig

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 avatar Apr 01 '23 10:04 mrazauskas

@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.

thernstig avatar Apr 01 '23 10:04 thernstig

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)

mrazauskas avatar Apr 01 '23 11:04 mrazauskas

Yeah, I agree it is subjective.

Would a new toBeClone() add too many similar APIs?

thernstig avatar Apr 01 '23 12:04 thernstig

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?

mrazauskas avatar Apr 01 '23 12:04 mrazauskas

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?

thernstig avatar Apr 01 '23 13:04 thernstig

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 !!!

mrazauskas avatar Apr 01 '23 14:04 mrazauskas

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.

github-actions[bot] avatar May 01 '23 15:05 github-actions[bot]

I suppose this is still a great feature to have.

thernstig avatar May 02 '23 05:05 thernstig

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 avatar May 23 '23 13:05 Marchelune

@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 avatar May 23 '23 19:05 thernstig

@thernstig I did read that and I saw the prototypes aren't the same but it still doesn't explain why it is so.

Marchelune avatar May 23 '23 20:05 Marchelune

@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?

thernstig avatar May 24 '23 10:05 thernstig

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.

github-actions[bot] avatar Jun 23 '23 11:06 github-actions[bot]

Unstale

thernstig avatar Jun 26 '23 08:06 thernstig

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.

github-actions[bot] avatar Jul 26 '23 10:07 github-actions[bot]

Feels important enough to keep alive as I suspect modern JavaScript will use structuredClone

thernstig avatar Jul 27 '23 08:07 thernstig

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 avatar Jul 27 '23 08:07 mrazauskas

@mrazauskas close this thread then? Or is it a docs issue?

thernstig avatar Jul 28 '23 07:07 thernstig

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?

alecmev avatar Jul 29 '23 21:07 alecmev

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 avatar Aug 16 '23 09:08 Grub4K

@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.

mrazauskas avatar Aug 16 '23 10:08 mrazauskas

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.

github-actions[bot] avatar Sep 15 '23 11:09 github-actions[bot]

@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.

thernstig avatar Sep 15 '23 11:09 thernstig

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.

github-actions[bot] avatar Oct 15 '23 13:10 github-actions[bot]

Comment. Still a bug.

thernstig avatar Oct 15 '23 13:10 thernstig