Skip to content

Commit 6093d76

Browse files
authored
fix(spy)!: preserve class mock prototype methods on instances (#10910)
1 parent 61c4b80 commit 6093d76

8 files changed

Lines changed: 431 additions & 6 deletions

File tree

docs/api/mock.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -166,6 +166,8 @@ mockFn.mock.calls[0][0] === 0 // true
166166
mockFn.mock.calls[1][0] === 1 // true
167167
```
168168

169+
If the implementation is a class, the mock's `prototype` is re-pointed to the implementation's prototype, so constructed instances see its prototype methods and pass `instanceof` checks against it. See [Mocking Classes](/guide/mocking/classes) for details.
170+
169171
## mockImplementationOnce
170172

171173
```ts
@@ -284,6 +286,8 @@ Does what [`mockClear`](#mockClear) does and resets the mock implementation. Thi
284286
Note that resetting a mock from `vi.fn()` will set the implementation to an empty function that returns `undefined`.
285287
Resetting a mock from `vi.fn(impl)` will reset the implementation to `impl`.
286288

289+
The mock's `prototype` chain follows along: it reverts to the original class for `vi.fn(impl)` and `vi.spyOn()`, and to a plain object for `vi.fn()`, so instances constructed after the reset no longer pass `instanceof` checks against a previously set class implementation.
290+
287291
This is useful when you want to reset a mock to its original state.
288292

289293
```ts

docs/api/vi.md

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -473,13 +473,18 @@ You can also pass down a class to `vi.fn`:
473473

474474
```ts
475475
const Cart = vi.fn(class {
476-
get = () => 0
476+
get() {
477+
return 0
478+
}
477479
})
478480

479481
const cart = new Cart()
480482
expect(Cart).toHaveBeenCalled()
483+
expect(cart.get()).toBe(0)
481484
```
482485

486+
Instances keep the prototype chain of the implementation class, so its prototype methods are available on instances, and `instanceof` checks against the implementation class pass. See [Mocking Classes](/guide/mocking/classes) for details.
487+
483488
### vi.mockObject <Version>3.2.0</Version>
484489

485490
```ts
@@ -614,6 +619,8 @@ const spy = vi.spyOn(cart, 'Apples')
614619
615620
If you provide an arrow function, you will get [`<anonymous> is not a constructor` error](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Errors/Not_a_constructor) when the mock is called.
616621
622+
With a class implementation, instances keep the prototype chain of that class: prototype methods like `getApples` are available on instances, and `instanceof` checks against the implementation class pass. See [Mocking Classes](/guide/mocking/classes) for details.
623+
617624
::: tip
618625
In environments that support [Explicit Resource Management](https://github.com/tc39/proposal-explicit-resource-management), you can use `using` instead of `const` to automatically call `mockRestore` on any mocked function when the containing block is exited. This is especially useful for spied methods:
619626

docs/guide/migration.md

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -217,6 +217,32 @@ In browser mode, mock metadata is serialized between Vitest and the test iframe.
217217

218218
Automocks are now restored as automocks. If a browser test relied on the original implementation running through an automocked module, its exports now return `undefined` by default. Pass [`{ spy: true }`](/api/vi#vi-mock) to keep calling the real implementation while still tracking calls, or provide a factory with the behavior you need.
219219

220+
### Class Mocks Keep Prototype Methods
221+
222+
Instances created from a class mock previously inherited from the mock's own empty `prototype`. Methods defined with the regular class syntax were `undefined` on instances, even inside the constructor, and `instanceof` checks against the implementation class failed. This affected [`vi.fn(Dog)`](/api/vi#vi-fn), `vi.spyOn(obj, 'Dog')` with or without a mock implementation, and [`.mockImplementation(class ...)`](/api/mock#mockimplementation).
223+
224+
The mock's `prototype` is now chained to the implementation's prototype as soon as the implementation is set, and kept in sync when it changes, so instances behave like instances of the implementation class:
225+
226+
```ts
227+
class Dog {
228+
speak() {
229+
return 'bark!'
230+
}
231+
}
232+
233+
const MockedDog = vi.fn(Dog)
234+
const dog = new MockedDog()
235+
236+
typeof dog.speak // was 'undefined', now 'function'
237+
dog instanceof Dog // was false, now true
238+
dog instanceof MockedDog // true, as before
239+
240+
// the chain is visible on the mock itself
241+
Object.getPrototypeOf(MockedDog.prototype) // was Object.prototype, now Dog.prototype
242+
```
243+
244+
Overriding methods on the mock's `prototype` still works and shadows the implementation. [`mockReset`](/api/mock#mockreset) reverts the chain together with the implementation: back to the original class for `vi.fn(Dog)` and `vi.spyOn()`, and to a plain object for `vi.fn()`. See [Mocking Classes](/guide/mocking/classes) for details.
245+
220246
### Benchmarking API Rewrite
221247

222248
The benchmarking API has been rewritten. `bench` is no longer a top-level import from `vitest`; it is a [test-context fixture](/guide/test-context#bench) accessed from inside a regular `test()`. See the [Benchmarking guide](/guide/benchmarking) for the new API.

docs/guide/mocking/classes.md

Lines changed: 74 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -27,7 +27,12 @@ class Dog {
2727
}
2828
```
2929

30-
We can re-create this class with `vi.fn` (or `vi.spyOn().mockImplementation()`):
30+
Notice that this class defines its members in two different ways, and the difference matters for mocking:
31+
32+
- `greet` is a class field. The assignment runs during construction, so every instance gets its own copy of the function as its own property.
33+
- `speak`, `isHungry`, and `feed` are prototype methods. They are created once and stored on `Dog.prototype`, an object that all instances share. An instance doesn't have a `speak` property of its own: when you call `dog.speak()`, JavaScript doesn't find `speak` on the instance and continues looking on `Dog.prototype`. Every instance finds the same function there, so `dog.speak === Dog.prototype.speak` is `true`.
34+
35+
We can re-create this class with `vi.fn` (or `vi.spyOn().mockImplementation()`). By defining every method as a class field, each instance gets its own separate mock, which allows checking calls on a single instance:
3136

3237
```ts
3338
const Dog = vi.fn(class {
@@ -93,6 +98,7 @@ import { feed } from '../src/feed.js'
9398

9499
const Dog = vi.fn(class {
95100
feed = vi.fn()
101+
isHungry = vi.fn(() => false)
96102
})
97103

98104
test('can feed dogs', () => {
@@ -124,7 +130,72 @@ expect(Max.speak).not.toHaveBeenCalled()
124130
expect(Max.greet).not.toHaveBeenCalled()
125131
```
126132

127-
We can reassign the return value for a specific instance:
133+
You don't have to redefine every method as a class field. Instances keep the prototype chain of the class you pass to `vi.fn`, so prototype methods stay available on instances, both during and after construction, and instances pass `instanceof` checks against that class:
134+
135+
```ts
136+
class OriginalDog {
137+
constructor(name) {
138+
this.name = name
139+
}
140+
141+
speak() {
142+
return 'bark!'
143+
}
144+
}
145+
146+
const MockedDog = vi.fn(OriginalDog)
147+
const dog = new MockedDog('Cooper')
148+
149+
dog.speak() // bark!
150+
dog instanceof MockedDog // true
151+
dog instanceof OriginalDog // true
152+
```
153+
154+
Note that nothing is mocked in this example. Unlike the `speak = vi.fn()` field in the `Dog` example above, the instance doesn't receive its own mock function. `dog.speak` is found through the prototype chain and refers to the original class method (`dog.speak === MockedDog.prototype.speak`), so call assertions throw:
155+
156+
```ts
157+
expect(dog.speak).toHaveBeenCalled()
158+
// TypeError: [Function speak] is not a spy or a call to a spy!
159+
```
160+
161+
Since every instance finds `speak` on the prototype, you can mock it for all of them at once by assigning a mock there:
162+
163+
```ts
164+
MockedDog.prototype.speak = vi.fn(() => 'woof!')
165+
166+
const cooper = new MockedDog('Cooper')
167+
const max = new MockedDog('Max')
168+
169+
cooper.speak() // woof!
170+
max.speak() // woof!
171+
172+
// calls from both instances are recorded by the same mock
173+
expect(MockedDog.prototype.speak).toHaveBeenCalledTimes(2)
174+
// `mock.contexts` keeps the instance of every call
175+
expect(vi.mocked(MockedDog.prototype.speak).mock.contexts).toEqual([cooper, max])
176+
```
177+
178+
Assigning on `MockedDog.prototype` instead of `OriginalDog.prototype` keeps the original class untouched: the lookup order is `instance``MockedDog.prototype``OriginalDog.prototype`, so the assigned function shadows the original method. Because instances look the method up on every call rather than keeping a copy, the mock is visible to all of them, even those created before the assignment. The trade-off is that they also share a single call history, unlike class fields, which give every instance its own mock.
179+
180+
::: warning
181+
The mock's `prototype` always follows the current implementation: it is re-pointed when you set a new implementation, when a queued `mockImplementationOnce` class is constructed, and when the mock is reset. If a single mock uses different class implementations, instances created by earlier implementations lose access to their prototype methods once a newer implementation takes over. Own properties assigned in the constructor or via class fields are not affected.
182+
:::
183+
184+
If you want to mock the method of one instance only, use [`vi.spyOn`](/api/vi#vi-spyon). It defines the mock directly on that instance, shadowing the prototype method just for it:
185+
186+
```ts
187+
const cooper = new MockedDog('Cooper')
188+
const max = new MockedDog('Max')
189+
190+
vi.spyOn(cooper, 'speak').mockReturnValue('meow!')
191+
192+
cooper.speak() // meow!
193+
max.speak() // bark!, still the original method
194+
195+
expect(cooper.speak).toHaveBeenCalledTimes(1)
196+
```
197+
198+
When methods are defined as class fields, like in the mocked `Dog` class at the top of this page, every instance already has its own mock, so you can reassign the return value for a specific instance directly:
128199

129200
```ts
130201
const dog = new Dog('Cooper')
@@ -138,7 +209,7 @@ vi.mocked(dog.speak).mockReturnValue('woof woof')
138209
dog.speak() // woof woof
139210
```
140211

141-
To mock the property, we can use the `vi.spyOn(dog, 'name', 'get')` method. This makes it possible to use spy assertions on the mocked property:
212+
To mock a non-function property, like `name`, we can use the `vi.spyOn(dog, 'name', 'get')` method. This makes it possible to use spy assertions on the mocked property:
142213

143214
```ts
144215
const dog = new Dog('Cooper')

packages/spy/src/index.ts

Lines changed: 53 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -71,6 +71,21 @@ export function createMockInstance(options: MockInstanceOption = {}): Mock<Proce
7171
return config.onceMockImplementations[0] || config.mockImplementation
7272
}
7373

74+
// keep the prototype chain in sync with the implementation the next
75+
// construction will use, so it is correct before any `new` call.
76+
// automocked classes are skipped: their methods are pre-mocked
77+
// on `mock.prototype`
78+
const updateMockPrototype = () => {
79+
if (!options.prototypeMembers?.length) {
80+
reparentMockPrototype(
81+
mock,
82+
config.onceMockImplementations[0]
83+
|| config.mockImplementation
84+
|| originalImplementation,
85+
)
86+
}
87+
}
88+
7489
Object.defineProperty(mock, 'mock', {
7590
configurable: false,
7691
enumerable: true,
@@ -80,11 +95,13 @@ export function createMockInstance(options: MockInstanceOption = {}): Mock<Proce
8095

8196
mock.mockImplementation = function mockImplementation(implementation) {
8297
config.mockImplementation = implementation
98+
updateMockPrototype()
8399
return mock
84100
}
85101

86102
mock.mockImplementationOnce = function mockImplementationOnce(implementation) {
87103
config.onceMockImplementations.push(implementation)
104+
updateMockPrototype()
88105
return mock
89106
}
90107

@@ -95,10 +112,12 @@ export function createMockInstance(options: MockInstanceOption = {}): Mock<Proce
95112
const reset = () => {
96113
config.mockImplementation = previousImplementation
97114
config.onceMockImplementations = previousOnceImplementations
115+
updateMockPrototype()
98116
}
99117

100118
config.mockImplementation = implementation
101119
config.onceMockImplementations = []
120+
updateMockPrototype()
102121

103122
const returnValue = callback()
104123

@@ -211,6 +230,7 @@ export function createMockInstance(options: MockInstanceOption = {}): Mock<Proce
211230
: undefined
212231
config.mockName = resetToMockName ? (mock.name || 'vi.fn()') : 'vi.fn()'
213232
config.onceMockImplementations = []
233+
updateMockPrototype()
214234
return mock
215235
}
216236

@@ -237,6 +257,10 @@ export function createMockInstance(options: MockInstanceOption = {}): Mock<Proce
237257
if (mockImplementation) {
238258
mock.mockImplementation(mockImplementation)
239259
}
260+
else {
261+
// vi.spyOn() has no mock implementation, chain the original one
262+
updateMockPrototype()
263+
}
240264

241265
return mock
242266
}
@@ -462,6 +486,7 @@ function createMock(
462486
const original = config.mockOriginal // init with vi.spyOn(obj, 'Klass')
463487
const pseudoOriginal = mockImplementation // init with vi.fn(Klass)
464488
const name = (mockName || original?.name || 'Mock') as string
489+
const noopImplementation = function () {}
465490
const namedObject: Record<string, Mock<Procedure | Constructable>> = {
466491
// to keep the name of the function intact
467492
[name]: (function (this: any, ...args: any[]) {
@@ -491,14 +516,21 @@ function createMock(
491516
|| prototypeConfig?.onceMockImplementations.shift()
492517
|| prototypeConfig?.mockImplementation
493518
|| original
494-
|| function () {}
519+
|| noopImplementation
495520

496521
let returnValue
497522
let thrownValue
498523
let didThrow = false
499524

500525
try {
501526
if (new.target) {
527+
// the prototype chain is already prepared when the implementation
528+
// is registered, but a consumed `mockImplementationOnce` can change
529+
// which implementation this construction uses
530+
if (prototypeMembers.length === 0) {
531+
// eslint-disable-next-line ts/no-use-before-define
532+
reparentMockPrototype(mock, implementation === noopImplementation ? undefined : implementation)
533+
}
502534
returnValue = Reflect.construct(implementation, args, new.target)
503535

504536
// jest calls this before the implementation, but we have to resolve this _after_
@@ -592,6 +624,26 @@ function createMock(
592624
return mock
593625
}
594626

627+
// puts the implementation's prototype behind `mock.prototype` so instances
628+
// see prototype methods both during and after construction, while properties
629+
// assigned on `mock.prototype` still shadow them
630+
function reparentMockPrototype(
631+
mock: Mock<Procedure | Constructable>,
632+
implementation: Procedure | Constructable | undefined,
633+
) {
634+
const mockPrototype = mock.prototype
635+
if (mockPrototype == null) {
636+
return
637+
}
638+
// an implementation without a usable prototype (reset mock, arrow or bound
639+
// function) reverts the chain to `Object.prototype`, the parent every mock
640+
// is created with
641+
const parent = (implementation as Constructable | undefined)?.prototype ?? Object.prototype
642+
if (Object.getPrototypeOf(mockPrototype) !== parent) {
643+
Object.setPrototypeOf(mockPrototype, parent)
644+
}
645+
}
646+
595647
function registerCalls(args: unknown[], state: MockContext, prototypeState?: MockContext) {
596648
state.calls.push(args)
597649
prototypeState?.calls.push(args)

test/unit/test/jest-mock.test.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -617,7 +617,7 @@ describe('jest mock compat layer', () => {
617617
Spy.mockImplementation(MockExample)
618618

619619
expect(new Spy()).toBeInstanceOf(Spy)
620-
expect(new Spy()).not.toBeInstanceOf(MockExample)
620+
expect(new Spy()).toBeInstanceOf(MockExample)
621621

622622
const instance = new Spy()
623623
expectTypeOf(instance).toEqualTypeOf<Example>()

0 commit comments

Comments
 (0)
Sponsor
SponsoredKunjungi sekarang
Promo