diff --git a/docs/api/mock.md b/docs/api/mock.md
index 8407a7024..964a33fac 100644
--- a/docs/api/mock.md
+++ b/docs/api/mock.md
@@ -166,6 +166,8 @@ mockFn.mock.calls[0][0] === 0 // true
mockFn.mock.calls[1][0] === 1 // true
```
+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.
+
## mockImplementationOnce
```ts
@@ -284,6 +286,8 @@ Does what [`mockClear`](#mockClear) does and resets the mock implementation. Thi
Note that resetting a mock from `vi.fn()` will set the implementation to an empty function that returns `undefined`.
Resetting a mock from `vi.fn(impl)` will reset the implementation to `impl`.
+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.
+
This is useful when you want to reset a mock to its original state.
```ts
diff --git a/docs/api/vi.md b/docs/api/vi.md
index 913251988..5e773eb2c 100644
--- a/docs/api/vi.md
+++ b/docs/api/vi.md
@@ -473,13 +473,18 @@ You can also pass down a class to `vi.fn`:
```ts
const Cart = vi.fn(class {
- get = () => 0
+ get() {
+ return 0
+ }
})
const cart = new Cart()
expect(Cart).toHaveBeenCalled()
+expect(cart.get()).toBe(0)
```
+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.
+
### vi.mockObject 3.2.0
```ts
@@ -614,6 +619,8 @@ const spy = vi.spyOn(cart, 'Apples')
If you provide an arrow function, you will get [` is not a constructor` error](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Errors/Not_a_constructor) when the mock is called.
+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.
+
::: tip
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:
diff --git a/docs/guide/migration.md b/docs/guide/migration.md
index 82951fe73..1d80d7388 100644
--- a/docs/guide/migration.md
+++ b/docs/guide/migration.md
@@ -217,6 +217,32 @@ In browser mode, mock metadata is serialized between Vitest and the test iframe.
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.
+### Class Mocks Keep Prototype Methods
+
+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).
+
+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:
+
+```ts
+class Dog {
+ speak() {
+ return 'bark!'
+ }
+}
+
+const MockedDog = vi.fn(Dog)
+const dog = new MockedDog()
+
+typeof dog.speak // was 'undefined', now 'function'
+dog instanceof Dog // was false, now true
+dog instanceof MockedDog // true, as before
+
+// the chain is visible on the mock itself
+Object.getPrototypeOf(MockedDog.prototype) // was Object.prototype, now Dog.prototype
+```
+
+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.
+
### Benchmarking API Rewrite
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.
diff --git a/docs/guide/mocking/classes.md b/docs/guide/mocking/classes.md
index 09f8b8cd7..a3a69002b 100644
--- a/docs/guide/mocking/classes.md
+++ b/docs/guide/mocking/classes.md
@@ -27,7 +27,12 @@ class Dog {
}
```
-We can re-create this class with `vi.fn` (or `vi.spyOn().mockImplementation()`):
+Notice that this class defines its members in two different ways, and the difference matters for mocking:
+
+- `greet` is a class field. The assignment runs during construction, so every instance gets its own copy of the function as its own property.
+- `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`.
+
+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:
```ts
const Dog = vi.fn(class {
@@ -93,6 +98,7 @@ import { feed } from '../src/feed.js'
const Dog = vi.fn(class {
feed = vi.fn()
+ isHungry = vi.fn(() => false)
})
test('can feed dogs', () => {
@@ -124,7 +130,72 @@ expect(Max.speak).not.toHaveBeenCalled()
expect(Max.greet).not.toHaveBeenCalled()
```
-We can reassign the return value for a specific instance:
+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:
+
+```ts
+class OriginalDog {
+ constructor(name) {
+ this.name = name
+ }
+
+ speak() {
+ return 'bark!'
+ }
+}
+
+const MockedDog = vi.fn(OriginalDog)
+const dog = new MockedDog('Cooper')
+
+dog.speak() // bark!
+dog instanceof MockedDog // true
+dog instanceof OriginalDog // true
+```
+
+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:
+
+```ts
+expect(dog.speak).toHaveBeenCalled()
+// TypeError: [Function speak] is not a spy or a call to a spy!
+```
+
+Since every instance finds `speak` on the prototype, you can mock it for all of them at once by assigning a mock there:
+
+```ts
+MockedDog.prototype.speak = vi.fn(() => 'woof!')
+
+const cooper = new MockedDog('Cooper')
+const max = new MockedDog('Max')
+
+cooper.speak() // woof!
+max.speak() // woof!
+
+// calls from both instances are recorded by the same mock
+expect(MockedDog.prototype.speak).toHaveBeenCalledTimes(2)
+// `mock.contexts` keeps the instance of every call
+expect(vi.mocked(MockedDog.prototype.speak).mock.contexts).toEqual([cooper, max])
+```
+
+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.
+
+::: warning
+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.
+:::
+
+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:
+
+```ts
+const cooper = new MockedDog('Cooper')
+const max = new MockedDog('Max')
+
+vi.spyOn(cooper, 'speak').mockReturnValue('meow!')
+
+cooper.speak() // meow!
+max.speak() // bark!, still the original method
+
+expect(cooper.speak).toHaveBeenCalledTimes(1)
+```
+
+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:
```ts
const dog = new Dog('Cooper')
@@ -138,7 +209,7 @@ vi.mocked(dog.speak).mockReturnValue('woof woof')
dog.speak() // woof woof
```
-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:
+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:
```ts
const dog = new Dog('Cooper')
diff --git a/packages/spy/src/index.ts b/packages/spy/src/index.ts
index 8030588ba..16bd610f8 100644
--- a/packages/spy/src/index.ts
+++ b/packages/spy/src/index.ts
@@ -71,6 +71,21 @@ export function createMockInstance(options: MockInstanceOption = {}): Mock {
+ if (!options.prototypeMembers?.length) {
+ reparentMockPrototype(
+ mock,
+ config.onceMockImplementations[0]
+ || config.mockImplementation
+ || originalImplementation,
+ )
+ }
+ }
+
Object.defineProperty(mock, 'mock', {
configurable: false,
enumerable: true,
@@ -80,11 +95,13 @@ export function createMockInstance(options: MockInstanceOption = {}): Mock {
config.mockImplementation = previousImplementation
config.onceMockImplementations = previousOnceImplementations
+ updateMockPrototype()
}
config.mockImplementation = implementation
config.onceMockImplementations = []
+ updateMockPrototype()
const returnValue = callback()
@@ -211,6 +230,7 @@ export function createMockInstance(options: MockInstanceOption = {}): Mock> = {
// to keep the name of the function intact
[name]: (function (this: any, ...args: any[]) {
@@ -491,7 +516,7 @@ function createMock(
|| prototypeConfig?.onceMockImplementations.shift()
|| prototypeConfig?.mockImplementation
|| original
- || function () {}
+ || noopImplementation
let returnValue
let thrownValue
@@ -499,6 +524,13 @@ function createMock(
try {
if (new.target) {
+ // the prototype chain is already prepared when the implementation
+ // is registered, but a consumed `mockImplementationOnce` can change
+ // which implementation this construction uses
+ if (prototypeMembers.length === 0) {
+ // eslint-disable-next-line ts/no-use-before-define
+ reparentMockPrototype(mock, implementation === noopImplementation ? undefined : implementation)
+ }
returnValue = Reflect.construct(implementation, args, new.target)
// jest calls this before the implementation, but we have to resolve this _after_
@@ -592,6 +624,26 @@ function createMock(
return mock
}
+// puts the implementation's prototype behind `mock.prototype` so instances
+// see prototype methods both during and after construction, while properties
+// assigned on `mock.prototype` still shadow them
+function reparentMockPrototype(
+ mock: Mock,
+ implementation: Procedure | Constructable | undefined,
+) {
+ const mockPrototype = mock.prototype
+ if (mockPrototype == null) {
+ return
+ }
+ // an implementation without a usable prototype (reset mock, arrow or bound
+ // function) reverts the chain to `Object.prototype`, the parent every mock
+ // is created with
+ const parent = (implementation as Constructable | undefined)?.prototype ?? Object.prototype
+ if (Object.getPrototypeOf(mockPrototype) !== parent) {
+ Object.setPrototypeOf(mockPrototype, parent)
+ }
+}
+
function registerCalls(args: unknown[], state: MockContext, prototypeState?: MockContext) {
state.calls.push(args)
prototypeState?.calls.push(args)
diff --git a/test/unit/test/jest-mock.test.ts b/test/unit/test/jest-mock.test.ts
index 566ae41ae..6ed76bbf3 100644
--- a/test/unit/test/jest-mock.test.ts
+++ b/test/unit/test/jest-mock.test.ts
@@ -617,7 +617,7 @@ describe('jest mock compat layer', () => {
Spy.mockImplementation(MockExample)
expect(new Spy()).toBeInstanceOf(Spy)
- expect(new Spy()).not.toBeInstanceOf(MockExample)
+ expect(new Spy()).toBeInstanceOf(MockExample)
const instance = new Spy()
expectTypeOf(instance).toEqualTypeOf()
diff --git a/test/unit/test/mocking/vi-fn.test.ts b/test/unit/test/mocking/vi-fn.test.ts
index 9ef0ed222..68d6b9235 100644
--- a/test/unit/test/mocking/vi-fn.test.ts
+++ b/test/unit/test/mocking/vi-fn.test.ts
@@ -799,6 +799,197 @@ describe('vi.fn() implementations', () => {
expect(Mock.mock.calls).toEqual([['test', 42]])
})
+ test('vi.fn(class) keeps prototype methods on instances', () => {
+ let methodInConstructor!: unknown
+ class Dog {
+ name: string
+ constructor(name: string) {
+ this.name = name
+ methodInConstructor = this.speak
+ }
+
+ speak() {
+ return `${this.name} barks!`
+ }
+ }
+
+ const MockDog = vi.fn(Dog)
+ const dog = new MockDog('Rex')
+
+ expect(methodInConstructor).toBeTypeOf('function')
+ expect(dog.speak()).toBe('Rex barks!')
+ expect(dog).toBeInstanceOf(MockDog)
+ expect(dog).toBeInstanceOf(Dog)
+ expect(Object.getPrototypeOf(dog)).toBe(MockDog.prototype)
+ expect(MockDog.mock.calls).toEqual([['Rex']])
+ expect(MockDog.mock.instances).toEqual([dog])
+ })
+
+ test('vi.fn(class) allows overriding methods on the mock prototype', () => {
+ class Dog {
+ speak() {
+ return 'bark'
+ }
+
+ fetch() {
+ return 'ball'
+ }
+ }
+
+ const MockDog = vi.fn(Dog)
+ MockDog.prototype.speak = () => 'meow'
+
+ const dog = new MockDog()
+ expect(dog.speak()).toBe('meow')
+ expect(dog.fetch()).toBe('ball')
+
+ // overrides assigned after construction are visible on existing instances
+ MockDog.prototype.fetch = () => 'stick'
+ expect(dog.fetch()).toBe('stick')
+ })
+
+ test('vi.fn(class) tracks prototype mock calls from every instance', () => {
+ class Dog {
+ speak() {
+ return 'bark'
+ }
+ }
+
+ const MockDog = vi.fn(Dog)
+ MockDog.prototype.speak = vi.fn(() => 'woof')
+
+ const cooper = new MockDog()
+ const max = new MockDog()
+
+ expect(cooper.speak()).toBe('woof')
+ expect(max.speak()).toBe('woof')
+
+ const speak = vi.mocked(MockDog.prototype.speak)
+ expect(speak).toHaveBeenCalledTimes(2)
+ expect(speak.mock.contexts).toEqual([cooper, max])
+ expect(speak.mock.instances).toEqual([cooper, max])
+ })
+
+ test('vi.fn(class) chains the prototype before the first construction', () => {
+ class ActualClass {
+ method() {
+ return 42
+ }
+ }
+ const Mock = vi.fn(ActualClass)
+
+ expect(Object.getPrototypeOf(Mock.prototype)).toBe(ActualClass.prototype)
+ expect(Object.create(Mock.prototype)).toBeInstanceOf(ActualClass)
+
+ class Another {
+ method() {
+ return 0
+ }
+ }
+ Mock.mockImplementation(Another)
+ expect(Object.getPrototypeOf(Mock.prototype)).toBe(Another.prototype)
+
+ Mock.mockReset()
+ expect(Object.getPrototypeOf(Mock.prototype)).toBe(ActualClass.prototype)
+ })
+
+ test('vi.fn() prototype chain is reverted when the mock is reset', () => {
+ class Actual {
+ method() {
+ return 42
+ }
+ }
+
+ const Mock = vi.fn()
+ Mock.mockImplementation(Actual)
+ expect(new Mock()).toBeInstanceOf(Actual)
+
+ Mock.mockReset()
+ expect(Object.getPrototypeOf(Mock.prototype)).toBe(Object.prototype)
+ expect(new Mock()).not.toBeInstanceOf(Actual)
+ })
+
+ test('vi.fn() prototype chain is reverted after a once implementation is consumed', () => {
+ class Actual {
+ method() {
+ return 42
+ }
+ }
+
+ const Mock = vi.fn()
+ Mock.mockImplementationOnce(Actual)
+
+ const first = new Mock()
+ expect(first).toBeInstanceOf(Actual)
+
+ const second = new Mock()
+ expect(second).not.toBeInstanceOf(Actual)
+ expect(Object.getPrototypeOf(Mock.prototype)).toBe(Object.prototype)
+ // the prototype is shared, so the first instance loses the chain as well
+ expect(first).not.toBeInstanceOf(Actual)
+ })
+
+ test('vi.fn() rejects an implementation that inherits from the mock itself', () => {
+ const Mock: any = vi.fn()
+ // constructing such a mock would call the implementation, whose super()
+ // re-enters the mock, so it could never be constructed anyway
+ expect(() => Mock.mockImplementation(class extends Mock {}))
+ .toThrowErrorMatchingInlineSnapshot(`[TypeError: Cyclic __proto__ value]`)
+ })
+
+ test('vi.fn() implementation can inherit from another mock', () => {
+ const Mock1: any = vi.fn()
+ const Mock2: any = vi.fn()
+ class Impl extends Mock2 {
+ method() {
+ return 42
+ }
+ }
+ Mock1.mockImplementation(Impl)
+
+ const instance = new Mock1()
+ expect(instance.method()).toBe(42)
+ expect(instance).toBeInstanceOf(Mock1)
+ expect(instance).toBeInstanceOf(Impl)
+ expect(instance).toBeInstanceOf(Mock2)
+
+ // super() re-enters Mock2, so both mocks track the construction
+ expect(Mock1.mock.calls).toEqual([[]])
+ expect(Mock2.mock.calls).toEqual([[]])
+ expect(Mock1.mock.instances).toEqual([instance])
+ expect(Mock2.mock.instances).toEqual([instance])
+ })
+
+ test('vi.fn(class) prototype follows the latest constructed implementation', () => {
+ class First {
+ first() {
+ return 'first'
+ }
+ }
+ class Second {
+ second() {
+ return 'second'
+ }
+ }
+
+ const Mock = vi.fn()
+ .mockImplementationOnce(First)
+ .mockImplementation(Second)
+
+ const first = new Mock()
+ expect(first.first()).toBe('first')
+ expect(first.second).toBeUndefined()
+
+ // `mock.prototype` is shared between instances, so constructing with
+ // another implementation points all instances at the new prototype
+ const second = new Mock()
+ expect(second.second()).toBe('second')
+ expect(first.second()).toBe('second')
+ expect(first.first).toBeUndefined()
+ expect(first).toBeInstanceOf(Mock)
+ expect(second).toBeInstanceOf(Mock)
+ })
+
test('vi.fn() with mockReturnValue throws when called with new', () => {
const Mock = vi.fn()
Mock.mockReturnValue(42)
diff --git a/test/unit/test/mocking/vi-spyOn.test.ts b/test/unit/test/mocking/vi-spyOn.test.ts
index cb01dffc7..cfb5bfdfd 100644
--- a/test/unit/test/mocking/vi-spyOn.test.ts
+++ b/test/unit/test/mocking/vi-spyOn.test.ts
@@ -295,6 +295,80 @@ describe('vi.spyOn() state', () => {
assertStateEmpty(state)
})
+ // reproduction of #10553
+ test('vi.spyOn() keeps prototype methods of a class implementation', () => {
+ class OriginalClass {
+ method() {
+ return 'original'
+ }
+ }
+
+ const myObj = { TestClass: OriginalClass }
+
+ const spy = vi.spyOn(myObj, 'TestClass').mockImplementation(
+ class MockClass extends OriginalClass {
+ method() {
+ return 'mocked'
+ }
+ },
+ )
+
+ const instance = new myObj.TestClass()
+
+ expect(instance.method()).toBe('mocked')
+ expect(instance).toBeInstanceOf(myObj.TestClass)
+ expect(instance).toBeInstanceOf(OriginalClass)
+ expect(spy.mock.calls).toEqual([[]])
+ expect(spy.mock.instances).toEqual([instance])
+
+ spy.mockRestore()
+ expect(myObj.TestClass).toBe(OriginalClass)
+ expect(new myObj.TestClass().method()).toBe('original')
+ })
+
+ test('vi.spyOn() chains the class prototype before the first construction', () => {
+ class OriginalClass {
+ method() {
+ return 'original'
+ }
+ }
+
+ const myObj = { TestClass: OriginalClass }
+ const spy = vi.spyOn(myObj, 'TestClass')
+
+ expect(Object.getPrototypeOf(myObj.TestClass.prototype)).toBe(OriginalClass.prototype)
+ expect(Object.create(myObj.TestClass.prototype)).toBeInstanceOf(OriginalClass)
+ expect(Object.create(myObj.TestClass.prototype)).toBeInstanceOf(myObj.TestClass)
+
+ spy.mockRestore()
+ })
+
+ test('vi.spyOn() keeps prototype methods when constructing the original class', () => {
+ let methodInConstructor!: unknown
+ class OriginalClass {
+ constructor() {
+ methodInConstructor = this.method
+ }
+
+ method() {
+ return 'original'
+ }
+ }
+
+ const myObj = { TestClass: OriginalClass }
+ const spy = vi.spyOn(myObj, 'TestClass')
+
+ const instance = new myObj.TestClass()
+
+ expect(methodInConstructor).toBeTypeOf('function')
+ expect(instance.method()).toBe('original')
+ expect(instance).toBeInstanceOf(myObj.TestClass)
+ expect(instance).toBeInstanceOf(OriginalClass)
+ expect(spy.mock.calls).toEqual([[]])
+
+ spy.mockRestore()
+ })
+
test('vi.spyOn() spies and tracks overridden async calls', async () => {
const object = createObject()
const mock = vi.spyOn(object, 'async')