diff --git a/docs/api/mock.md b/docs/api/mock.md index ccdefd837..860e2c0d4 100644 --- a/docs/api/mock.md +++ b/docs/api/mock.md @@ -50,6 +50,47 @@ fn.length // == 2 The custom function implementation in the types below is marked with a generic ``. ::: +::: warning Class Support {#class-support} +Shorthand methods like `mockReturnValue`, `mockReturnValueOnce`, `mockResolvedValue` and others cannot be used on a mocked class. Class constructors have [unintuitive behaviour](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Classes/constructor) regarding the return value: + +```ts {2,7} +const CorrectDogClass = vi.fn(class { + constructor(public name: string) {} +}) + +const IncorrectDogClass = vi.fn(class { + constructor(public name: string) { + return { name } + } +}) + +const Marti = new CorrectDogClass('Marti') +const Newt = new IncorrectDogClass('Newt') + +Marti instanceof CorrectDogClass // ✅ true +Newt instanceof IncorrectDogClass // ❌ false! +``` + +Even though the shapes are the same, the _return value_ from the constructor is assigned to `Newt`, which is a plain object, not an instance of a mock. Vitest guards you against this behaviour in shorthand methods (but not in `mockImplementation`!) and throws an error instead. + +If you need to mock constructed instance of a class, consider using the `class` syntax with `mockImplementation` instead: + +```ts +mock.mockReturnValue({ hello: () => 'world' }) // [!code --] +mock.mockImplementation(class { hello = () => 'world' }) // [!code ++] +``` + +If you need to test the behaviour where this is a valid use case, you can use `mockImplementation` with a `constructor`: + +```ts +mock.mockImplementation(class { + constructor(name: string) { + return { name } + } +}) +``` +::: + ## getMockImplementation ```ts diff --git a/eslint.config.js b/eslint.config.js index bf294817a..08519157a 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -107,6 +107,7 @@ export default antfu( `**/*.md/${GLOB_SRC}`, ], rules: { + 'prefer-arrow-callback': 'off', 'perfectionist/sort-imports': 'off', 'style/max-statements-per-line': 'off', 'import/newline-after-import': 'off', diff --git a/packages/spy/src/index.ts b/packages/spy/src/index.ts index 623f5f7db..0b20accc9 100644 --- a/packages/spy/src/index.ts +++ b/packages/spy/src/index.ts @@ -121,27 +121,63 @@ export function createMockInstance(options: MockInstanceOption = {}): Mock value) + return mock.mockImplementation(function () { + if (new.target) { + throwConstructorError('mockReturnValue') + } + + return value + }) } mock.mockReturnValueOnce = function mockReturnValueOnce(value) { - return mock.mockImplementationOnce(() => value) + return mock.mockImplementationOnce(function () { + if (new.target) { + throwConstructorError('mockReturnValueOnce') + } + + return value + }) } mock.mockResolvedValue = function mockResolvedValue(value) { - return mock.mockImplementation(() => Promise.resolve(value)) + return mock.mockImplementation(function () { + if (new.target) { + throwConstructorError('mockResolvedValue') + } + + return Promise.resolve(value) + }) } mock.mockResolvedValueOnce = function mockResolvedValueOnce(value) { - return mock.mockImplementationOnce(() => Promise.resolve(value)) + return mock.mockImplementationOnce(function () { + if (new.target) { + throwConstructorError('mockResolvedValueOnce') + } + + return Promise.resolve(value) + }) } mock.mockRejectedValue = function mockRejectedValue(value) { - return mock.mockImplementation(() => Promise.reject(value)) + return mock.mockImplementation(function () { + if (new.target) { + throwConstructorError('mockRejectedValue') + } + + return Promise.reject(value) + }) } mock.mockRejectedValueOnce = function mockRejectedValueOnce(value) { - return mock.mockImplementationOnce(() => Promise.reject(value)) + return mock.mockImplementationOnce(function () { + if (new.target) { + throwConstructorError('mockRejectedValueOnce') + } + + return Promise.reject(value) + }) } mock.mockClear = function mockClear() { @@ -644,6 +680,12 @@ export function resetAllMocks(): void { REGISTERED_MOCKS.forEach(mock => mock.mockReset()) } +function throwConstructorError(shorthand: string): never { + throw new TypeError( + `Cannot use \`${shorthand}\` when called with \`new\`. Use \`mockImplementation\` with a \`class\` keyword instead. See https://vitest.dev/api/mock#class-support for more information.`, + ) +} + export type { Constructable, MaybeMocked, diff --git a/packages/spy/src/types.ts b/packages/spy/src/types.ts index fad6e7ae0..f7b91c4f9 100644 --- a/packages/spy/src/types.ts +++ b/packages/spy/src/types.ts @@ -47,7 +47,7 @@ export type MockParameters = T extends Cons ? Parameters : never export type MockReturnType = T extends Constructable - ? void + ? InstanceType : T extends Procedure ? ReturnType : never diff --git a/test/core/test/mocking/vi-fn.test-d.ts b/test/core/test/mocking/vi-fn.test-d.ts index da996bfe1..97b35ebd2 100644 --- a/test/core/test/mocking/vi-fn.test-d.ts +++ b/test/core/test/mocking/vi-fn.test-d.ts @@ -17,11 +17,11 @@ test('spy.mock when implementation is a class', () => { const Mock = vi.fn(Klass) expectTypeOf(Mock.mock.calls).toEqualTypeOf<[a: string, b?: number][]>() - expectTypeOf(Mock.mock.results).toEqualTypeOf[]>() + expectTypeOf(Mock.mock.results).toEqualTypeOf[]>() expectTypeOf(Mock.mock.contexts).toEqualTypeOf() expectTypeOf(Mock.mock.instances).toEqualTypeOf() expectTypeOf(Mock.mock.invocationCallOrder).toEqualTypeOf() - expectTypeOf(Mock.mock.settledResults).toEqualTypeOf[]>() + expectTypeOf(Mock.mock.settledResults).toEqualTypeOf[]>() expectTypeOf(Mock.mock.lastCall).toEqualTypeOf<[a: string, b?: number] | undefined>() // static properties are defined diff --git a/test/core/test/mocking/vi-fn.test.ts b/test/core/test/mocking/vi-fn.test.ts index b85263328..5505c1b70 100644 --- a/test/core/test/mocking/vi-fn.test.ts +++ b/test/core/test/mocking/vi-fn.test.ts @@ -716,6 +716,54 @@ describe('vi.fn() implementations', () => { expect(callArgs).toEqual(['test', 42]) expect(Mock.mock.calls).toEqual([['test', 42]]) }) + + test('vi.fn() with mockReturnValue throws when called with new', () => { + const Mock = vi.fn() + Mock.mockReturnValue(42) + expect(() => new Mock()).toThrowError( + 'Cannot use `mockReturnValue` when called with `new`. Use `mockImplementation` with a `class` keyword instead.', + ) + }) + + test('vi.fn() with mockReturnValueOnce throws when called with new', () => { + const Mock = vi.fn() + Mock.mockReturnValueOnce(42) + expect(() => new Mock()).toThrowError( + 'Cannot use `mockReturnValueOnce` when called with `new`. Use `mockImplementation` with a `class` keyword instead.', + ) + }) + + test('vi.fn() with mockResolvedValue throws when called with new', () => { + const Mock = vi.fn() + Mock.mockResolvedValue(42) + expect(() => new Mock()).toThrowError( + 'Cannot use `mockResolvedValue` when called with `new`. Use `mockImplementation` with a `class` keyword instead.', + ) + }) + + test('vi.fn() with mockResolvedValueOnce throws when called with new', () => { + const Mock = vi.fn() + Mock.mockResolvedValueOnce(42) + expect(() => new Mock()).toThrowError( + 'Cannot use `mockResolvedValueOnce` when called with `new`. Use `mockImplementation` with a `class` keyword instead.', + ) + }) + + test('vi.fn() with mockRejectedValue throws when called with new', () => { + const Mock = vi.fn() + Mock.mockRejectedValue(new Error('test')) + expect(() => new Mock()).toThrowError( + 'Cannot use `mockRejectedValue` when called with `new`. Use `mockImplementation` with a `class` keyword instead.', + ) + }) + + test('vi.fn() with mockRejectedValueOnce throws when called with new', () => { + const Mock = vi.fn() + Mock.mockRejectedValueOnce(new Error('test')) + expect(() => new Mock()).toThrowError( + 'Cannot use `mockRejectedValueOnce` when called with `new`. Use `mockImplementation` with a `class` keyword instead.', + ) + }) }) function assertStateEmpty(state: MockContext) {