From cd7d7e430bde8e3e70ac88f183d955eef2becedf Mon Sep 17 00:00:00 2001 From: exo-egor Date: Tue, 8 Sep 2026 15:41:29 +0300 Subject: [PATCH 1/2] perf: avoid copying mock history in call-count assertions --- src/expect.cjs | 12 ++- src/jest.fn.js | 2 + tests/jest-superset/expect.mock-count.test.js | 79 +++++++++++++++++++ 3 files changed, 91 insertions(+), 2 deletions(-) create mode 100644 tests/jest-superset/expect.mock-count.test.js diff --git a/src/expect.cjs b/src/expect.cjs index c3bf625d..f1eb2f06 100644 --- a/src/expect.cjs +++ b/src/expect.cjs @@ -2,6 +2,7 @@ let expect let assertionsDelta = 0 const extend = [] const set = [] +const mockContexts = new WeakMap() 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), @@ -180,6 +187,7 @@ function createExpect() { exports.expect = createExpect() exports.loadExpect = loadExpect +exports.registerMockContext = (state, context) => mockContexts.set(state, context) // https://github.com/trynova/nova/issues/935 if (process.env.EXODUS_TEST_PLATFORM === 'nova') exports.expect = require('expect').expect diff --git a/src/jest.fn.js b/src/jest.fn.js index cdf3873c..1472e6db 100644 --- a/src/jest.fn.js +++ b/src/jest.fn.js @@ -1,4 +1,5 @@ import { mock, assert } from './engine.js' +import { registerMockContext } from './expect.cjs' const registry = new Set() let callId = 0 @@ -111,6 +112,7 @@ export const jestfn = (baseimpl, parent, property) => { return fnmock.calls.at(-1)?.arguments }, } + registerMockContext(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 00000000..f5bbc0ce --- /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() +}) From bc6b262c49526630b5c16ca8abe0ed4baa89b269 Mon Sep 17 00:00:00 2001 From: exo-egor Date: Tue, 8 Sep 2026 15:50:09 +0300 Subject: [PATCH 2/2] fix: keep mock context imports out of expect discovery --- package.json | 1 + src/expect.cjs | 3 +-- src/jest.fn.context.cjs | 1 + src/jest.fn.js | 4 ++-- 4 files changed, 5 insertions(+), 4 deletions(-) create mode 100644 src/jest.fn.context.cjs diff --git a/package.json b/package.json index 15d66bff..a3e5a19e 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 f1eb2f06..caf42b9b 100644 --- a/src/expect.cjs +++ b/src/expect.cjs @@ -2,7 +2,7 @@ let expect let assertionsDelta = 0 const extend = [] const set = [] -const mockContexts = new WeakMap() +const mockContexts = require('./jest.fn.context.cjs') function fixupAssertions() { if (assertionsDelta === 0) return @@ -187,7 +187,6 @@ function createExpect() { exports.expect = createExpect() exports.loadExpect = loadExpect -exports.registerMockContext = (state, context) => mockContexts.set(state, context) // https://github.com/trynova/nova/issues/935 if (process.env.EXODUS_TEST_PLATFORM === 'nova') exports.expect = require('expect').expect diff --git a/src/jest.fn.context.cjs b/src/jest.fn.context.cjs new file mode 100644 index 00000000..0d7a581a --- /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 1472e6db..a99cb13a 100644 --- a/src/jest.fn.js +++ b/src/jest.fn.js @@ -1,5 +1,5 @@ import { mock, assert } from './engine.js' -import { registerMockContext } from './expect.cjs' +import mockContexts from './jest.fn.context.cjs' const registry = new Set() let callId = 0 @@ -112,7 +112,7 @@ export const jestfn = (baseimpl, parent, property) => { return fnmock.calls.at(-1)?.arguments }, } - registerMockContext(jestfnmock, fnmock) + mockContexts.set(jestfnmock, fnmock) const fnProxyGet = (obj, key) => { const wrap =