feat(jest-mock): add mock.whenCalledWith(...)#16053
Conversation
✅ Deploy Preview for jestjs ready!
To edit notification comments on pull requests, go to your Netlify project configuration. |
|
|
babel-jest
babel-plugin-jest-hoist
babel-preset-jest
create-jest
@jest/diff-sequences
expect
@jest/expect-utils
jest
jest-changed-files
jest-circus
jest-cli
jest-config
@jest/console
@jest/core
@jest/create-cache-key-function
jest-diff
jest-docblock
jest-each
@jest/environment
jest-environment-jsdom
@jest/environment-jsdom-abstract
jest-environment-node
@jest/expect
@jest/fake-timers
@jest/get-type
@jest/globals
jest-haste-map
jest-jasmine2
jest-leak-detector
jest-matcher-utils
jest-message-util
jest-mock
@jest/pattern
jest-phabricator
jest-regex-util
@jest/reporters
jest-resolve
jest-resolve-dependencies
jest-runner
jest-runtime
@jest/schemas
jest-snapshot
@jest/snapshot-utils
@jest/source-map
@jest/test-result
@jest/test-sequencer
@jest/transform
@jest/types
jest-util
jest-validate
jest-watcher
jest-worker
pretty-format
commit: |
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]>
timkindberg
left a comment
There was a problem hiding this comment.
Self-review after a tougher second pass. Surfacing four points worth design discussion before this lands — happy to act on whichever direction maintainers prefer.
|
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 |
|
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
|
|
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:
|
|
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 ( mockImplementation footgun: agreed. Reinstalled the dispatcher in place instead of resetting 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. |
|
Ok all 6 are in the latest commits.
Others: PR description updated, "Strict arity" label dropped, |
|
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 I'll do a thorough review either tomorrow or next week 👍 Would love @jeysal's thoughts on this as well 🙂 |
There was a problem hiding this comment.
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 inpackages/jest-mock. - Add comprehensive behavior tests + type tests covering routing/precedence, reset semantics, spies/constructors, and TS constraints.
- Export
AsymmetricMatcherfrom@jest/expect-utilsand updateexpecttypings 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. |
"before the dispatcher took over"/"set before whenCalledWith" doesn't make sense to me here. I don't think it should matter if the E.g. this should work right? (Please educate me if you think it shouldn't, and why) |
|
Basically shouldn't we fully integrate fallback implementation and branches, rather than "switching back and forth" between modes? |
|
I can't really think of why you'd to Sure you might want to remove the |
|
ok, latest push handles a few things you raised... big one was the @jeysal design point: 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 also pulled in 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 @SimenB one scope question for you while we're in this area: oh also... you asked about |
| /** | ||
| * A wrapper over `FunctionParametersInternal` which converts `never` evaluations to `Array<unknown>`. | ||
| * | ||
| * This is only necessary for Typescript versions prior to 5.3. |
There was a problem hiding this comment.
removing this should probably land on main separately from the new mock APIs
There was a problem hiding this comment.
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).
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? |
|
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. |
jeysal
left a comment
There was a problem hiding this comment.
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
|
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. |
|
Back to @timkindberg :) Thanks for your awesome work on this <3 |
45f0693 to
c6a50c8
Compare
… 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
012c7d7 to
4063799
Compare
|
@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! |
SimenB
left a comment
There was a problem hiding this comment.
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
jeysal
left a comment
There was a problem hiding this comment.
Ahahahaha yea that might be true, early 2019 it was
|
https://www.githubstatus.com/incidents/ctf7nxpq5jzn But the branch was green before my push, and the unit test passes locally. Sooooo |

Summary
Adds
mockFn.whenCalledWith(...args)to Jest core so users can configure return values per argument list without writing branching logic insidemockImplementation. 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 currentmainand addresses the*Once/withImplementationcleanup behavior the prior PR documented as surprising, plus the cross-branch*Onceexhaustion case raised in review by @jeysal. Several follow-up rounds with @SimenB tightened up the type-erasure leaks andwithImplementationinteraction (see "Review feedback" below).Design
A single dispatcher is installed in
mockConfig.mockImplon the firstwhenCalledWithcall. The dispatcher walksmockConfig.whenCalledWithRegistrations(a parallel array of{matchers, subMock}records) on every call and routes per the precedence rules below. Each sub-mock is a realMock<T>from_makeComponent({type: 'function'}), somockReturnValue,mockReturnValueOnce,mockResolvedValue, etc. work on it identically to ajest.fn().mockReset/mockClear/mockRestorework without modification because the dispatcher lives inmockConfig.mockImpland the registrations live in the sameMockFunctionConfigthey already wipe.Call-time precedence
*Oncequeue wins for the call that consumes it.fn.mockImplementationOnce/mockReturnValueOnce/mockResolvedValueOnce/mockRejectedValueOnceare the most explicit one-shot overrides and fire next regardless of whether the call's args match a registered matcher. Once the queue drains, normalwhenCalledWithrouting resumes.*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.mockImplwas set before the dispatcher took over. For ajest.fn()with no base impl this isundefined; for aspyOnit's the original-method bridge; for a user-providedmockImplementationset beforewhenCalledWithit's that fn.Repeat calls return distinct branches
Each
whenCalledWith(...)call returns a fresh sub-mock — there's no "same-matchers merge". TwowhenCalledWith('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:Re-arming
If the user calls
mockImplementation/mockReturnValue/etc. afterwhenCalledWith, that overwrites the dispatcher inmockConfig.mockImpl. The nextwhenCalledWithdetects 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
equalsfrom@jest/expect-utils— same machinerytoHaveBeenCalledWithuses. Asymmetric matchers (expect.any(...),expect.objectContaining(...)) and the trailing-undefinedarity quirk all behave consistently with existing Jest semantics.Constructor calls
new fn(args)matches the same way asfn(args). The dispatcher dispatches every target through.apply(matched branch or fallback), mirroringmockConstructor's own handling ofnew— so non-constructable fallbacks (arrow-fn user impls, method-shorthand spy bridges) don't blow up. Sub-mocks are realMocks and still recordthison their ownmock.instances.Reset semantics
fn.mockReset()cascades to every sub-mock returned bywhenCalledWith(...)— 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).withImplementationinteractionfn.withImplementation(temp, callback)saves and restoresfallbackImplalongsidemockImplandspecificMockImpls. IfwhenCalledWithre-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 bytoHaveBeenCalledWith) into thewhenCalledWithsignature sofn.whenCalledWith(expect.any(Number))typechecks against typed mocks. ReturnsMock<T>(notthis) since the call hands back a child sub-mock, not the parent. TheAsymmetricMatcherstructural protocol type was hoisted frompackages/expect/src/types.tsdown to@jest/expect-utils(re-exported fromexpectfor backward compat — no breaking change).FunctionParametersand its private helpers stay injest-mockto avoid expanding@jest/expect-utils's public surface for one consumer;expect'sMockParametersimports it back fromjest-mock.Review feedback addressed
*Onceexhaustion ordering when overlapping matchers were registered out of order.getMockImplementation;mockImplementationbetween twowhenCalledWithcalls clobbering prior registrations;Reflect.constructfailing on non-constructable fallbacks;fallbackImplcorruption insidewithImplementation;whenCalledWith(...): thismistyped vs. the actualMock<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 matcherexpect.allArgs(fn)— whole-arg-list matchermock.defaultReturnValue(x)— explicit bottom-of-stack default forjest-whenmigration parityexpectCalledWith/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
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+withImplementationinteractions,withImplementationsave/restore of fallback, sub-mock reset granularity + parent-reset cascade, parent-level*Onceprecedence,getMockImplementationdoesn't leak the dispatcher, dispatcher re-arm preserves prior registrations, constructor (new fn()) routing + sub-mockmock.instancesrecording + arrow-fn / spy-bridge fall-through, spy integration,thiscontext, call recording, null/symbol identity, arity rules.packages/jest-mock/__typetests__/mock-functions.test.ts—FunctionParameters<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.