Thanks to visit codestin.com
Credit goes to github.com

Skip to content

feat(jest-mock): add mock.whenCalledWith(...)#16053

Merged
SimenB merged 33 commits into
jestjs:mainfrom
timkindberg:feat/when-called-with
May 15, 2026
Merged

feat(jest-mock): add mock.whenCalledWith(...)#16053
SimenB merged 33 commits into
jestjs:mainfrom
timkindberg:feat/when-called-with

Conversation

@timkindberg
Copy link
Copy Markdown
Contributor

@timkindberg timkindberg commented Apr 28, 2026

Summary

Adds mockFn.whenCalledWith(...args) to Jest core so users can configure return values per argument list without writing branching logic inside mockImplementation. This is the long-requested API tracked in #6180 and #13811.

The API surface and naming (whenCalledWith) match what @mrazauskas approved on #14076. Implementation rewritten against current main and addresses the *Once / withImplementation cleanup behavior the prior PR documented as surprising, plus the cross-branch *Once exhaustion case raised in review by @jeysal. Several follow-up rounds with @SimenB tightened up the type-erasure leaks and withImplementation interaction (see "Review feedback" below).

Design

A single dispatcher is installed in mockConfig.mockImpl on the first whenCalledWith call. The dispatcher walks mockConfig.whenCalledWithRegistrations (a parallel array of {matchers, subMock} records) on every call and routes per the precedence rules below. Each sub-mock is a real Mock<T> from _makeComponent({type: 'function'}), so mockReturnValue, mockReturnValueOnce, mockResolvedValue, etc. work on it identically to a jest.fn().

mockReset / mockClear / mockRestore work without modification because the dispatcher lives in mockConfig.mockImpl and the registrations live in the same MockFunctionConfig they already wipe.

Call-time precedence

  1. Parent-level *Once queue wins for the call that consumes it. fn.mockImplementationOnce / mockReturnValueOnce / mockResolvedValueOnce / mockRejectedValueOnce are the most explicit one-shot overrides and fire next regardless of whether the call's args match a registered matcher. Once the queue drains, normal whenCalledWith routing resumes.
  2. Earliest matching branch with a queued *Once. Onces declared on a sub-mock drain in registration order across all matching branches before any persistent impl fires. Addresses the case raised in PR review where a queued once on an inner branch was previously unreachable when an overlapping outer branch had a persistent impl.
  3. Last-registered matching persistent branch. Among matching branches with no queued onces, the most recently registered persistent impl wins.
  4. Fall through to the base impl — whatever mockImpl was set before the dispatcher took over. For a jest.fn() with no base impl this is undefined; for a spyOn it's the original-method bridge; for a user-provided mockImplementation set before whenCalledWith it's that fn.

Repeat calls return distinct branches

Each whenCalledWith(...) call returns a fresh sub-mock — there's no "same-matchers merge". Two whenCalledWith('x') calls produce two branches that the precedence rules above route through correctly (last-registered persistent wins for matches; queued onces drain in order across all matching branches). Users who want to extend an existing registration chain on a saved reference:

const branch = fn.whenCalledWith('x');
branch.mockReturnValueOnce('once');
branch.mockReturnValue('default');

Re-arming

If the user calls mockImplementation/mockReturnValue/etc. after whenCalledWith, that overwrites the dispatcher in mockConfig.mockImpl. The next whenCalledWith detects the clobber via identity check and reinstalls the dispatcher with the user's new impl as the fallback. Prior registrations are preserved — re-arming the fallback shouldn't silently drop branches the user already configured.

Argument matching

Uses equals from @jest/expect-utils — same machinery toHaveBeenCalledWith uses. Asymmetric matchers (expect.any(...), expect.objectContaining(...)) and the trailing-undefined arity quirk all behave consistently with existing Jest semantics.

Constructor calls

new fn(args) matches the same way as fn(args). The dispatcher dispatches every target through .apply (matched branch or fallback), mirroring mockConstructor's own handling of new — so non-constructable fallbacks (arrow-fn user impls, method-shorthand spy bridges) don't blow up. Sub-mocks are real Mocks and still record this on their own mock.instances.

Reset semantics

fn.mockReset() cascades to every sub-mock returned by whenCalledWith(...) — without that cascade, references the user kept would silently retain their prior impl and queued onces. fn.mockClear() does not touch registrations (consistent with its existing "clear stats only" semantic).

withImplementation interaction

fn.withImplementation(temp, callback) saves and restores fallbackImpl alongside mockImpl and specificMockImpls. If whenCalledWith re-arms the dispatcher inside the callback, the temp fn becomes the fallback during the scope (as expected) but is unwound on exit — registrations made inside the scope persist; the fallback returns to its pre-scope value.

