From d4957e1e0be992f2d8316b6b8aca74078764e41a Mon Sep 17 00:00:00 2001 From: Michael Cousins Date: Mon, 27 Apr 2026 13:22:58 -0400 Subject: [PATCH] refactor!: remove original impl workaround for Vitest 3 BREAKING CHANGE: When using Vitest v3, the fallback mock implementation will be lost after `mockReset`. This is a bug in Vitest. To resolve, upgrade to Vitest v4 --- README.md | 23 +++++++++++++++++ src/fallback-implementation.ts | 46 ---------------------------------- src/stubs.ts | 6 +++-- test/vitest-when.test.ts | 15 ----------- 4 files changed, 27 insertions(+), 63 deletions(-) delete mode 100644 src/fallback-implementation.ts diff --git a/README.md b/README.md index e7f9207..54024b6 100644 --- a/README.md +++ b/README.md @@ -67,7 +67,11 @@ test('stubbing with vitest-when', () => { You should call `vi.resetAllMocks()` in your suite's `afterEach` hook to remove the implementation added by `when`. You can also set Vitest's [`mockReset`](https://vitest.dev/config/#mockreset) config to `true` instead of using `afterEach`. +> [!NOTE] +> In Vitest 3 and earlier, `mockReset` will clear any default implementation passed to `vi.fn()` (e.g. `vi.fn(() => 'fallback')`). To preserve default implementations, **upgrade to Vitest 4 or later**. See [fallback implementations][] for more details and a workaround for earlier versions of Vitest. + [vitest's mock functions]: https://vitest.dev/api/mock.html +[fallback implementations]: #fallback [stubs]: https://en.wikipedia.org/wiki/Test_stub [when]: #whenmock-tfunc-options-whenoptions-stubwrappertfunc [called-with]: #calledwithargs-parameterstfunc-stubtfunc @@ -210,6 +214,12 @@ import type { WhenOptions } from 'vitest-when' | `ignoreExtraArgs` | `false` | boolean | Ignore extra arguments when matching arguments | | `times` | N/A | integer | Only trigger configured behavior a number of times | +#### `vi.spyOn()` + +`when()` works with `vi.spyOn()` mocks, but unmatched calls will no-op and return `undefined` rather than falling back to the real implementation. If you need the real implementation as a fallback, consider wrapping the module in an adapter and using `vi.fn()` instead — see [Don't mock what you don't own][no-mock-own]. + +[no-mock-own]: https://github.com/testdouble/contributing-tests/wiki/Don't-mock-what-you-don't-own + ### `.calledWith(...args: Parameters): Stub` Create a stub that matches a given set of arguments which you can configure with different behaviors using methods like [`.thenReturn(...)`][then-return]. @@ -274,6 +284,19 @@ mock('hello') // "world" mock('jello') // "you messed up!" ``` +> [!NOTE] +> In Vitest 3 and earlier, `mockReset` will clear any default implementation passed to `vi.fn()`. To preserve default implementations, **upgrade to Vitest 4 or later**. +> +> As a workaround in Vitest 3 and earlier, you can use a `beforeEach` instead: +> +> ```diff +> - const mockWithFallback = vi.fn(() => 'fallback') +> + const mockWithFallback = vi.fn() +> + beforeEach(() => { +> + mockWithFallback.mockImplementation(() => 'fallback') +> + }) +> ``` + [mock API]: https://vitest.dev/api/mock.html ### `.thenReturn(value: TReturn) -> Mock` diff --git a/src/fallback-implementation.ts b/src/fallback-implementation.ts deleted file mode 100644 index b82d7f2..0000000 --- a/src/fallback-implementation.ts +++ /dev/null @@ -1,46 +0,0 @@ -import type { AnyFunction, AnyMockInstance } from './types.ts' - -/** Get the fallback implementation of a mock if no matching stub is found. */ -export const getFallbackImplementation = ( - mock: AnyMockInstance, -): AnyFunction | undefined => { - return ( - (mock.getMockImplementation() as AnyFunction | undefined) ?? - getTinyspyInternals(mock)?.getOriginal() - ) -} - -/** Internal state from Tinyspy, where a mock's default implementation is stored. */ -interface TinyspyInternals { - getOriginal: () => AnyFunction | undefined -} - -/** - * Get the fallback implementation out of tinyspy internals. - * - * This slight hack works around a bug in Vitest <= 3 - * where `getMockImplementation` will return `undefined` after `mockReset`, - * even if a default implementation is still active. - * The implementation remains present in tinyspy internal state, - * which is stored on a Symbol key in the mock object. - */ -const getTinyspyInternals = ( - mock: AnyMockInstance, -): TinyspyInternals | undefined => { - const maybeTinyspy = mock as unknown as Record - - for (const key of Object.getOwnPropertySymbols(maybeTinyspy)) { - const maybeTinyspyInternals = maybeTinyspy[key] - - if ( - maybeTinyspyInternals && - typeof maybeTinyspyInternals === 'object' && - 'getOriginal' in maybeTinyspyInternals && - typeof maybeTinyspyInternals.getOriginal === 'function' - ) { - return maybeTinyspyInternals as TinyspyInternals - } - } - - return undefined -} diff --git a/src/stubs.ts b/src/stubs.ts index f281092..79b22e4 100644 --- a/src/stubs.ts +++ b/src/stubs.ts @@ -4,8 +4,8 @@ import { createBehaviorStack, } from './behaviors.ts' import { NotAMockFunctionError } from './errors.ts' -import { getFallbackImplementation } from './fallback-implementation.ts' import type { + AnyFunction, AnyMockable, AsFunction, Mock, @@ -30,7 +30,9 @@ export const configureMock = ( } const behaviorStack = createBehaviorStack() - const fallbackImplementation = getFallbackImplementation(mock) + const fallbackImplementation = mock.getMockImplementation() as + | AnyFunction + | undefined function implementation(this: ThisType, ...args: ParametersOf) { const behavior = behaviorStack.use(args)?.behavior ?? { diff --git a/test/vitest-when.test.ts b/test/vitest-when.test.ts index dd543f9..fff2628 100644 --- a/test/vitest-when.test.ts +++ b/test/vitest-when.test.ts @@ -1,5 +1,4 @@ import { describe, expect, it, vi } from 'vitest' -import vitestPkg from 'vitest/package.json' with { type: 'json' } import * as subject from '../src/vitest-when.ts' import { SimpleClass } from './fixtures.ts' @@ -20,7 +19,6 @@ expect.extend({ }) const noop = () => undefined -const vitestMajorVersion = Number(vitestPkg.version.split('.')[0]) describe('vitest-when', () => { it('should raise an error if passed a non-spy', () => { @@ -62,19 +60,6 @@ describe('vitest-when', () => { expect(spy()).toEqual(100) }) - it.skipIf(vitestMajorVersion < 3)( - 'should fall back to original implementation after reset', - () => { - const spy = vi.fn((n) => 2 * n) - - vi.resetAllMocks() - - subject.when(spy).calledWith(1).thenReturn(4) - expect(spy(1)).toEqual(4) - expect(spy(2)).toEqual(4) - }, - ) - it('should return a number of times', () => { const spy = subject .when(vi.fn(), { times: 2 })