vitest-dev / vitest

Next generation testing framework powered by Vite.
https://vitest.dev
MIT License
12.92k stars 1.15k forks source link

toMatchObject doesn't work well with Proxy objects #6672

Open jimmycallin opened 1 week ago

jimmycallin commented 1 week ago

Describe the bug

It seems like toMatchObject doesn't work well with proxy objects, and won't call the handler to check equality.

Reproduction

  const proxyObj = new Proxy(
    { foo: "bar" },
    {
      get(target, prop, receiver) {
        if (prop === "bar") {
          return "foo";
        }
      },
    },
  );

  // this is equivalent object without proxy
  const obj = { foo: "bar", bar: "foo" };

  // passes correctly
  expect(obj).toMatchObject({ bar: "foo" });
  // passes correctly
  expect(proxyObj.bar).toBe("foo");
  // fails incorrectly
  expect(proxyObj).toMatchObject({ bar: "foo" });
AssertionError: expected { foo: undefined } to match object { bar: 'foo' }
(1 matching property omitted from actual)

- Expected
+ Received

  Object {
-   "bar": "foo",
+   "foo": undefined,
  }

System Info

System:
    OS: macOS 15.0.1
    CPU: (8) arm64 Apple M1
    Memory: 65.80 MB / 16.00 GB
    Shell: 3.7.1 - /opt/homebrew/bin/fish
  Binaries:
    Node: 20.11.0 - ~/.local/state/fnm_multishells/83610_1728461619670/bin/node
    Yarn: 4.1.0 - ~/.local/state/fnm_multishells/83610_1728461619670/bin/yarn
    npm: 10.2.4 - ~/.local/state/fnm_multishells/83610_1728461619670/bin/npm
  Browsers:
    Chrome: 129.0.6668.100
    Firefox: 130.0.1
    Safari: 18.0.1
  npmPackages:
    vitest: ^2.1.1 => 2.1.1

Used Package Manager

yarn

Validations

hi-ogawa commented 1 week ago

Probably this is because toMatchObject is checking hasOwnProperty https://github.com/vitest-dev/vitest/blob/a8299df2140d2a1a62bec704514fd7e2163c9b18/packages/expect/src/jest-utils.ts#L527-L527

It looks like proxy can intercept more things. For example, something like this can make "more equivalent" object as plain one and toMatchObject and error diff work https://stackblitz.com/edit/vitest-dev-vitest-kuacmv?file=test%2Frepro.test.ts

example ```ts const proxyObj = new Proxy( {}, { get(target, prop, receiver) { if (prop === 'bar') { return 'foo'; } }, getOwnPropertyDescriptor(target, prop) { if (prop === 'bar') { return { configurable: true, enumerable: true }; } return Reflect.getOwnPropertyDescriptor(target, prop); }, ownKeys() { return ['bar']; }, } ); // pass expect(proxyObj).toMatchObject({ bar: 'foo' }) // error diff // Object { // - "bar": "not-foo", // + "bar": "foo", // } expect(proxyObj).toMatchObject({ bar: 'not-foo' }) ```

Though this seems technically possible, I don' think people usually go this far for proxy implementation, so it might make sense to loosen toMatchObject (or specifically subsetEquality) to skip hasOwnProperty check. It looks mostly harmless removing this line though I haven't tested.

https://github.com/vitest-dev/vitest/blob/a8299df2140d2a1a62bec704514fd7e2163c9b18/packages/expect/src/jest-utils.ts#L567-L573

hi-ogawa commented 1 week ago

expect.objectContaining({ bar: "foo" }) also requires hasOwnProperty. So, if we change toMatchObject, then we probably should change this too. https://stackblitz.com/edit/vitest-dev-vitest-dr5jfq?file=test%2Frepro.test.ts

https://github.com/vitest-dev/vitest/blob/2a50464d58e98f58fed513971a570a952081bfef/packages/expect/src/jest-asymmetric-matchers.ts#L148-L156

hi-ogawa commented 1 week ago

@jimmycallin Btw, can you explain your actual use case? We think either behavior seems fine (current one or https://github.com/vitest-dev/vitest/pull/6675), but knowing a concrete use case would help decisions. For example, is proxy coming from some well-known library or your specific implementation?

jimmycallin commented 1 week ago

@jimmycallin Btw, can you explain your actual use case? We think either behavior seems fine (current one or #6675), but knowing a concrete use case would help decisions. For example, is proxy coming from some well-known library or your specific implementation?

Sure! It may be a bit esoteric, but we are using react-query which has a concept of tracked queries, which checks which properties you actually use to reduce rerenders in react. We have a wrapper around a useQuery function that adds a few properties, which naively would look something like this:

function useWrapper() {
    const query = useQuery(...);
    return {...query, additionalProperty: true }
}

Unfortunately the rest spread causes it to "trigger" each property, which nullifies the optimization. So we use a proxy to get around it:

function useWrapper() {
  const query = useQuery({...});
  return new Proxy(query, {
    get(target, prop, receiver) {
      if (prop === "additionalProp") {
        return true;
      }
      return Reflect.get(target, prop, receiver);
    },
  });
}

Then we use react-testing-library to test the result of the hook.

Hope this makes sense!

hi-ogawa commented 1 week ago

Interesting, wrapping useQuery sounds like a sensible use case. How about defining getOwnPropertyDescriptor / ownKeys? I guess it would be too tedious? (honestly I didn't even know such proxy handler exists :smile:)

jimmycallin commented 1 week ago

Interesting, wrapping useQuery sounds like a sensible use case. How about defining getOwnPropertyDescriptor / ownKeys? I guess it would be too tedious? (honestly I didn't even know such proxy handler exists 😄)

We had that previously, but just changed to Proxy after I saw this discussion that Proxy is a lot more performant: https://github.com/TanStack/query/discussions/8091

Which is what triggered this issue. :)

hi-ogawa commented 1 week ago

What I was suggesting is to implement getOwnPropertyDescriptor and ownKeys of proxy:

function useWrapper() {
  const query = useQuery({...});
  return new Proxy(query, {
    get(target, prop, receiver) {
      if (prop === "additionalProp") {
        return true;
      }
      return Reflect.get(target, prop, receiver);
    },
    getOwnPropertyDescriptor(target, prop) {
      if (prop === 'additionalProp') {
        return { configurable: true, enumerable: true };
      }
      return Reflect.getOwnPropertyDescriptor(target, prop);
    },
    ownKeys(target) {
      return [...Reflect.ownKeys(target, prop), 'additionalProp']
    },
  });
}

But, I think https://github.com/TanStack/query/pull/8092 proves the point. If useQuery implements only Proxy.get, then Reflect.getOwnPropertyDescriptor won't probably work for downstream proxy.

EDIT: Oh wait, maybe their useQuery is fine because they have new Proxy(result, { get }), so default getOwnPropertyDescriptor/ownKeys can enumerate the ones from result.