Types

Wires FunctionParameters<T> (already used by toHaveBeenCalledWith) into the whenCalledWith signature so fn.whenCalledWith(expect.any(Number)) typechecks against typed mocks. Returns Mock<T> (not this) since the call hands back a child sub-mock, not the parent. The AsymmetricMatcher structural protocol type was hoisted from packages/expect/src/types.ts down to @jest/expect-utils (re-exported from expect for backward compat — no breaking change). FunctionParameters and its private helpers stay in jest-mock to avoid expanding @jest/expect-utils's public surface for one consumer; expect's MockParameters imports it back from jest-mock.

Review feedback addressed

  • @jeysal — cross-branch *Once exhaustion ordering when overlapping matchers were registered out of order.
  • @SimenB — dispatcher leaking through getMockImplementation; mockImplementation between two whenCalledWith calls clobbering prior registrations; Reflect.construct failing on non-constructable fallbacks; fallbackImpl corruption inside withImplementation; whenCalledWith(...): this mistyped vs. the actual Mock<T> return; equals-based same-matchers merge being broken in both directions (false negatives for duplicate asymmetric matchers; false positives where a literal merged with a wildcard, killing the wildcard).

What's not in this PR

Per the discussion in #14076 and #13811, the following are intentionally deferred to follow-up PRs:

  • expect.predicate(fn) — function as asymmetric matcher
  • expect.allArgs(fn) — whole-arg-list matcher
  • mock.defaultReturnValue(x) — explicit bottom-of-stack default for jest-when migration parity
  • expectCalledWith / verifyAllWhenMocksCalled (skipped permanently — overlap with built-ins)

Also out of scope: a codemod for migrating from jest-when, which I'll ship as a separate package once this lands in a stable release.

Test plan

  • 46 behavior tests in packages/jest-mock/src/__tests__/whenCalledWith.test.ts — literal args, asymmetric matchers, multi-matcher routing, repeat-call branch isolation, drained-branch fall-through across overlapping matchers (regardless of declaration order), drain order across multiple queued onces on overlapping branches, *Once + withImplementation interactions, withImplementation save/restore of fallback, sub-mock reset granularity + parent-reset cascade, parent-level *Once precedence, getMockImplementation doesn't leak the dispatcher, dispatcher re-arm preserves prior registrations, constructor (new fn()) routing + sub-mock mock.instances recording + arrow-fn / spy-bridge fall-through, spy integration, this context, call recording, null/symbol identity, arity rules.
  • Type assertions in packages/jest-mock/__typetests__/mock-functions.test.tsFunctionParameters<T> constraint, asymmetric matchers per slot, optional/variadic args, void returns, generics with conditional/mapped return types, Mock<T> return type.
  • yarn lint, yarn lint:prettier:ci, yarn check-changelog, yarn check-copyright-headers, yarn constraints, yarn dedupe --check, yarn verify-pnp, yarn test-types --target '>=5.4' — all pass.

🤖 Generated with Claude Code but I reviewed every line and we used Matt Pocock's set of skills /grill-me, /write-a-prd, /prd-to-issues, /tdd to implement in a red/green fashion so I could learn the codebase as changes were made and steer as needed.

@netlify
Copy link
Copy Markdown

netlify Bot commented Apr 28, 2026

Deploy Preview for jestjs ready!

Name Link
🔨 Latest commit eaecbbf
🔍 Latest deploy log https://app.netlify.com/projects/jestjs/deploys/6a06d00386548b00080ea5ec
😎 Deploy Preview https://deploy-preview-16053--jestjs.netlify.app
📱 Preview on mobile
Toggle QR Code...

QR Code

Use your smartphone camera to open QR code link.

To edit notification comments on pull requests, go to your Netlify project configuration.

@linux-foundation-easycla
Copy link
Copy Markdown

linux-foundation-easycla Bot commented Apr 28, 2026

CLA Signed
The committers listed above are authorized under a signed CLA.

@pkg-pr-new
Copy link
Copy Markdown

pkg-pr-new Bot commented Apr 28, 2026

Open in StackBlitz

babel-jest

npm i https://pkg.pr.new/babel-jest@16053

babel-plugin-jest-hoist

npm i https://pkg.pr.new/babel-plugin-jest-hoist@16053

babel-preset-jest

npm i https://pkg.pr.new/babel-preset-jest@16053

create-jest

npm i https://pkg.pr.new/create-jest@16053

@jest/diff-sequences

npm i https://pkg.pr.new/@jest/diff-sequences@16053

expect

npm i https://pkg.pr.new/expect@16053

@jest/expect-utils

