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')