diff --git a/code/.storybook/preview.tsx b/code/.storybook/preview.tsx index df899c18e33b..d6ca299ed18f 100644 --- a/code/.storybook/preview.tsx +++ b/code/.storybook/preview.tsx @@ -39,6 +39,7 @@ sb.mock(import('@storybook/global'), { spy: true }); sb.mock('../core/template/stories/test/ModuleMocking.utils.ts'); sb.mock('../core/template/stories/test/ModuleSpyMocking.utils.ts', { spy: true }); sb.mock('../core/template/stories/test/ModuleAutoMocking.utils.ts'); +sb.mock('../core/template/stories/test/ClearModuleMocksMocking.api.ts', { spy: true }); /* eslint-disable depend/ban-dependencies */ sb.mock(import('lodash-es')); sb.mock(import('lodash-es/add')); diff --git a/code/core/src/mocking-utils/automock.ts b/code/core/src/mocking-utils/automock.ts index aac315198c81..9cd30d5cb8a6 100644 --- a/code/core/src/mocking-utils/automock.ts +++ b/code/core/src/mocking-utils/automock.ts @@ -171,6 +171,20 @@ const __vitest_current_es_module__ = { } const __vitest_mocked_module__ = globalThis[${globalThisAccessor}].mockObject(__vitest_current_es_module__, "${mockType}") `; + + // Register module mock spies in the global registry so that clearAllMocks/resetAllMocks/ + // restoreAllMocks from storybook/test can find and clear them. This is needed because the + // module mocker may use a different @vitest/spy instance than the one bundled with storybook/test. + const spyRegistration = ` +if (!globalThis.__STORYBOOK_MODULE_MOCK_SPIES__) { globalThis.__STORYBOOK_MODULE_MOCK_SPIES__ = new Set(); } +for (const __vitest_key__ of Object.keys(__vitest_mocked_module__)) { + const __vitest_val__ = __vitest_mocked_module__[__vitest_key__]; + if (__vitest_val__ && typeof __vitest_val__ === "function" && __vitest_val__._isMockFunction === true) { + globalThis.__STORYBOOK_MODULE_MOCK_SPIES__.add(__vitest_val__); + } +} +`; + const assigning = allSpecifiers .map(({ name }, index) => { return `const __vitest_mocked_${index}__ = __vitest_mocked_module__["${name}"]`; @@ -187,6 +201,6 @@ export { ${redeclarations} } `; - m.append(moduleObject + assigning + specifiersExports); + m.append(moduleObject + spyRegistration + assigning + specifiersExports); return m; } diff --git a/code/core/src/test/spy.ts b/code/core/src/test/spy.ts index 8e9537e5f091..673c800001cc 100644 --- a/code/core/src/test/spy.ts +++ b/code/core/src/test/spy.ts @@ -16,6 +16,19 @@ export type * from '@vitest/spy'; export { isMockFunction, mocks }; +/** + * Global registry for module mock spies created by `sb.mock('...', { spy: true })`. + * + * These spies are created by the module mocker (via `__vitest_mocker__.mockObject()`) and may use a + * different `@vitest/spy` instance than the one bundled with storybook/test. This means they won't + * appear in the `mocks` Set that `clearAllMocks`/`resetAllMocks`/`restoreAllMocks` iterate over. + * + * The automock code generation registers spies here so they can be properly cleared between + * stories. + */ +const moduleMockSpies: Set = ((globalThis as any).__STORYBOOK_MODULE_MOCK_SPIES__ ??= + new Set()); + type Listener = (mock: MockInstance, args: unknown[]) => void; const listeners = new Set(); @@ -63,6 +76,7 @@ function listenWhenCalled(mock: MockInstance) { */ export function clearAllMocks() { mocks.forEach((spy) => spy.mockClear()); + moduleMockSpies.forEach((spy) => spy.mockClear()); } /** @@ -74,6 +88,7 @@ export function clearAllMocks() { */ export function resetAllMocks() { mocks.forEach((spy) => spy.mockReset()); + moduleMockSpies.forEach((spy) => spy.mockReset()); } /** @@ -82,6 +97,11 @@ export function resetAllMocks() { */ export function restoreAllMocks() { mocks.forEach((spy) => spy.mockRestore()); + // For module mock spies, we only clear call history (not restore), because: + // - mockRestore() would try to undo the spyOn on the module export object, which is not + // meaningful for automocked modules where the spy reference is captured at module load time + // - The spy needs to remain active for subsequent stories + moduleMockSpies.forEach((spy) => spy.mockClear()); } /** diff --git a/code/core/template/stories/test/ClearModuleMocksMocking.api.ts b/code/core/template/stories/test/ClearModuleMocksMocking.api.ts new file mode 100644 index 000000000000..8dc03b90db0f --- /dev/null +++ b/code/core/template/stories/test/ClearModuleMocksMocking.api.ts @@ -0,0 +1,17 @@ +export type Data = { + userId: number; + id: number; + title: string; + body: string; +}; + +export const fetchData = async (): Promise => { + return Promise.resolve([ + { + userId: 1, + id: 1, + title: 'mocked title', + body: 'mocked body', + }, + ]); +}; diff --git a/code/core/template/stories/test/ClearModuleMocksMocking.stories.ts b/code/core/template/stories/test/ClearModuleMocksMocking.stories.ts new file mode 100644 index 000000000000..230dd6d65a79 --- /dev/null +++ b/code/core/template/stories/test/ClearModuleMocksMocking.stories.ts @@ -0,0 +1,51 @@ +// Replace your-framework with the framework you are using, e.g. react-vite, nextjs, nextjs-vite, etc. +import { global as globalThis } from '@storybook/global'; + +import { clearAllMocks, expect, waitFor } from 'storybook/test'; + +import { fetchData } from './ClearModuleMocksMocking.api'; + +/** + * The purpose of this story is to verify that the `clearAllMocks` function properly clears mocks + * created with the `spy: true` option in `sb.mock()`. This is necessary because those mocks are + * created with a different instance of `@vitest/spy` than the one bundled with storybook/test. This + * means they won't be cleared by the `clearMocks` option of Vitest, and we need to use + * `clearAllMocks` to clear them manually. See issue: + * https://github.com/storybookjs/storybook/issues/34075 + */ +const meta = { + component: globalThis.__TEMPLATE_COMPONENTS__.Button, + args: { + label: 'Fetch Data', + onClick: () => { + fetchData(); + }, + }, + beforeEach: async () => { + clearAllMocks(); + }, +}; + +export default meta; + +export const First = { + args: {}, + play: async ({ canvas }: any) => { + const button = await canvas.getByRole('button'); + await button.click(); + await waitFor(() => { + expect(fetchData).toHaveBeenCalledTimes(1); + }); + }, +}; + +export const Second = { + args: {}, + play: async ({ canvas }: any) => { + const button = await canvas.getByRole('button'); + await button.click(); + await waitFor(() => { + expect(fetchData).toHaveBeenCalledTimes(1); + }); + }, +}; diff --git a/code/core/template/stories/test/ModuleAutoMocking.stories.ts b/code/core/template/stories/test/ModuleAutoMocking.stories.ts index 4d9c750437aa..587d6ab63a1c 100644 --- a/code/core/template/stories/test/ModuleAutoMocking.stories.ts +++ b/code/core/template/stories/test/ModuleAutoMocking.stories.ts @@ -1,7 +1,6 @@ import { global as globalThis } from '@storybook/global'; import { expect } from 'storybook/test'; -import { v4 } from 'uuid'; import { fn } from './ModuleAutoMocking.utils'; diff --git a/scripts/tasks/sandbox-parts.ts b/scripts/tasks/sandbox-parts.ts index c5dd92921d9e..2c94b638f201 100644 --- a/scripts/tasks/sandbox-parts.ts +++ b/scripts/tasks/sandbox-parts.ts @@ -844,6 +844,7 @@ export const extendPreview: Task['run'] = async ({ template, sandboxDir }) => { "sb.mock('../template-stories/core/test/ModuleMocking.utils.ts');", "sb.mock('../template-stories/core/test/ModuleSpyMocking.utils.ts', { spy: true });", "sb.mock('../template-stories/core/test/ModuleAutoMocking.utils.ts');", + "sb.mock('../template-stories/core/test/ClearModuleMocksMocking.api.ts', { spy: true });", "sb.mock(import('lodash-es'));", "sb.mock(import('lodash-es/add'));", "sb.mock(import('lodash-es/sum'));",