npm i https://pkg.pr.new/@jest/expect-utils@16053

jest

npm i https://pkg.pr.new/jest@16053

jest-changed-files

npm i https://pkg.pr.new/jest-changed-files@16053

jest-circus

npm i https://pkg.pr.new/jest-circus@16053

jest-cli

npm i https://pkg.pr.new/jest-cli@16053

jest-config

npm i https://pkg.pr.new/jest-config@16053

@jest/console

npm i https://pkg.pr.new/@jest/console@16053

@jest/core

npm i https://pkg.pr.new/@jest/core@16053

@jest/create-cache-key-function

npm i https://pkg.pr.new/@jest/create-cache-key-function@16053

jest-diff

npm i https://pkg.pr.new/jest-diff@16053

jest-docblock

npm i https://pkg.pr.new/jest-docblock@16053

jest-each

npm i https://pkg.pr.new/jest-each@16053

@jest/environment

npm i https://pkg.pr.new/@jest/environment@16053

jest-environment-jsdom

npm i https://pkg.pr.new/jest-environment-jsdom@16053

@jest/environment-jsdom-abstract

npm i https://pkg.pr.new/@jest/environment-jsdom-abstract@16053

jest-environment-node

npm i https://pkg.pr.new/jest-environment-node@16053

@jest/expect

npm i https://pkg.pr.new/@jest/expect@16053

@jest/fake-timers

npm i https://pkg.pr.new/@jest/fake-timers@16053

@jest/get-type

npm i https://pkg.pr.new/@jest/get-type@16053

@jest/globals

npm i https://pkg.pr.new/@jest/globals@16053

jest-haste-map

npm i https://pkg.pr.new/jest-haste-map@16053

jest-jasmine2

npm i https://pkg.pr.new/jest-jasmine2@16053

jest-leak-detector

npm i https://pkg.pr.new/jest-leak-detector@16053

jest-matcher-utils

npm i https://pkg.pr.new/jest-matcher-utils@16053

jest-message-util

npm i https://pkg.pr.new/jest-message-util@16053

jest-mock

npm i https://pkg.pr.new/jest-mock@16053

@jest/pattern

npm i https://pkg.pr.new/@jest/pattern@16053

jest-phabricator

npm i https://pkg.pr.new/jest-phabricator@16053

jest-regex-util

npm i https://pkg.pr.new/jest-regex-util@16053

@jest/reporters

npm i https://pkg.pr.new/@jest/reporters@16053

jest-resolve

npm i https://pkg.pr.new/jest-resolve@16053

jest-resolve-dependencies

npm i https://pkg.pr.new/jest-resolve-dependencies@16053

jest-runner

npm i https://pkg.pr.new/jest-runner@16053

jest-runtime

npm i https://pkg.pr.new/jest-runtime@16053

@jest/schemas

npm i https://pkg.pr.new/@jest/schemas@16053

jest-snapshot

npm i https://pkg.pr.new/jest-snapshot@16053

@jest/snapshot-utils

npm i https://pkg.pr.new/@jest/snapshot-utils@16053

@jest/source-map

npm i https://pkg.pr.new/@jest/source-map@16053

@jest/test-result

npm i https://pkg.pr.new/@jest/test-result@16053

@jest/test-sequencer

npm i https://pkg.pr.new/@jest/test-sequencer@16053

@jest/transform

npm i https://pkg.pr.new/@jest/transform@16053

@jest/types

npm i https://pkg.pr.new/@jest/types@16053

jest-util

npm i https://pkg.pr.new/jest-util@16053

jest-validate

npm i https://pkg.pr.new/jest-validate@16053

jest-watcher

npm i https://pkg.pr.new/jest-watcher@16053

jest-worker

npm i https://pkg.pr.new/jest-worker@16053

pretty-format

npm i https://pkg.pr.new/pretty-format@16053

commit: eaecbbf

