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
64 changes: 64 additions & 0 deletions bench/deepEqual/match-object.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
import { expect } from "bun:test";
import { EventEmitter } from "node:events";
import { bench, run } from "../runner.mjs";

class Point {
constructor() {
this.x = 1;
this.y = 2;
}
}

class Base {
constructor() {
this.id = 1;
}
}
class Middle extends Base {}
class Derived extends Middle {}

function deepClass(prototypes) {
let Class = Base;
for (let i = 1; i < prototypes; i++) Class = class extends Class {};
return new Class();
}

const user = () => ({
id: 1,
name: "n",
email: "e",
createdAt: 1,
updatedAt: 2,
role: "r",
active: true,
profile: 3,
});

// One row for each kind of value that toMatchObject() gets as the whole received or expected value.
const cases = {
"two objects of one shape": [
{ a: 1, b: 2 },
{ a: 1, b: 2 },
],
"3 properties, 1 expected": [{ a: 1, b: 2, c: 3 }, { a: 1 }],
"8 properties, 1 expected": [user(), { id: 1 }],
"class instance": [new Point(), { x: 1 }],
"class instance, 3 prototypes": [new Derived(), { id: 1 }],
"class instance, 12 prototypes": [deepClass(12), { id: 1 }],
"EventEmitter": [new EventEmitter(), {}],
"URL": [new URL("http://example.com/"), {}],
"URL expected": [new URL("http://example.com/"), new URL("http://example.com/")],
"array": [
[1, 2, 3],
[1, 2, 3],
],
"nested object": [{ x: 1, y: { z: 2 } }, { y: { z: 2 } }],
"expect.any(Object)": [{ a: 1, b: 2 }, expect.any(Object)],
"expect.objectContaining()": [{ a: 1, b: 2 }, expect.objectContaining({ a: 1 })],
};

for (const [name, [received, expected]] of Object.entries(cases)) {
bench(`toMatchObject: ${name}`, () => expect(received).toMatchObject(expected));
}

