diff --git a/package.json b/package.json index 15d66bf..a3e5a19 100644 --- a/package.json +++ b/package.json @@ -102,6 +102,7 @@ "src/jest.config.js", "src/jest.config.fs.js", "src/jest.environment.js", + "src/jest.fn.context.cjs", "src/jest.fn.js", "src/jest.mock.js", "src/jest.setup.js", diff --git a/src/expect.cjs b/src/expect.cjs index c3bf625..caf42b9 100644 --- a/src/expect.cjs +++ b/src/expect.cjs @@ -2,6 +2,7 @@ let expect let assertionsDelta = 0 const extend = [] const set = [] +const mockContexts = require('./jest.fn.context.cjs') function fixupAssertions() { if (assertionsDelta === 0) return @@ -34,6 +35,12 @@ function loadExpect(loadReason) { const areNumeric = (...args) => args.every((a) => typeof a === 'number' || typeof a === 'bigint') +const mockCallCount = (x) => { + const state = x.mock + const context = mockContexts.get(state) + return context ? context.callCount() : state?.calls?.length +} + const matchers = { __proto__: null, toBe: (x, y) => Object.is(x, y), @@ -54,8 +61,8 @@ const matchers = { toBeGreaterThanOrEqual: (x, c) => areNumeric(x, c) && x >= c, toBeLessThan: (x, c) => areNumeric(x, c) && x < c, toBeLessThanOrEqual: (x, c) => areNumeric(x, c) && x <= c, - toHaveBeenCalled: (x) => x?._isMockFunction && x?.mock?.calls?.length > 0, - toHaveBeenCalledTimes: (x, c) => x?._isMockFunction && x?.mock?.calls?.length === c, + toHaveBeenCalled: (x) => x?._isMockFunction && mockCallCount(x) > 0, + toHaveBeenCalledTimes: (x, c) => x?._isMockFunction && mockCallCount(x) === c, toBeCalled: (...a) => matchers.toHaveBeenCalled(...a), toBeCalledTimes: (...a) => matchers.toHaveBeenCalledTimes(...a), toHaveBeenCalledOnce: (x) => matchers.toHaveBeenCalledTimes(x, 1), diff --git a/src/jest.fn.context.cjs b/src/jest.fn.context.cjs new file mode 100644 index 0000000..0d7a581 --- /dev/null +++ b/src/jest.fn.context.cjs @@ -0,0 +1 @@ +module.exports = new WeakMap() diff --git a/src/jest.fn.js b/src/jest.fn.js index cdf3873..a99cb13 100644 --- a/src/jest.fn.js +++ b/src/jest.fn.js @@ -1,4 +1,5 @@ import { mock, assert } from './engine.js' +import mockContexts from './jest.fn.context.cjs' const registry = new Set() let callId = 0 @@ -111,6 +112,7 @@ export const jestfn = (baseimpl, parent, property) => { return fnmock.calls.at(-1)?.arguments }, } + mockContexts.set(jestfnmock, fnmock) const fnProxyGet = (obj, key) => { const wrap = diff --git a/tests/jest-superset/expect.mock-count.test.js b/tests/jest-superset/expect.mock-count.test.js new file mode 100644 index 0000000..f5bbc0c --- /dev/null +++ b/tests/jest-superset/expect.mock-count.test.js @@ -0,0 +1,79 @@ +const testOptimized = jest.exodus.platform === 'nova' ? test.skip : test + +testOptimized('count matchers do not materialize call history', () => { + const fn = jest.fn() + const { get } = Object.getOwnPropertyDescriptor(fn.mock, 'calls') + let reads = 0 + Object.defineProperty(fn.mock, 'calls', { + get() { + reads++ + return get.call(this) + }, + }) + + expect(fn).not.toHaveBeenCalled() + expect(fn).not.toBeCalled() + expect(fn).toHaveBeenCalledTimes(0) + fn('first') + expect(fn).toHaveBeenCalledOnce() + const bound = fn.bind(null, 'second') + bound() + expect(fn).toHaveBeenCalled() + expect(fn).toBeCalled() + expect(fn).toHaveBeenCalledTimes(2) + expect(bound).toBeCalledTimes(2) + expect(fn).not.toHaveBeenCalledTimes(1) + expect(fn).not.toBeCalledTimes(1) + expect(reads).toBe(0) + expect(fn.mock.calls).toEqual([['first'], ['second']]) + + fn.mockClear() + expect(fn).toHaveBeenCalledTimes(0) + expect(bound).not.toHaveBeenCalled() + fn() + expect(fn).toHaveBeenCalledTimes(1) + fn.mockReset() + expect(fn).toHaveBeenCalledTimes(0) + expect(reads).toBe(1) +}) + +test('count matchers use call history for unregistered mocks', () => { + const calls = [] + const fn = (...args) => calls.push(args) + fn._isMockFunction = true + fn.mock = { calls } + + expect(fn).not.toHaveBeenCalled() + expect(fn).toHaveBeenCalledTimes(0) + fn('first') + expect(fn).toHaveBeenCalled() + expect(fn).toHaveBeenCalledTimes(1) + calls.length = 0 + expect(fn).not.toHaveBeenCalled() + expect(fn).toHaveBeenCalledTimes(0) +}) + +test('count matchers follow completed calls, including thrown calls', () => { + let completed = 0 + const fn = jest.fn(() => { + expect(fn).toHaveBeenCalledTimes(completed) + throw new Error('mock failure') + }) + + for (let i = 0; i < 2; i++) { + expect(fn).toThrow('mock failure') + completed++ + expect(fn).toHaveBeenCalledTimes(completed) + } +}) + +test('count matchers still report failures and reject non-mocks', () => { + const fn = jest.fn() + expect(() => expect(fn).toHaveBeenCalled()).toThrow() + expect(() => expect(fn).toHaveBeenCalledTimes(1)).toThrow() + expect(() => expect(fn).toHaveBeenCalledTimes()).toThrow() + fn() + expect(() => expect(fn).not.toHaveBeenCalled()).toThrow() + expect(() => expect(fn).not.toHaveBeenCalledTimes(1)).toThrow() + expect(() => expect(() => {}).toHaveBeenCalledTimes(0)).toThrow() +})