Comment thread docs/MockFunctionAPI.md Outdated
Comment thread docs/MockFunctionAPI.md Outdated
Comment thread docs/MockFunctionAPI.md Outdated
timkindberg added a commit to timkindberg/jest that referenced this pull request Apr 28, 2026
Replaces the closure-chain routing with a single dispatcher per mock
that walks `whenCalledWithRegistrations` on every call. Drains queued
*Once impls across all matching branches in registration order, then
falls back to the last-registered persistent matching branch, then to
`fallbackImpl` (whatever mockImpl existed before the dispatcher took
over — e.g. spyOn's original-method bridge).

The previous closure-chain only consulted the most-recently-registered
matching branch, which meant a queued mockReturnValueOnce on an inner
branch was unreachable when an outer branch had a persistent impl. Per
review feedback on jestjs#16053, this is the case users would intuitively
expect to "exhaust onces first".

The dispatcher is allocated eagerly per mock and cached in closure;
re-installation after a user clobber (e.g. mockImplementation between
whenCalledWith calls) reuses the same dispatcher and just resets the
registrations + fallback.

Also adds two regression tests for the overlapping-matchers case and
extends the merge test for asymmetric-matcher equivalence.

Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
Copy link
Copy Markdown
Contributor Author

@timkindberg timkindberg left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Self-review after a tougher second pass. Surfacing four points worth design discussion before this lands — happy to act on whichever direction maintainers prefer.

Comment thread packages/expect-utils/src/index.ts
Comment thread packages/jest-mock/src/index.ts
Comment thread packages/jest-mock/src/index.ts
Comment thread packages/jest-mock/src/__tests__/whenCalledWith.test.ts
@jeysal
Copy link
Copy Markdown
Collaborator

jeysal commented May 1, 2026

Thanks for all the work! I've been a bit busy this week but haven't forgotten, will take a look as soon as I can

@SimenB
Copy link
Copy Markdown
Member

SimenB commented May 4, 2026

Hello! 👋

Thanks for picking this back up! I've finally got my head above water after some ESM work, and I'd like to include this in the next release together with require(esm). I've been looking through the code and tests, and a few things jump out to me:

  • The equals-based same-matchers merge seems broken for duplicate asymmetric matchers. equals([expect.any(String)], [expect.any(String)]) returns false, because the left-side asymmetric matcher calls .asymmetricMatch on the right-side matcher object (not a string). So calling whenCalledWith(expect.any(String)) twice creates two separate registrations instead of merging - stacking mockReturnValueOnce across those two calls won't behave as expected (I think? You probably have a better intuition than me on these things, tho). Referential equality for the merge check would probably be more correct, or drop the merge entirely (last call wins, document that the same pattern should be chained on one reference).

  • There's also seemingly a footgun when mixing mockImplementation with whenCalledWith:

    fn.whenCalledWith('x').mockReturnValue('X');
    fn.mockImplementation(() => 'fallback'); // replaces dispatcher
    fn.whenCalledWith('y').mockReturnValue('Y'); // ← silently clears 'x'

    The third call reinstalls the dispatcher, wiping the 'x' registration. The fix might be to update fallbackImpl but preserve existing registrations?

  • Minor: whenCalledWith(...args): this in the interface is a slight type mismatch - the return type this implies the same instance, but it returns a child sub-mock. In practice the types are compatible, but Mock<T> would be more accurate.

  • The overlapping-matchers precedence is complex but probably worth it given @jeysal's question. The tests cover it well - just make sure the precedence table in the docs is easy to find, since that's going to be the first stop for anyone who hits a surprising result.

@SimenB
Copy link
Copy Markdown
Member

SimenB commented May 7, 2026

Thanks! I threw claude code at it now to see if it spotted anything I didn't 😀 it came back with a few things posted unedited (except for me removing a couple of things I don't care about) below:

  • getMockImplementation() now returns the internal dispatcher function once whenCalledWith has been called, rather than the user-visible impl. Not a Jest-internal problem, but user code or custom matchers that inspect this will get the wrong thing back. Worth either documenting or filtering in getMockImplementation.

  • There's a fallbackImpl corruption if whenCalledWith is called inside a withImplementation callback:

    fn.mockReturnValue('default');
    fn.whenCalledWith('x').mockReturnValue('X');
    fn.withImplementation(() => 'temp', () => {
      fn.whenCalledWith('y').mockReturnValue('Y'); // re-arm fires here
      // fallbackImpl is now set to () => 'temp'
    });
    // dispatcher is restored, but fallbackImpl permanently points to the temp fn
    fn('other'); // returns 'temp' instead of 'default'

    The re-arm logic sees mockImpl !== dispatcherImpl (because withImplementation replaced it with the temp fn) and updates fallbackImpl. After the callback exits the dispatcher is restored, but fallbackImpl is now stale. Probably needs a guard — skip the re-arm inside a withImplementation scope, or restore fallbackImpl alongside mockImpl.

  • For the construct fall-through case: when new fn('noMatch') is called and no branch matches, the dispatcher calls Reflect.construct(fallbackImpl, callArgs). If fn is a spy, fallbackImpl is the original-method bridge, which is not a constructor. The spy tests only cover the plain-call fall-through (target.greet('jest')'hello jest'); new spy('noMatch') is untested and would likely misbehave.

  • The PR description still has the "same-matchers merge" section that 2af23ef7 removed — contradicts the current behaviour, worth cleaning up before this comes out of draft.

  • Minor: "Strict arity" in the docs is a slightly odd label given that trailing undefined args are explicitly allowed in the very next bullet. Maybe just drop the label and merge the two bullets into one.

  • Minor: WhenCalledWithRegistration.subMock is typed as Function but it's always a Mock — removing the need for casts at every use site.