await run();
10 changes: 9 additions & 1 deletion src/jsc/bindings/bindings.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -3657,7 +3657,15 @@ bool JSC__JSValue__jestDeepMatch(JSC::EncodedJSValue JSValue0, JSC::EncodedJSVal

Bun::DeepMatchState state;
state.records = !!replaced;
bool matched = Bun__deepMatch<true>(obj, subset, globalObject, scope, state, false);
// A matcher that is the whole expected value is applied. The received value is not read as one, so the key walk keeps its records.
AsymmetricMatcherResult root = AsymmetricMatcherResult::NOT_MATCHER;
if (mayBeAsymmetricMatcher(subset)) [[unlikely]] {
if (obj != subset) {
root = matchAsymmetricMatcher(globalObject, subset, obj, scope);
RETURN_IF_EXCEPTION(scope, false);
}
Comment thread
robobun marked this conversation as resolved.
Resolved
}
Comment thread
robobun marked this conversation as resolved.
Resolved
bool matched = root == AsymmetricMatcherResult::NOT_MATCHER ? Bun__deepMatch<true>(obj, subset, globalObject, scope, state, false) : root == AsymmetricMatcherResult::PASS;
RETURN_IF_EXCEPTION(scope, false);
if (replaced) {
JSValue shown = matched == replaceWhen ? replaceMatchedProperties(globalObject, scope, state, obj, subset) : obj;
Expand Down
68 changes: 68 additions & 0 deletions test/js/bun/test/expect-deferred.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1268,6 +1268,74 @@ describe.concurrent("matchers that have to wait for a promise", () => {
expect({ exitCode, signalCode }).toEqual({ exitCode: 1, signalCode: null });
});

test("a matcher that waits, as the whole expected value of toMatchObject", async () => {
const source = `
expect.extend({
async toHaveLater(received, key) {
await later();
return { pass: key in received, message: () => "toHaveLater" };
},
});
test("passes", async () => {
const matcher = expect(later({ a: 1, b: 2 })).toMatchObject(expect.resolvesTo.objectContaining({ a: 1 }));
console.log(matcher instanceof Promise, await matcher);
console.log(expect(Promise.resolve({ a: 1 })).toMatchObject(expect.resolvesTo.objectContaining({ a: 1 })));
await expect(later({ a: 1 })).not.toMatchObject(expect.resolvesTo.objectContaining({ a: 2 }));
await expect(laterReject({ a: 1 })).toMatchObject(expect.rejectsTo.objectContaining({ a: 1 }));
await expect({ a: 1 }).toMatchObject(expect.toHaveLater("a"));
await expect({ a: 1 }).not.toMatchObject(expect.toHaveLater("b"));
});
test("fails", async () => {
await expect(later({ a: 1 })).toMatchObject(expect.resolvesTo.objectContaining({ a: 2 }));
});
test("fails, not awaited", () => {
expect(later({ a: 1 })).toMatchObject(expect.rejectsTo.anything());
});
test("fails, expect.extend", async () => {
await expect({ a: 1 }).toMatchObject(expect.toHaveLater("b"));
});
test("property matchers", async () => {
const matcher = expect({ a: 1 }).toMatchSnapshot(expect.toHaveLater("a"));
console.log(matcher instanceof Promise, await matcher);
await expect({ a: 1 }).toMatchInlineSnapshot(expect.toHaveLater("a"), \`
{
"a": 1,
}
\`);
});
test("fails, property matchers", async () => {
await expect(later({ a: 1 })).toMatchSnapshot(expect.resolvesTo.objectContaining({ a: 2 }));
});
test("times out", async () => {
await expect(never()).toMatchObject(expect.resolvesTo.anything());
}, 50);
`;
const { stdout, stderr, results, exitCode, signalCode, written } = await runTests(source, [
"__snapshots__/a.test.js.snap",
]);
expect(stdout).toEqual(["true undefined", "undefined", "true undefined"]);
expect(results).toEqual([
"(pass) passes",
"(fail) fails",
"(fail) fails, not awaited",
"(fail) fails, expect.extend",
"(pass) property matchers",
"(fail) fails, property matchers",
"(fail) times out",
]);
// the matcher that waited and matched stored the received value, and the one that failed stored nothing
expect(written).toEqual([
'// Bun Snapshot v1, https://bun.sh/docs/test/snapshots\n\nexports[`property matchers 1`] = `\n{\n "a": 1,\n}\n`;\n',
]);
expect(failingLines(stderr, source)).toEqual([
"await expect(later({ a: 1 })).toMatchObject(expect.resolvesTo.objectContaining({ a: 2 }));",
"expect(later({ a: 1 })).toMatchObject(expect.rejectsTo.anything());",
'await expect({ a: 1 }).toMatchObject(expect.toHaveLater("b"));',
"await expect(later({ a: 1 })).toMatchSnapshot(expect.resolvesTo.objectContaining({ a: 2 }));",
]);
expect({ exitCode, signalCode }).toEqual({ exitCode: 1, signalCode: null });
});

test("a matcher that re-runs after a wait reuses the values from its first run", async () => {
const { stdout, exitCode, signalCode } = await runTests(`
const calls = {};
Expand Down
15 changes: 15 additions & 0 deletions test/js/bun/test/expect-extend.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -170,6 +170,21 @@ it("defines asymmetric variadic matchers that can be prefixed by not", () => {
).not.toThrow();
});

it("applies an asymmetric matcher passed as the whole toMatchObject argument", () => {
expect({ value: 2 }).toMatchObject(expect._toCustomEqual({ value: 2 }));
expect({ value: 2 }).not.toMatchObject(expect._toCustomEqual({ value: 3 }));
// the message is colored in CI, so match the matcher name only, as Jest and Bun, or Vitest print it
expect(() => expect({ value: 2 }).toMatchObject(expect._toCustomEqual({ value: 3 }))).toThrow(
/toMatchObject|to match object/,
);

expect({ value: 2 }).toMatchObject(expect.not._toCustomEqual({ value: 3 }));
expect({ value: 2 }).not.toMatchObject(expect.not._toCustomEqual({ value: 2 }));

// the matcher receives the whole object, which is not a number
expect({ value: 2 }).not.toMatchObject(expect._toBeDivisibleBy(2));
});

it("prints the Symbol into the error message", () => {
const foo = Symbol("foo");
const bar = Symbol("bar");
Expand Down
97 changes: 97 additions & 0 deletions test/js/bun/test/expect.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -3839,6 +3839,103 @@ describe("expect()", () => {
expect(Bun.deepMatch({ a: shared, b: shared }, { a: { v: 1 }, b: { v: 1 } })).toBe(true);
});
}
test("an asymmetric matcher as the whole expected value is applied", async () => {
expect({ a: 1, b: 2 }).toMatchObject(expect.objectContaining({ a: 1 }));
expect({ a: 1, b: 2 }).not.toMatchObject(expect.objectContaining({ c: 1 }));
expect({ a: 1 }).toMatchObject(expect.not.objectContaining({ b: 1 }));
expect({ a: 1 }).not.toMatchObject(expect.not.objectContaining({ a: 1 }));
expect([1]).toMatchObject(expect.any(Array));
expect({ a: 1 }).not.toMatchObject(expect.any(Array));
expect([1, 2]).toMatchObject(expect.arrayContaining([1]));
expect([1, 2]).not.toMatchObject(expect.arrayContaining([3]));
expect({ a: 1 }).toMatchObject(expect.anything());
expect({ a: 1 }).toMatchObject(expect.any(Object));
expect(() => expect({ a: 1 }).toMatchObject(expect.objectContaining({ c: 1 }))).toThrow();
expect(() => expect({ a: 1 }).not.toMatchObject(expect.objectContaining({ a: 1 }))).toThrow();
expect(() => expect({ a: 1 }).toMatchObject(expect.any(Array))).toThrow();
expect(() => expect({ a: 1 }).not.toMatchObject(expect.any(Object))).toThrow();
// matchers that never match an object
expect({ a: 1 }).not.toMatchObject(expect.closeTo(1));
expect({ a: 1 }).not.toMatchObject(expect.stringContaining("a"));
expect({ a: 1 }).not.toMatchObject(expect.stringMatching(/a/));
// matchers inside the matcher
expect({ a: { b: 1 }, c: "x" }).toMatchObject(expect.objectContaining({ a: { b: expect.any(Number) } }));
expect({ a: { b: 1 }, c: "x" }).not.toMatchObject(expect.objectContaining({ a: { b: expect.any(String) } }));
// a Response is an object of the type that the built-in matchers have
expect(new Response("")).toMatchObject(expect.any(Response));
expect(new Response("")).not.toMatchObject(expect.any(URL));
// what the matcher throws is what the assertion throws
class Throws {
static [Symbol.hasInstance]() {
throw new Error("from the matcher");
}
}
expect(() => expect({ a: 1 }).toMatchObject(expect.any(Throws))).toThrow("from the matcher");
await expect(Promise.resolve({ a: 1 })).resolves.not.toMatchObject(expect.any(Array));
});

test("an asymmetric matcher as the whole expected value, against a matcher", () => {
// one object on both sides is not applied to itself
const number = expect.any(Number);
expect(number).toMatchObject(number);
const containing = expect.objectContaining({ a: 1 });
expect(containing).toMatchObject(containing);
const holder = { a: expect.any(Number) };
expect(holder).toMatchObject(holder);
// two matchers that differ
expect(expect.any(Number)).not.toMatchObject(expect.any(String));
expect(expect.objectContaining({ a: 1 })).not.toMatchObject(expect.objectContaining({ a: 2 }));
// the expected matcher is the one that is applied: expect.any(Object) accepts the other matcher
expect(expect.any(Object)).not.toMatchObject(expect.any(Number));
// the received value is an object with an asymmetricMatch() method
expect({ asymmetricMatch: () => true }).not.toMatchObject(expect.any(Number));
});

if (isBun) {
test("details of a matcher as the whole expected value", () => {
const messageOf = (/** @type {() => void} */ fn) => {
try {
fn();
} catch (e) {
return stripVTControlCharacters(/** @type {Error} */ (e).message);
}
return "no error";
};
// the message shows the matcher
expect(messageOf(() => expect({ a: 1 }).toMatchObject(expect.any(Array)))).toBe(
'expect(received).toMatchObject(expected)\n\n- Any<Array>\n+ {\n+ "a": 1,\n+ }\n\n- Expected - 1\n+ Received + 3\n',
);
expect(messageOf(() => expect({ a: 1 }).not.toMatchObject(expect.any(Object)))).toBe(
"expect(received).not.toMatchObject(expected)\n\nExpected: not Any<Object>\n",
);
// Bun.deepMatch has no asymmetric matchers. To it a matcher is an object with no properties.
expect(Bun.deepMatch(expect.any(Array), { a: 1 })).toBe(true);
// expect.toSatisfy() has an enumerable asymmetricMatch, as a matcher of expect.extend has: the key walk never matched it
expect({ a: 1 }).toMatchObject(expect.toSatisfy((/** @type {any} */ value) => value.a === 1));
expect({ a: 1 }).not.toMatchObject(expect.toSatisfy((/** @type {any} */ value) => value.a === 2));
// a URL is an object of the type that the built-in matchers have. As the expected value it is still walked.
expect(new URL("http://a/")).toMatchObject(new URL("http://a/"));
expect(new URL("http://a/")).not.toMatchObject(new URL("http://b/"));
});

test("the received value of toMatchObject is not read as a matcher", () => {
// Its properties are compared, and its asymmetricMatch() is not called.
let calls = 0;
class Service {
a = 1;
asymmetricMatch() {
calls++;
}
}
expect(new Service()).toMatchObject({ a: 1 });
expect(new Service()).not.toMatchObject({ a: 2 });
const literal = { a: 1, asymmetricMatch: () => void calls++ };
expect(literal).toMatchObject({ a: 1 });
expect(calls).toBe(0);
// a built-in matcher has no properties
expect(expect.any(Object)).not.toMatchObject({ a: 1 });
});
}
test("compares an object that occurs twice with each of its patterns", () => {
const shared = { v: 1 };
expect({ a: shared, b: shared }).not.toMatchObject({ a: { v: 1 }, b: { v: 2 } });
Expand Down
8 changes: 8 additions & 0 deletions test/js/bun/test/snapshot-tests/bun-snapshots.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,14 @@ describe("toMatchSnapshot errors", () => {
expect(1).toMatchSnapshot({ a: 1 });
}).toThrow();
});
it("should throw if property matchers that are one asymmetric matcher don't match", () => {
expect(() => {
expect({ a: 1 }).toMatchSnapshot(expect.objectContaining({ b: 1 }));
}).toThrow("to match properties from received object");
expect(() => {
expect({ a: 1 }).toMatchInlineSnapshot(expect.any(Array), `{"a": 1}`);
}).toThrow("to match properties from received object");
});
it("should throw if property matchers don't match", () => {
expect(() => {
expect({ a: 3 }).toMatchSnapshot({ a: 1 });
Expand Down
Loading
Loading