From c1e4bb595dec6dc55a64e6211594774b32a84690 Mon Sep 17 00:00:00 2001 From: Hiroshi Ogawa <4232207+hi-ogawa@users.noreply.github.com> Date: Wed, 10 Jun 2026 18:45:24 +0900 Subject: [PATCH 1/3] fix(spy): preserve class mock prototype methods Co-authored-by: Codex --- packages/spy/src/index.ts | 15 ++++++++++++ test/unit/test/mocking/vi-fn.test.ts | 32 +++++++++++++++++++++++++ test/unit/test/mocking/vi-spyOn.test.ts | 17 +++++++++++++ 3 files changed, 64 insertions(+) diff --git a/packages/spy/src/index.ts b/packages/spy/src/index.ts index 125724845c71..4a59f265ddb2 100644 --- a/packages/spy/src/index.ts +++ b/packages/spy/src/index.ts @@ -500,6 +500,21 @@ function createMock( try { if (new.target) { returnValue = Reflect.construct(implementation, args, new.target) + const implementationPrototype = (implementation as any).prototype + if ( + prototypeMembers.length === 0 + && new.target?.prototype + && implementationPrototype + && Object.getPrototypeOf(returnValue) === new.target.prototype + ) { + const { properties, descriptors } = getAllProperties(implementationPrototype) + for (const property of properties) { + if (property === 'constructor' || property in returnValue) { + continue + } + Object.defineProperty(returnValue, property, descriptors[property]!) + } + } // jest calls this before the implementation, but we have to resolve this _after_ // because we cannot do it before the `Reflect.construct` called the custom implementation. diff --git a/test/unit/test/mocking/vi-fn.test.ts b/test/unit/test/mocking/vi-fn.test.ts index 9ef0ed222023..5e07a25df60a 100644 --- a/test/unit/test/mocking/vi-fn.test.ts +++ b/test/unit/test/mocking/vi-fn.test.ts @@ -799,6 +799,38 @@ describe('vi.fn() implementations', () => { expect(Mock.mock.calls).toEqual([['test', 42]]) }) + test('vi.fn(class) exposes prototype methods on constructed instances', () => { + const Mock = vi.fn(class { + method() { + return 'mocked' + } + }) + + expect(new Mock().method()).toBe('mocked') + }) + + test('vi.fn(class) does not leak prototype methods from once implementations', () => { + const Mock = vi.fn() + .mockImplementationOnce(class { + first() { + return 'first' + } + }) + .mockImplementation(class { + second() { + return 'second' + } + }) + + const first = new Mock() + const second = new Mock() + + expect(first.first()).toBe('first') + expect(first.second).toBeUndefined() + expect(second.second()).toBe('second') + expect(second.first).toBeUndefined() + }) + 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 be42771f2bcf..19b10f367b8f 100644 --- a/test/unit/test/mocking/vi-spyOn.test.ts +++ b/test/unit/test/mocking/vi-spyOn.test.ts @@ -262,6 +262,23 @@ describe('vi.spyOn() state', () => { assertStateEmpty(state) }) + test('vi.spyOn() exposes mockImplementation class prototype methods', () => { + class OriginalClass { + method() { + return 'original' + } + } + const object = { Class: OriginalClass } + + vi.spyOn(object, 'Class').mockImplementation(class MockClass extends OriginalClass { + method() { + return 'mocked' + } + }) + + expect(new object.Class().method()).toBe('mocked') + }) + test('vi.spyOn() spies and tracks overridden async calls', async () => { const object = createObject() const mock = vi.spyOn(object, 'async') From 9edb0cfc59a2a1eb02181dfa98b74fe0c1646b37 Mon Sep 17 00:00:00 2001 From: Hiroshi Ogawa <4232207+hi-ogawa@users.noreply.github.com> Date: Wed, 10 Jun 2026 18:47:19 +0900 Subject: [PATCH 2/3] nit --- packages/spy/src/index.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/packages/spy/src/index.ts b/packages/spy/src/index.ts index 4a59f265ddb2..5c0ddc1e0d27 100644 --- a/packages/spy/src/index.ts +++ b/packages/spy/src/index.ts @@ -502,7 +502,9 @@ function createMock( returnValue = Reflect.construct(implementation, args, new.target) const implementationPrototype = (implementation as any).prototype if ( + // prototypeMembers is used by automocking case and we want to skip that case. prototypeMembers.length === 0 + // TODO: no idea what && new.target?.prototype && implementationPrototype && Object.getPrototypeOf(returnValue) === new.target.prototype From 686fa0c9a94db0e260d9b2e81e3c65b584c96ff3 Mon Sep 17 00:00:00 2001 From: Hiroshi Ogawa <4232207+hi-ogawa@users.noreply.github.com> Date: Wed, 10 Jun 2026 19:06:12 +0900 Subject: [PATCH 3/3] test: constructor returning value --- packages/spy/src/index.ts | 4 ++-- test/unit/test/mocking/vi-fn.test.ts | 18 ++++++++++++++++++ 2 files changed, 20 insertions(+), 2 deletions(-) diff --git a/packages/spy/src/index.ts b/packages/spy/src/index.ts index 5c0ddc1e0d27..318a36ba7500 100644 --- a/packages/spy/src/index.ts +++ b/packages/spy/src/index.ts @@ -504,9 +504,9 @@ function createMock( if ( // prototypeMembers is used by automocking case and we want to skip that case. prototypeMembers.length === 0 - // TODO: no idea what - && new.target?.prototype + && new.target.prototype && implementationPrototype + // skip when constructor returns value && Object.getPrototypeOf(returnValue) === new.target.prototype ) { const { properties, descriptors } = getAllProperties(implementationPrototype) diff --git a/test/unit/test/mocking/vi-fn.test.ts b/test/unit/test/mocking/vi-fn.test.ts index 5e07a25df60a..8b2905977eed 100644 --- a/test/unit/test/mocking/vi-fn.test.ts +++ b/test/unit/test/mocking/vi-fn.test.ts @@ -809,6 +809,24 @@ describe('vi.fn() implementations', () => { expect(new Mock().method()).toBe('mocked') }) + test('vi.fn(class) does not copy prototype methods when constructor returns an object', () => { + const returned = { custom: true } + const Mock = vi.fn(class { + constructor() { + return returned as any + } + + method() { + return 'mocked' + } + }) + + const instance = new Mock() + + expect(instance).toBe(returned) + expect(instance.method).toBeUndefined() + }) + test('vi.fn(class) does not leak prototype methods from once implementations', () => { const Mock = vi.fn() .mockImplementationOnce(class {