@timkindberg
Copy link
Copy Markdown
Contributor Author

Good catches @SimenB, all four initial issues are addressed in the last four commits on the branch. Quick rundown:

Merge: ended up just dropping it. I tried to stubbornly make it work before giving up. You'd flagged the false-negative case (equals([expect.any(String)], [expect.any(String)]) returning false), but while digging in I realized equals is actually broken in both directions. equals(['x'], [expect.any(String)]) returns true because the asymmetric matcher matches the literal. So whenCalledWith('x').mockReturnValue('lit') followed by whenCalledWith(expect.any(String)).mockReturnValue('wild') was silently merging into a single branch with the literal's matchers, which killed the wildcard for any non-'x' string. That's actually the more damaging bug imo since it makes the wildcard intent disappear entirely. Once both sides were broken, repairing it (referential, structural, etc.) felt like piling complexity onto something that wasn't really pulling its weight. The dispatcher's existing precedence rules already handle overlapping branches correctly... onces drain in registration order, last-registered persistent wins, fall through to base. Dropping the merge reduces lines, broke zero existing tests, and tightened things up.

mockImplementation footgun: agreed. Reinstalled the dispatcher in place instead of resetting whenCalledWithRegistrations to []. Pinned with a regression test.

this → Mock: done. Tstyche assertions still pass on TS 5.4 → 6.0.

Precedence table — the old "Same-matchers merge" subsection was obsolete after dropping the merge, so I rewrote it to point at the precedence table, simplified the table heading, and added a lead-in sentence. Should be more findable now.

Also picked up a handful of new tests along the way for the wildcard-vs-literal scenarios that the merge had been hiding — those were the actual user-visible bugs.

I'll work on your new set of feedback next.

@timkindberg
Copy link
Copy Markdown
Contributor Author

timkindberg commented May 8, 2026

Ok all 6 are in the latest commits.

  1. getMockImplementation() Yup it was a real leak... now it's returning what the user actually set now, not the internal routing thing. Test added.

  2. withImplementation This one was sneaky. The temp fn was hanging around past the end of the scope. Fixed by saving the previous fallback and putting it back when the scope ends, same shape as what the scope already does for its other bits. Test pins the before/during/after.

  3. new fn('noMatch') Honestly I overcomplicated this one. mockConstructor itself doesn't use Reflect.construct, it just .applys, so I matched that. Handles the spy and arrow-fn cases. Added tests for both. Side note: calling new on a spy that wraps an actual class is still busted on main because .apply throws on a class, but that's not a whenCalledWith thing so I didn't touch it here.

Others: PR description updated, "Strict arity" label dropped, subMock typed as Mock with the casts gone.

@timkindberg timkindberg marked this pull request as ready for review May 8, 2026 15:57
@SimenB SimenB requested a review from jeysal May 9, 2026 07:37
@SimenB
Copy link
Copy Markdown
Member

SimenB commented May 9, 2026

Thanks @timkindberg, this is looking great! A quick look through shows me no gaps. There's no e2e tests, but I can't find those for our existing APIs either 😅 Maybe no explicit jest.resetAllMocks tests, but that's effectively covered by mockReset tests I believe?

I'll do a thorough review either tomorrow or next week 👍

Would love @jeysal's thoughts on this as well 🙂

Copy link
Copy Markdown

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

This PR introduces a first-class mockFn.whenCalledWith(...args) API in jest-mock to configure mock behavior per argument list (including asymmetric matchers), implemented via a per-mock dispatcher that routes calls to registered “sub-mocks”. It also moves the structural AsymmetricMatcher type into @jest/expect-utils and reuses jest-mock’s FunctionParameters type for expect’s call-argument typing.

Changes:

  • Add MockInstance#whenCalledWith(...args) with dispatcher-based routing and fallback handling in packages/jest-mock.
  • Add comprehensive behavior tests + type tests covering routing/precedence, reset semantics, spies/constructors, and TS constraints.
  • Export AsymmetricMatcher from @jest/expect-utils and update expect typings accordingly; document the new API.

Reviewed changes

Copilot reviewed 10 out of 11 changed files in this pull request and generated 4 comments.

