From 5f86b839b4d63bf48db5b290c52b2bf2ee8f58a5 Mon Sep 17 00:00:00 2001 From: James Anderson Date: Sat, 25 Nov 2023 02:24:21 +0000 Subject: [PATCH] feat(test): `toHaveBeenCalledWith` and `toHaveBeenLastCalledWith` (#7277) --- docs/guides/test/migrate-from-jest.md | 1 - docs/test/writing.md | 4 +- packages/bun-types/bun-test.d.ts | 6 +- src/bun.js/test/expect.zig | 133 +++++++++++++++++++++++++- src/bun.js/test/jest.classes.ts | 2 - test/js/bun/test/mock-fn.test.js | 85 ++++++++++++++++ 6 files changed, 223 insertions(+), 8 deletions(-) diff --git a/docs/guides/test/migrate-from-jest.md b/docs/guides/test/migrate-from-jest.md index 4adbddc34e..5938ec00ac 100644 --- a/docs/guides/test/migrate-from-jest.md +++ b/docs/guides/test/migrate-from-jest.md @@ -32,7 +32,6 @@ Some notable missing features: - `expect.extend()` - `expect().toMatchInlineSnapshot()` -- `expect().toHaveBeenCalledWith()` - `expect().toHaveReturned()` --- diff --git a/docs/test/writing.md b/docs/test/writing.md index b8b0118f7f..b833af3c73 100644 --- a/docs/test/writing.md +++ b/docs/test/writing.md @@ -313,12 +313,12 @@ Bun implements the following matchers. Full Jest compatibility is on the roadmap --- -- ❌ +- ✅ - [`.toHaveBeenCalledWith()`](https://jestjs.io/docs/expect#tohavebeencalledwitharg1-arg2-) --- -- ❌ +- ✅ - [`.toHaveBeenLastCalledWith()`](https://jestjs.io/docs/expect#tohavebeenlastcalledwitharg1-arg2-) --- diff --git a/packages/bun-types/bun-test.d.ts b/packages/bun-types/bun-test.d.ts index c2fabfe81e..90e56e8a0d 100644 --- a/packages/bun-types/bun-test.d.ts +++ b/packages/bun-types/bun-test.d.ts @@ -1144,7 +1144,11 @@ declare module "bun:test" { /** * Ensure that a mock function is called with specific arguments. */ - // toHaveBeenCalledWith(...expected: Array): void; + toHaveBeenCalledWith(...expected: Array): void; + /** + * Ensure that a mock function is called with specific arguments for the last call. + */ + toHaveBeenLastCalledWith(...expected: Array): void; }; } diff --git a/src/bun.js/test/expect.zig b/src/bun.js/test/expect.zig index e6dc378d88..b8c7626035 100644 --- a/src/bun.js/test/expect.zig +++ b/src/bun.js/test/expect.zig @@ -3493,8 +3493,137 @@ pub const Expect = struct { return .zero; } - pub const toHaveBeenCalledWith = notImplementedJSCFn; - pub const toHaveBeenLastCalledWith = notImplementedJSCFn; + pub fn toHaveBeenCalledWith(this: *Expect, globalObject: *JSC.JSGlobalObject, callframe: *JSC.CallFrame) callconv(.C) JSC.JSValue { + JSC.markBinding(@src()); + + const thisValue = callframe.this(); + const arguments_ = callframe.argumentsPtr()[0..callframe.argumentsCount()]; + const arguments: []const JSValue = arguments_.ptr[0..arguments_.len]; + defer this.postMatch(globalObject); + const value: JSValue = this.getValue(globalObject, thisValue, "toHaveBeenCalledWith", "expected") orelse return .zero; + + active_test_expectation_counter.actual += 1; + + const calls = JSMockFunction__getCalls(value); + + if (calls == .zero or !calls.jsType().isArray()) { + globalObject.throw("Expected value must be a mock function: {}", .{value}); + return .zero; + } + + var pass = false; + + if (calls.getLength(globalObject) > 0) { + var itr = calls.arrayIterator(globalObject); + while (itr.next()) |callItem| { + if (callItem == .zero or !callItem.jsType().isArray()) { + globalObject.throw("Expected value must be a mock function with calls: {}", .{value}); + return .zero; + } + + if (callItem.getLength(globalObject) != arguments.len) { + continue; + } + + var callItr = callItem.arrayIterator(globalObject); + var match = true; + while (callItr.next()) |callArg| { + if (!callArg.jestDeepEquals(arguments[callItr.i - 1], globalObject)) { + match = false; + break; + } + } + + if (match) { + pass = true; + break; + } + } + } + + const not = this.flags.not; + if (not) pass = !pass; + if (pass) return thisValue; + + // handle failure + if (not) { + const signature = comptime getSignature("toHaveBeenCalledWith", "expected", true); + const fmt = signature ++ "\n\n" ++ "Number of calls: {any}\n"; + globalObject.throwPretty(fmt, .{calls.getLength(globalObject)}); + return .zero; + } + + const signature = comptime getSignature("toHaveBeenCalledWith", "expected", false); + const fmt = signature ++ "\n\n" ++ "Number of calls: {any}\n"; + globalObject.throwPretty(fmt, .{calls.getLength(globalObject)}); + return .zero; + } + + pub fn toHaveBeenLastCalledWith(this: *Expect, globalObject: *JSC.JSGlobalObject, callframe: *JSC.CallFrame) callconv(.C) JSC.JSValue { + JSC.markBinding(@src()); + + const thisValue = callframe.this(); + const arguments_ = callframe.argumentsPtr()[0..callframe.argumentsCount()]; + const arguments: []const JSValue = arguments_.ptr[0..arguments_.len]; + defer this.postMatch(globalObject); + const value: JSValue = this.getValue(globalObject, thisValue, "toHaveBeenLastCalledWith", "expected") orelse return .zero; + + active_test_expectation_counter.actual += 1; + + const calls = JSMockFunction__getCalls(value); + + if (calls == .zero or !calls.jsType().isArray()) { + globalObject.throw("Expected value must be a mock function: {}", .{value}); + return .zero; + } + + const totalCalls = @as(u32, @intCast(calls.getLength(globalObject))); + var lastCallValue: JSValue = .zero; + + var pass = totalCalls > 0; + + if (pass) { + lastCallValue = calls.getIndex(globalObject, totalCalls - 1); + + if (lastCallValue == .zero or !lastCallValue.jsType().isArray()) { + globalObject.throw("Expected value must be a mock function with calls: {}", .{value}); + return .zero; + } + + if (lastCallValue.getLength(globalObject) != arguments.len) { + pass = false; + } else { + var itr = lastCallValue.arrayIterator(globalObject); + while (itr.next()) |callArg| { + if (!callArg.jestDeepEquals(arguments[itr.i - 1], globalObject)) { + pass = false; + break; + } + } + } + } + + const not = this.flags.not; + if (not) pass = !pass; + if (pass) return thisValue; + + // handle failure + var formatter = JSC.ZigConsoleClient.Formatter{ .globalThis = globalObject, .quote_strings = true }; + const received_fmt = lastCallValue.toFmt(globalObject, &formatter); + + if (not) { + const signature = comptime getSignature("toHaveBeenLastCalledWith", "expected", true); + const fmt = signature ++ "\n\n" ++ "Received: {any}" ++ "\n\n" ++ "Number of calls: {any}\n"; + globalObject.throwPretty(fmt, .{ received_fmt, totalCalls }); + return .zero; + } + + const signature = comptime getSignature("toHaveBeenLastCalledWith", "expected", false); + const fmt = signature ++ "\n\n" ++ "Received: {any}" ++ "\n\n" ++ "Number of calls: {any}\n"; + globalObject.throwPretty(fmt, .{ received_fmt, totalCalls }); + return .zero; + } + pub const toHaveBeenNthCalledWith = notImplementedJSCFn; pub const toHaveReturnedTimes = notImplementedJSCFn; pub const toHaveReturnedWith = notImplementedJSCFn; diff --git a/src/bun.js/test/jest.classes.ts b/src/bun.js/test/jest.classes.ts index 017c446eea..8100c9a716 100644 --- a/src/bun.js/test/jest.classes.ts +++ b/src/bun.js/test/jest.classes.ts @@ -146,11 +146,9 @@ export default [ }, toHaveBeenCalledWith: { fn: "toHaveBeenCalledWith", - length: 1, }, toHaveBeenLastCalledWith: { fn: "toHaveBeenLastCalledWith", - length: 1, }, toHaveBeenNthCalledWith: { fn: "toHaveBeenNthCalledWith", diff --git a/test/js/bun/test/mock-fn.test.js b/test/js/bun/test/mock-fn.test.js index aef4e85ad0..79e9f53249 100644 --- a/test/js/bun/test/mock-fn.test.js +++ b/test/js/bun/test/mock-fn.test.js @@ -65,10 +65,13 @@ describe("mock()", () => { expect(fn).toHaveBeenCalledTimes(1); expect(fn.mock.calls).toHaveLength(1); expect(fn.mock.calls[0]).toBeEmpty(); + expect(fn).toHaveBeenLastCalledWith(); expect(fn()).toBe(42); expect(fn).toHaveBeenCalledTimes(2); expect(fn.mock.calls).toHaveLength(2); expect(fn.mock.calls[1]).toBeEmpty(); + expect(fn).toHaveBeenLastCalledWith(); + expect(fn).toHaveBeenCalledWith(); }); test("passes this value", () => { const fn = jest.fn(function hey() { @@ -107,10 +110,13 @@ describe("mock()", () => { expect(fn).toHaveBeenCalledTimes(1); expect(fn.mock.calls).toHaveLength(1); expect(fn.mock.calls[0]).toBeEmpty(); + expect(fn).toHaveBeenLastCalledWith(); expect(Number(fn.call(234))).toBe(234); expect(fn).toHaveBeenCalledTimes(2); expect(fn.mock.calls).toHaveLength(2); expect(fn.mock.calls[1]).toBeEmpty(); + expect(fn).toHaveBeenLastCalledWith(); + expect(fn).toHaveBeenCalledWith(); }); test(".apply works", function () { const fn = jest.fn(function hey() { @@ -121,10 +127,13 @@ describe("mock()", () => { expect(fn).toHaveBeenCalledTimes(1); expect(fn.mock.calls).toHaveLength(1); expect(fn.mock.calls[0]).toBeEmpty(); + expect(fn).toHaveBeenLastCalledWith(); expect(Number(fn.apply(234))).toBe(234); expect(fn).toHaveBeenCalledTimes(2); expect(fn.mock.calls).toHaveLength(2); expect(fn.mock.calls[1]).toBeEmpty(); + expect(fn).toHaveBeenLastCalledWith(); + expect(fn).toHaveBeenCalledWith(); }); test(".bind works", () => { const fn = jest.fn(function hey() { @@ -135,10 +144,13 @@ describe("mock()", () => { expect(fn).toHaveBeenCalledTimes(1); expect(fn.mock.calls).toHaveLength(1); expect(fn.mock.calls[0]).toBeEmpty(); + expect(fn).toHaveBeenLastCalledWith(); expect(Number(fn.bind(234)())).toBe(234); expect(fn).toHaveBeenCalledTimes(2); expect(fn.mock.calls).toHaveLength(2); expect(fn.mock.calls[1]).toBeEmpty(); + expect(fn).toHaveBeenLastCalledWith(); + expect(fn).toHaveBeenCalledWith(); }); test(".name works", () => { const fn = jest.fn(function hey() { @@ -181,6 +193,10 @@ describe("mock()", () => { value: 43, }); expect(fn.mock.calls[0]).toEqual([43]); + expect(fn).toHaveBeenCalled(); + expect(fn).toHaveBeenCalledTimes(1); + expect(fn).toHaveBeenLastCalledWith(43); + expect(fn).toHaveBeenCalledWith(43); }); test("works when throwing", () => { const instance = new Error("foo"); @@ -193,6 +209,8 @@ describe("mock()", () => { value: instance, }); expect(fn.mock.calls[0]).toEqual([43]); + expect(fn).toHaveBeenLastCalledWith(43); + expect(fn).toHaveBeenCalledWith(43); }); test("mockReset works", () => { const instance = new Error("foo"); @@ -205,11 +223,15 @@ describe("mock()", () => { value: instance, }); expect(fn.mock.calls[0]).toEqual([43]); + expect(fn).toHaveBeenLastCalledWith(43); + expect(fn).toHaveBeenCalledWith(43); fn.mockReset(); expect(fn.mock.calls).toBeEmpty(); expect(fn.mock.results).toBeEmpty(); expect(fn.mock.instances).toBeEmpty(); expect(fn).not.toHaveBeenCalled(); + expect(fn).not.toHaveBeenLastCalledWith(43); + expect(fn).not.toHaveBeenCalledWith(43); expect(() => expect(fn).toHaveBeenCalled()).toThrow(); expect(fn(43)).toBe(undefined); expect(fn.mock.results).toEqual([ @@ -219,6 +241,8 @@ describe("mock()", () => { }, ]); expect(fn.mock.calls).toEqual([[43]]); + expect(fn).toHaveBeenLastCalledWith(43); + expect(fn).toHaveBeenCalledWith(43); }); test("mockClear works", () => { const instance = new Error("foo"); @@ -231,17 +255,23 @@ describe("mock()", () => { value: instance, }); expect(fn.mock.calls[0]).toEqual([43]); + expect(fn).toHaveBeenLastCalledWith(43); + expect(fn).toHaveBeenCalledWith(43); fn.mockClear(); expect(fn.mock.calls).toBeEmpty(); expect(fn.mock.results).toBeEmpty(); expect(fn.mock.instances).toBeEmpty(); expect(fn).not.toHaveBeenCalled(); + expect(fn).not.toHaveBeenLastCalledWith(43); + expect(fn).not.toHaveBeenCalledWith(43); expect(() => fn(43)).toThrow("foo"); expect(fn.mock.results[0]).toEqual({ type: "throw", value: instance, }); expect(fn.mock.calls[0]).toEqual([43]); + expect(fn).toHaveBeenLastCalledWith(43); + expect(fn).toHaveBeenCalledWith(43); }); // this is an implementation detail i don't think we *need* to support test("mockClear doesnt update existing object", () => { @@ -255,10 +285,14 @@ describe("mock()", () => { value: instance, }); expect(fn.mock.calls[0]).toEqual([43]); + expect(fn).toHaveBeenLastCalledWith(43); + expect(fn).toHaveBeenCalledWith(43); const stolen = fn.mock; fn.mockClear(); expect(stolen).not.toBe(fn.mock); expect(fn.mock.calls).toBeEmpty(); + expect(fn).not.toHaveBeenLastCalledWith(43); + expect(fn).not.toHaveBeenCalledWith(43); expect(stolen.calls).not.toBeEmpty(); expect(fn.mock.results).toBeEmpty(); expect(stolen.results).not.toBeEmpty(); @@ -271,22 +305,29 @@ describe("mock()", () => { value: instance, }); expect(fn.mock.calls[0]).toEqual([43]); + expect(fn).toHaveBeenLastCalledWith(43); + expect(fn).toHaveBeenCalledWith(43); }); test("multiple calls work", () => { const fn = jest.fn(f => f); expect(fn(43)).toBe(43); + expect(fn).toHaveBeenLastCalledWith(43); expect(fn(44)).toBe(44); + expect(fn).toHaveBeenLastCalledWith(44); expect(fn.mock.calls[0]).toEqual([43]); expect(fn.mock.results[0]).toEqual({ type: "return", value: 43, }); expect(fn.mock.calls[1]).toEqual([44]); + expect(fn).toHaveBeenLastCalledWith(44); expect(fn.mock.results[1]).toEqual({ type: "return", value: 44, }); expect(fn.mock.contexts).toEqual([undefined, undefined]); + expect(fn).toHaveBeenCalledWith(43); + expect(fn).toHaveBeenCalledWith(44); }); test("this arg", () => { const fn = jest.fn(function (add) { @@ -295,6 +336,8 @@ describe("mock()", () => { const obj = { foo: 42, fn }; expect(obj.fn(2)).toBe(44); expect(fn.mock.calls[0]).toEqual([2]); + expect(fn).toHaveBeenLastCalledWith(2); + expect(fn).toHaveBeenCalledWith(2); expect(fn.mock.results[0]).toEqual({ type: "return", value: 44, @@ -320,19 +363,26 @@ describe("mock()", () => { }); const obj = { foo: 42, fn }; expect(obj.fn(2)).toBe(44); + expect(fn).toHaveBeenLastCalledWith(2); const this2 = { foo: 43 }; expect(fn.call(this2, 2)).toBe(45); + expect(fn).toHaveBeenLastCalledWith(2); const this3 = { foo: 44 }; expect(fn.apply(this3, [2])).toBe(46); + expect(fn).toHaveBeenLastCalledWith(2); const this4 = { foo: 45 }; expect(fn.bind(this4)(3)).toBe(48); + expect(fn).toHaveBeenLastCalledWith(3); const this5 = { foo: 45 }; expect(fn.bind(this5, 2)()).toBe(47); + expect(fn).toHaveBeenLastCalledWith(2); expect(fn.mock.calls[0]).toEqual([2]); expect(fn.mock.calls[1]).toEqual([2]); expect(fn.mock.calls[2]).toEqual([2]); expect(fn.mock.calls[3]).toEqual([3]); expect(fn.mock.calls[4]).toEqual([2]); + expect(fn).toHaveBeenCalledWith(2); + expect(fn).toHaveBeenCalledWith(3); expect(fn.mock.results[0]).toEqual({ type: "return", value: 44, @@ -509,6 +559,41 @@ describe("mock()", () => { expect(fn1.mock.invocationCallOrder).toEqual([first, first + 3]); expect(fn2.mock.invocationCallOrder).toEqual([first + 1, first + 2]); }); + + test("toHaveBeenCalledWith, toHaveBeenLastCalledWith works", () => { + const fn = jest.fn(); + expect(() => expect(() => {}).not.toHaveBeenLastCalledWith()).toThrow(); + expect(() => expect(() => {}).not.toHaveBeenCalledWith()).toThrow(); + expect(fn).not.toHaveBeenCalledWith(); + expect(fn).not.toHaveBeenLastCalledWith(); + fn(); + expect(fn).toHaveBeenCalledWith(); + expect(fn).toHaveBeenLastCalledWith(); + expect(fn).not.toHaveBeenCalledWith(1); + fn(1); + expect(fn).toHaveBeenCalledWith(1); + expect(fn).toHaveBeenLastCalledWith(1); + fn(1, 2, 3); + expect(fn).not.toHaveBeenCalledWith("123"); + expect(fn).not.toHaveBeenLastCalledWith(1); + expect(fn).not.toHaveBeenLastCalledWith(1, 2); + expect(fn).not.toHaveBeenLastCalledWith("123"); + expect(fn).toHaveBeenLastCalledWith(1, 2, 3); + expect(fn).not.toHaveBeenLastCalledWith(3, 2, 1); + fn("random string"); + expect(fn).toHaveBeenCalledWith(); + expect(fn).toHaveBeenCalledWith(1); + expect(fn).toHaveBeenCalledWith(1, 2, 3); + expect(fn).toHaveBeenCalledWith("random string"); + expect(fn).toHaveBeenLastCalledWith("random string"); + expect(fn).toHaveBeenCalledWith(expect.stringMatching(/^random \w+$/)); + expect(fn).toHaveBeenLastCalledWith(expect.stringMatching(/^random \w+$/)); + fn(1, undefined); + expect(fn).not.toHaveBeenLastCalledWith(1); + expect(fn).toHaveBeenLastCalledWith(1, undefined); + expect(fn).toHaveBeenCalledWith(1, undefined); + expect(fn).not.toHaveBeenCalledWith(undefined); + }); }); describe("spyOn", () => {