From 05b0521c3be20575513e1492df31c232adfa8819 Mon Sep 17 00:00:00 2001 From: Hiroshi Ogawa Date: Mon, 4 Dec 2023 22:14:22 +0900 Subject: [PATCH] fix(vitest): independently mock each instance's methods for mocked class (#4564) --- packages/vitest/src/runtime/mocker.ts | 31 ++++- test/core/src/mockedE.ts | 11 ++ .../test/mocked-class-restore-all.test.ts | 78 ++++++++++++ .../mocked-class-restore-explicit.test.ts | 86 +++++++++++++ test/core/test/mocked-class.test.ts | 115 ++++++++++++++++++ 5 files changed, 317 insertions(+), 4 deletions(-) create mode 100644 test/core/src/mockedE.ts create mode 100644 test/core/test/mocked-class-restore-all.test.ts create mode 100644 test/core/test/mocked-class-restore-explicit.test.ts create mode 100644 test/core/test/mocked-class.test.ts diff --git a/packages/vitest/src/runtime/mocker.ts b/packages/vitest/src/runtime/mocker.ts index 686a0ed9d428..5aa8c343c2e8 100644 --- a/packages/vitest/src/runtime/mocker.ts +++ b/packages/vitest/src/runtime/mocker.ts @@ -325,13 +325,36 @@ export class VitestMocker { continue if (isFunction) { - const spyModule = this.spyModule - if (!spyModule) + if (!this.spyModule) throw this.createError('[vitest] `spyModule` is not defined. This is Vitest error. Please open a new issue with reproduction.') - const mock = spyModule.spyOn(newContainer, property).mockImplementation(() => undefined) + const spyModule = this.spyModule + const primitives = this.primitives + function mockFunction(this: any) { + // detect constructor call and mock each instance's methods + // so that mock states between prototype/instances don't affect each other + // (jest reference https://github.com/jestjs/jest/blob/2c3d2409879952157433de215ae0eee5188a4384/packages/jest-mock/src/index.ts#L678-L691) + if (this instanceof newContainer[property]) { + for (const { key } of getAllMockableProperties(this, false, primitives)) { + const value = this[key] + const type = getType(value) + const isFunction = type.includes('Function') && typeof value === 'function' + if (isFunction) { + // mock and delegate calls to original prototype method, which should be also mocked already + const original = this[key] + const mock = spyModule.spyOn(this, key as string).mockImplementation(original) + mock.mockRestore = () => { + mock.mockReset() + mock.mockImplementation(original) + return mock + } + } + } + } + } + const mock = spyModule.spyOn(newContainer, property).mockImplementation(mockFunction) mock.mockRestore = () => { mock.mockReset() - mock.mockImplementation(() => undefined) + mock.mockImplementation(mockFunction) return mock } // tinyspy retains length, but jest doesn't. diff --git a/test/core/src/mockedE.ts b/test/core/src/mockedE.ts new file mode 100644 index 000000000000..99890956d185 --- /dev/null +++ b/test/core/src/mockedE.ts @@ -0,0 +1,11 @@ +export const symbolFn = Symbol.for('symbolFn') + +export class MockedE { + public testFn(arg: string) { + return arg.repeat(2) + } + + public [symbolFn](arg: string) { + return arg.repeat(2) + } +} diff --git a/test/core/test/mocked-class-restore-all.test.ts b/test/core/test/mocked-class-restore-all.test.ts new file mode 100644 index 000000000000..09bb1a83eb01 --- /dev/null +++ b/test/core/test/mocked-class-restore-all.test.ts @@ -0,0 +1,78 @@ +import { expect, test, vi } from 'vitest' + +import { MockedE } from '../src/mockedE' + +vi.mock('../src/mockedE') + +test(`mocked class are not affected by restoreAllMocks`, () => { + const instance0 = new MockedE() + expect(instance0.testFn('a')).toMatchInlineSnapshot(`undefined`) + expect(vi.mocked(instance0.testFn).mock.calls).toMatchInlineSnapshot(` + [ + [ + "a", + ], + ] + `) + expect(vi.mocked(MockedE.prototype.testFn).mock.calls).toMatchInlineSnapshot(` + [ + [ + "a", + ], + ] + `) + + vi.restoreAllMocks() + + // reset only history after restoreAllMocks + expect(instance0.testFn('b')).toMatchInlineSnapshot(`undefined`) + expect(vi.mocked(instance0.testFn).mock.calls).toMatchInlineSnapshot(` + [ + [ + "b", + ], + ] + `) + expect(vi.mocked(MockedE.prototype.testFn).mock.calls).toMatchInlineSnapshot(` + [ + [ + "b", + ], + ] + `) + + // mocked constructor is still effective after restoreAllMocks + const instance1 = new MockedE() + const instance2 = new MockedE() + expect(instance1).not.toBe(instance2) + expect(instance1.testFn).not.toBe(instance2.testFn) + expect(instance1.testFn).not.toBe(MockedE.prototype.testFn) + expect(vi.mocked(instance1.testFn).mock).not.toBe(vi.mocked(instance2.testFn).mock) + + expect(instance1.testFn('c')).toMatchInlineSnapshot(`undefined`) + expect(vi.mocked(instance0.testFn).mock.calls).toMatchInlineSnapshot(` + [ + [ + "b", + ], + ] + `) + expect(vi.mocked(instance1.testFn).mock.calls).toMatchInlineSnapshot(` + [ + [ + "c", + ], + ] + `) + expect(vi.mocked(instance2.testFn).mock.calls).toMatchInlineSnapshot(`[]`) + expect(vi.mocked(MockedE.prototype.testFn).mock.calls).toMatchInlineSnapshot(` + [ + [ + "b", + ], + [ + "c", + ], + ] + `) +}) diff --git a/test/core/test/mocked-class-restore-explicit.test.ts b/test/core/test/mocked-class-restore-explicit.test.ts new file mode 100644 index 000000000000..fc9d7d4e6a46 --- /dev/null +++ b/test/core/test/mocked-class-restore-explicit.test.ts @@ -0,0 +1,86 @@ +import { expect, test, vi } from 'vitest' + +import { MockedE } from '../src/mockedE' + +vi.mock('../src/mockedE') + +// this behavior looks odd but jest also doesn't seem to support this use case properly +test(`mocked class methods are not restorable by explicit mockRestore calls`, () => { + const instance1 = new MockedE() + + expect(instance1.testFn('a')).toMatchInlineSnapshot(`undefined`) + expect(vi.mocked(instance1.testFn).mock.calls).toMatchInlineSnapshot(` + [ + [ + "a", + ], + ] + `) + expect(vi.mocked(MockedE.prototype.testFn).mock.calls).toMatchInlineSnapshot(` + [ + [ + "a", + ], + ] + `) + + // restoring instance method + vi.mocked(instance1.testFn).mockRestore() + expect(vi.mocked(instance1.testFn).mock.calls).toMatchInlineSnapshot(`[]`) + expect(vi.mocked(MockedE.prototype.testFn).mock.calls).toMatchInlineSnapshot(` + [ + [ + "a", + ], + ] + `) + + expect(instance1.testFn('b')).toMatchInlineSnapshot(`undefined`) + expect(vi.mocked(instance1.testFn).mock.calls).toMatchInlineSnapshot(` + [ + [ + "b", + ], + ] + `) + expect(vi.mocked(MockedE.prototype.testFn).mock.calls).toMatchInlineSnapshot(` + [ + [ + "a", + ], + [ + "b", + ], + ] + `) + + // restoring prototype method + vi.mocked(MockedE.prototype.testFn).mockRestore() + expect(vi.mocked(instance1.testFn).mock.calls).toMatchInlineSnapshot(` + [ + [ + "b", + ], + ] + `) + expect(vi.mocked(MockedE.prototype.testFn).mock.calls).toMatchInlineSnapshot(`[]`) + + expect(instance1.testFn('c')).toMatchInlineSnapshot(`undefined`) + expect(vi.mocked(instance1.testFn).mock.calls).toMatchInlineSnapshot(` + [ + [ + "b", + ], + [ + "c", + ], + ] + `) + expect(vi.mocked(MockedE.prototype.testFn).mock.calls).toMatchInlineSnapshot(` + [ + [ + "c", + ], + ] + `) +}) diff --git a/test/core/test/mocked-class.test.ts b/test/core/test/mocked-class.test.ts new file mode 100644 index 000000000000..0eaa6b398791 --- /dev/null +++ b/test/core/test/mocked-class.test.ts @@ -0,0 +1,115 @@ +import { expect, test, vi } from 'vitest' + +import { MockedE, symbolFn } from '../src/mockedE' + +vi.mock('../src/mockedE') + +test(`each instance's methods of mocked class should have independent mock function state`, () => { + const instance1 = new MockedE() + const instance2 = new MockedE() + expect(instance1).not.toBe(instance2) + expect(instance1.testFn).not.toBe(instance2.testFn) + expect(instance1.testFn).not.toBe(MockedE.prototype.testFn) + expect(vi.mocked(instance1.testFn).mock).not.toBe(vi.mocked(instance2.testFn).mock) + + expect(instance1.testFn('a')).toMatchInlineSnapshot(`undefined`) + expect(instance1.testFn).toBeCalledTimes(1) + expect(instance2.testFn).toBeCalledTimes(0) + expect(MockedE.prototype.testFn).toBeCalledTimes(1) + expect(vi.mocked(instance1.testFn).mock.calls).toMatchInlineSnapshot(` + [ + [ + "a", + ], + ] + `) + expect(vi.mocked(MockedE.prototype.testFn).mock.calls).toMatchInlineSnapshot(` + [ + [ + "a", + ], + ] + `) + + expect(instance2.testFn('b')).toMatchInlineSnapshot(`undefined`) + expect(instance1.testFn).toBeCalledTimes(1) + expect(instance2.testFn).toBeCalledTimes(1) + expect(MockedE.prototype.testFn).toBeCalledTimes(2) + expect(vi.mocked(instance2.testFn).mock.calls).toMatchInlineSnapshot(` + [ + [ + "b", + ], + ] + `) + expect(vi.mocked(MockedE.prototype.testFn).mock.calls).toMatchInlineSnapshot(` + [ + [ + "a", + ], + [ + "b", + ], + ] + `) + + expect(instance1.testFn('c')).toMatchInlineSnapshot(`undefined`) + expect(instance1.testFn).toBeCalledTimes(2) + expect(instance2.testFn).toBeCalledTimes(1) + expect(MockedE.prototype.testFn).toBeCalledTimes(3) + expect(vi.mocked(instance1.testFn).mock.calls).toMatchInlineSnapshot(` + [ + [ + "a", + ], + [ + "c", + ], + ] + `) + expect(vi.mocked(instance2.testFn).mock.calls).toMatchInlineSnapshot(` + [ + [ + "b", + ], + ] + `) + expect(vi.mocked(MockedE.prototype.testFn).mock.calls).toMatchInlineSnapshot(` + [ + [ + "a", + ], + [ + "b", + ], + [ + "c", + ], + ] + `) + + // test same things for symbol key method + expect(instance1[symbolFn]).not.toBe(instance2[symbolFn]) + expect(instance1[symbolFn]).not.toBe(MockedE.prototype[symbolFn]) + expect(vi.mocked(instance1[symbolFn]).mock).not.toBe(vi.mocked(instance2[symbolFn]).mock) + + expect(instance1[symbolFn]('d')).toMatchInlineSnapshot(`undefined`) + expect(instance1[symbolFn]).toBeCalledTimes(1) + expect(instance2[symbolFn]).toBeCalledTimes(0) + expect(MockedE.prototype[symbolFn]).toBeCalledTimes(1) + expect(vi.mocked(instance1[symbolFn]).mock.calls).toMatchInlineSnapshot(` + [ + [ + "d", + ], + ] + `) + expect(vi.mocked(instance2[symbolFn]).mock.calls).toMatchInlineSnapshot(`[]`) + expect(vi.mocked(MockedE.prototype[symbolFn]).mock.calls).toMatchInlineSnapshot(` + [ + [ + "d", + ], + ] + `) +})