Show a summary per file
File Description
yarn.lock Adds workspace dependency wiring for @jest/expect-utils used by jest-mock.
packages/jest-mock/tsconfig.json Adds TS project reference to expect-utils.
packages/jest-mock/src/index.ts Implements whenCalledWith, dispatcher routing, fallback tracking, and new exported typing helpers.
packages/jest-mock/src/tests/whenCalledWith.test.ts Adds behavior coverage for matching, precedence, spies/constructors, reset semantics, and withImplementation.
packages/jest-mock/package.json Declares @jest/expect-utils dependency.
packages/jest-mock/typetests/mock-functions.test.ts Adds typing assertions for whenCalledWith argument and return-type constraints.
packages/expect/src/types.ts Re-exports AsymmetricMatcher from @jest/expect-utils and imports FunctionParameters from jest-mock.
packages/expect-utils/src/types.ts Defines AsymmetricMatcher structural protocol type.
packages/expect-utils/src/index.ts Re-exports AsymmetricMatcher.
docs/MockFunctionAPI.md Documents the new whenCalledWith API, matching rules, and precedence.
CHANGELOG.md Adds feature entries for whenCalledWith and AsymmetricMatcher export.

Comment thread packages/jest-mock/src/index.ts
Comment thread docs/MockFunctionAPI.md Outdated
Comment thread CHANGELOG.md Outdated
Comment thread packages/jest-mock/src/__tests__/whenCalledWith.test.ts Outdated
@jeysal
Copy link
Copy Markdown
Collaborator

jeysal commented May 11, 2026

Fall through to the base impl — whatever mockImpl was set before the dispatcher took over. For a jest.fn() with no base impl this is undefined; for a spyOn it's the original-method bridge; for a user-provided mockImplementation set before whenCalledWith it's that fn.

"before the dispatcher took over"/"set before whenCalledWith" doesn't make sense to me here. I don't think it should matter if the mockImplementation was set before or after any non-matching whenCalledWiths. If no more onces and no whenCalledWith special cases apply, we fall through. It's those three things, in order. This may already be the case in how the code works, I'm glancing over that next. Just making sure we're on the same page.

E.g. this should work right? (Please educate me if you think it shouldn't, and why)

fn.whenCalledWith('x').mockReturnValue('X');
fn.whenCalledWith('y').mockReturnValue('Y');
fn.mockImplementation(() => 'fallback');
fn('y') // Y
fn('x') // X
fn('bla') // fallback

@jeysal
Copy link
Copy Markdown
Collaborator

jeysal commented May 11, 2026

Basically shouldn't we fully integrate fallback implementation and branches, rather than "switching back and forth" between modes?

@jeysal
Copy link
Copy Markdown
Collaborator

jeysal commented May 11, 2026

I can't really think of why you'd to whenCalledWith() and then mockImplementation() and not want the whenCalledWith() cases to take effect but instead the mockImplementation to take over completely.
I don't think we should be order dependent.

Sure you might want to remove the whenCalledWith() registrations after use at some point, but for that we have mockReset().

Comment thread docs/MockFunctionAPI.md Outdated
Comment thread docs/MockFunctionAPI.md Outdated
Comment thread docs/MockFunctionAPI.md
@timkindberg
Copy link
Copy Markdown
Contributor Author

ok, latest push handles a few things you raised...

big one was the @jeysal design point: mockImplementation (and mockReturnValue / mockResolvedValue / etc.) called after whenCalledWith no longer clobbers the branches. it sets the fall-through instead. so your example works now:

fn.whenCalledWith('x').mockReturnValue('X');
fn.whenCalledWith('y').mockReturnValue('Y');
fn.mockImplementation(() => 'fallback');
fn('y')   // 'Y'
fn('x')   // 'X'
fn('bla') // 'fallback'

mental model is just "branches override, otherwise the base impl wins, no matter what order." trade-off being that the only way to "exit whenCalledWith mode" is now mockReset(), which you'd already endorsed so I think we're good. pinned with 4 new tests including yours verbatim.

also pulled in iterableEquality for Copilot's catch. without it, equals() was treating Maps as plain objects with no enumerable keys, so different Maps were routing to the wrong branch. test was RED before the fix and pins Map + Set discrimination now.

docs got a cleanup pass. dropped "dispatcher" from the user-facing prose since it was leaking implementation terminology, split the old Composition section into "On spies" + "Resetting", and dropped the constructor paragraph from there because honestly it wasn't specific to whenCalledWith. also rewrote the equality paragraph to be honest about what we actually use (equals + iterableEquality, no custom testers from expect.extend(...)).

@SimenB one scope question for you while we're in this area: toHaveBeenCalledWith uses iterableEquality AND subsetEquality AND any user-registered testers from expect.extend(...). I've only pulled in iterableEquality. subsetEquality I'm fine to skip imo, but the user-tester gap is real... if someone does expect.extend({...with tester...}) their tester won't run against whenCalledWith matchers. wiring it up means reaching into expect's internal state from jest-mock which feels wrong, and I can't see a clean way to expose customTesters from expect without a dep cycle. happy to keep it minimal and just document the gap, but flagging in case you'd rather we chase full parity.

oh also... you asked about jest.resetAllMocks coverage earlier. added one test that confirms it clears whenCalledWith registrations. skipped e2e since there aren't any for the other APIs either.

Copy link
Copy Markdown
Member

@SimenB SimenB left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Image

ooops, forgot to submit 😅

/**
* A wrapper over `FunctionParametersInternal` which converts `never` evaluations to `Array<unknown>`.
*
* This is only necessary for Typescript versions prior to 5.3.
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

removing this should probably land on main separately from the new mock APIs

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It's only moved to support the new mock apis though, if we move it in it's own PR then it's just moved for no reason really. I think expect-utils is a better home for it though (like your other comment suggested).

Comment thread packages/jest-mock/src/index.ts Outdated
@SimenB
Copy link
Copy Markdown
Member

SimenB commented May 12, 2026

@SimenB one scope question for you while we're in this area: toHaveBeenCalledWith uses iterableEquality AND subsetEquality AND any user-registered testers from expect.extend(...). I've only pulled in iterableEquality. subsetEquality I'm fine to skip imo, but the user-tester gap is real... if someone does expect.extend({...with tester...}) their tester won't run against whenCalledWith matchers. wiring it up means reaching into expect's internal state from jest-mock which feels wrong, and I can't see a clean way to expose customTesters from expect without a dep cycle. happy to keep it minimal and just document the gap, but flagging in case you'd rather we chase full parity.

Oh, good question 🤔 I'm thinking it's fine as-is, but should be mentioned in the docs? With some vague "currently" phrasing so people can file issues if it's an actual gap for them?

@timkindberg
Copy link
Copy Markdown
Contributor Author

I address all the latest comments. Gently pushed back on @SimenB's question about having some changes in their own PR, only because they are directly caused by the needs of this PR.

Copy link
Copy Markdown
Collaborator

@jeysal jeysal left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I did one final pass through docs. After this IMO it's good to go.
I'll also push a few test cases I got from checking docs against tests w/ Codex

Comment thread docs/MockFunctionAPI.md Outdated
Comment thread docs/MockFunctionAPI.md
Comment thread docs/MockFunctionAPI.md Outdated
Comment thread docs/MockFunctionAPI.md Outdated
Comment thread docs/MockFunctionAPI.md Outdated
Comment thread docs/MockFunctionAPI.md Outdated
@jeysal
Copy link
Copy Markdown
Collaborator

jeysal commented May 12, 2026

I also pushed clearer focused withImplementation tests. I don't think the behavior of withImplementation combined with branches is ideal. But it is undocumented for now, and I think it matters so little that it's not worth investing time.

@jeysal
Copy link
Copy Markdown
Collaborator

jeysal commented May 12, 2026

Back to @timkindberg :) Thanks for your awesome work on this <3
With some docs cleanup this is good to go from my side now, let's wait @SimenB approval signal

@jeysal jeysal force-pushed the feat/when-called-with branch from 45f0693 to c6a50c8 Compare May 12, 2026 17:57
timkindberg and others added 15 commits May 12, 2026 15:38
… more cases

Extend the wildcard-vs-literal precedence tests with additional
once-persistent permutations (calls hitting the wildcard alone, both,
etc.) to lock in the dispatcher's behavior in every overlap shape.
WhenCalledWithRegistration.subMock is always a Mock — typing it as
Function meant every use site needed an `as Mock` cast. Also collapse
the docs' "Strict arity" + "Trailing undefined" bullets into one, since
they're not actually two separate rules.
…tion

Once whenCalledWith installed the dispatcher, getMockImplementation()
returned the dispatcher fn itself rather than whatever the user had
configured. Code that introspects this (custom matchers, debug
helpers) was getting an internal closure back. Surface the user's
underlying impl (the captured fallback) instead.
The dispatcher was using Reflect.construct on the chosen target when
the parent was invoked via `new`, but mockConstructor itself always
uses .apply for both call and new. The mismatch broke fall-through to
non-constructable targets — arrow-function user impls, spy bridges
wrapping method shorthands. Drop the Reflect.construct branch and just
.apply the target like mockConstructor does. Sub-mocks still record
the right `this` on their mock.instances since they're real Mocks.
`Mock<T>` already exposes a `new` construct signature, so the
`as new (arg: string) => unknown` casts on `Ctor` and `fn` are
redundant. SpiedFunction<T> is a narrower type that doesn't model
construction (even though the runtime object is constructable), so
the spy test still needs one targeted cast through `Mock<T>` — that's
the minimum we can express here.
If whenCalledWith re-armed the dispatcher inside a withImplementation
callback, the temp fn leaked into mockConfig.fallbackImpl and outlived
the scope — so non-matching calls after the scope returned the temp
value instead of the original fallback. Save fallbackImpl alongside
mockImpl on entry and restore on exit (sync and async paths), matching
how specificMockImpls is already handled.
…e routing

When the whenCalledWith dispatcher is installed, mockImplementation,
mockReturnValue, mockResolvedValue, etc. now write to fallbackImpl
instead of mockImpl. Previously they overwrote the dispatcher, which
silently disabled all whenCalledWith branches whenever the user
mixed the two APIs. Addresses @jeysal's design pushback that the
two should be order-independent.

User-facing model: branches override; otherwise the base
implementation wins, regardless of registration order.

Pinned with tests for mockImplementation / mockReturnValue /
mockResolvedValue after whenCalledWith, fall-through overrides
without disturbing branches, mockReset clearing both branches and
fall-through, and resetAllMocks clearing registrations.
Without iterableEquality, equals() treats Maps and Sets as plain
objects with no enumerable keys — so distinct Map([['a',1]]) and
Map([['b',2]]) compared as equal and routed calls to the wrong
branch. Pass iterableEquality as a custom tester (same approach
toHaveBeenCalledWith uses) so Map/Set arguments discriminate
correctly. Other expect-side custom testers (subsetEquality, user
extensions) intentionally not pulled in — see the PR discussion.
…fy equality

Three rounds of nits from jeysal + Copilot:

- Drop the internal "dispatcher" word from user-facing prose (jeysal).
  Replace with behavioral phrasing.
- Split "Composition" into "On spies" and "Resetting" subsections, and
  drop the constructor paragraph — not specific to whenCalledWith
  (jeysal).
- Rewrite the argument-matching paragraph to be honest about what we
  actually use (`equals` + `iterableEquality`) and that user-extended
  custom testers from `expect.extend(...)` don't apply (Copilot).
- Fix `sub-mock`s` backtick typo in the constructor test name (Copilot).
- Add PR link to changelog entries (Copilot).
…tyle

Surrounding f.X overrides use 0-1 short lines of comment. The 4-line
block was overkill for the codebase.
@SimenB feedback: asymmetric-matcher-aware parameter type machinery
shouldn't live in jest-mock since matchers aren't owned there. Move
FunctionParameters (and its private helpers DeepAsymmetricMatcher,
WithAsymmetricMatchers, FunctionParametersInternal) into
@jest/expect-utils alongside AsymmetricMatcher. Both jest-mock and
expect now import from the single home, breaking the cycle that
forced the type into jest-mock originally.

Also: hedge the docs note about expect.extend(...) custom testers
with "not currently" wording so users know to file issues if it bites.
Co-authored-by: Tim Seckinger <[email protected]>
- precedence table: "Base" / "Earliest-registered" / "Implementation
  set on the base" — consistency with the other rows
- add concrete code example demonstrating all four precedence rules
- add code example for the Resetting section
- expand the repeat-calls example to show queued onces + persistents
  on both branches, so the cross-branch drain is visible in code
@timkindberg timkindberg force-pushed the feat/when-called-with branch from 012c7d7 to 4063799 Compare May 12, 2026 19:51
@jeysal jeysal requested a review from SimenB May 12, 2026 20:36
@timkindberg
Copy link
Copy Markdown
Contributor Author

@SimenB how are you feeling about this? I'm more than happy to address anything else, or run it past additional code reviews. Thanks to you both for your help so far!

Copy link
Copy Markdown

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 10 out of 11 changed files in this pull request and generated 2 comments.

Comment thread packages/jest-mock/src/index.ts
Comment thread docs/MockFunctionAPI.md Outdated
Copy link
Copy Markdown
Member

@SimenB SimenB left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

pushed up a commit based on copilot's review. I don't spot anything myself, this looks awesome, thank you!

And thanks @jeysal for reviewing and helping getting this over the line ❤️ I remember us talking about an API like this in London when the original issue was filed in 2018 haha

Copy link
Copy Markdown
Collaborator

@jeysal jeysal left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ahahahaha yea that might be true, early 2019 it was

@SimenB
Copy link
Copy Markdown
Member

SimenB commented May 15, 2026

https://www.githubstatus.com/incidents/ctf7nxpq5jzn

But the branch was green before my push, and the unit test passes locally. Sooooo

@SimenB SimenB merged commit dc8dc98 into jestjs:main May 15, 2026
7 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Parameterised mock return values

4 participants