From: Oleksii Date: Mon, 3 Nov 2025 16:26:28 +0000 (+0200) Subject: feat: add selective action stubbing support (#3040) X-Git-Tag: @pinia/nuxt@0.11.3~10 X-Git-Url: http://git.ipfire.org/gitweb.cgi?a=commitdiff_plain;h=cff409edf1862d54ad89528c0770c9c219453eed;p=thirdparty%2Fvuejs%2Fpinia.git feat: add selective action stubbing support (#3040) Co-authored-by: Eduardo San Martin Morote --- diff --git a/packages/docs/cookbook/testing.md b/packages/docs/cookbook/testing.md index 4c1a2a0f..1a78fdf0 100644 --- a/packages/docs/cookbook/testing.md +++ b/packages/docs/cookbook/testing.md @@ -166,6 +166,80 @@ store.someAction() expect(store.someAction).toHaveBeenCalledTimes(1) ``` +### Selective action stubbing + +Sometimes you may want to stub only specific actions while allowing others to execute normally. You can achieve this by passing an array of action names to the `stubActions` option: + +```js +// Only stub the 'increment' and 'reset' actions +const wrapper = mount(Counter, { + global: { + plugins: [ + createTestingPinia({ + stubActions: ['increment', 'reset'], + }), + ], + }, +}) + +const store = useSomeStore() + +// These actions will be stubbed (not executed) +store.increment() // stubbed +store.reset() // stubbed + +// Other actions will execute normally but still be spied +store.fetchData() // executed normally +expect(store.fetchData).toHaveBeenCalledTimes(1) +``` + +For more complex scenarios, you can pass a function that receives the action name and store instance, and returns whether the action should be stubbed: + +```js +// Stub actions based on custom logic +const wrapper = mount(Counter, { + global: { + plugins: [ + createTestingPinia({ + stubActions: (actionName, store) => { + // Stub all actions that start with 'set' + if (actionName.startsWith('set')) return true + + // Stub actions based on initial store state + if (store.isPremium) return false + + return true + }, + }), + ], + }, +}) + +const store = useSomeStore() + +// Actions starting with 'set' are stubbed +store.setValue(42) // stubbed + +// Other actions may execute based on the initial store state +store.fetchData() // executed or stubbed based on initial store.isPremium +``` + +::: tip + +- An empty array `[]` means no actions will be stubbed (same as `false`) +- The function is evaluated once at store setup time, receiving the store instance in its initial state + +::: + +You can also manually mock specific actions after creating the store: + +```ts +const store = useSomeStore() +vi.spyOn(store, 'increment').mockImplementation(() => {}) +// or if using testing pinia with stubbed actions +store.increment.mockImplementation(() => {}) +``` + ### Mocking the returned value of an action Actions are automatically spied but type-wise, they are still the regular actions. In order to get the correct type, we must implement a custom type-wrapper that applies the `Mock` type to each action. **This type depends on the testing framework you are using**. Here is an example with Vitest: diff --git a/packages/docs/zh/cookbook/testing.md b/packages/docs/zh/cookbook/testing.md index 810e3a66..229a920d 100644 --- a/packages/docs/zh/cookbook/testing.md +++ b/packages/docs/zh/cookbook/testing.md @@ -173,7 +173,79 @@ store.someAction() expect(store.someAction).toHaveBeenCalledTimes(1) ``` - +### 选择性 action 存根 %{#selective-action-stubbing}% + +有时你可能只想存根特定的 action,而让其他 action 正常执行。你可以通过向 `stubActions` 选项传递一个 action 名称数组来实现: + +```js +// 只存根 'increment' 和 'reset' action +const wrapper = mount(Counter, { + global: { + plugins: [ + createTestingPinia({ + stubActions: ['increment', 'reset'], + }), + ], + }, +}) + +const store = useSomeStore() + +// 这些 action 将被存根(不执行) +store.increment() // 存根 +store.reset() // 存根 + +// 其他 action 将正常执行但仍被监听 +store.fetchData() // 正常执行 +expect(store.fetchData).toHaveBeenCalledTimes(1) +``` + +对于更复杂的场景,你可以传递一个函数,该函数接收 action 名称和 store 实例,并返回是否应该存根该 action: + +```js +// 基于自定义逻辑存根 action +const wrapper = mount(Counter, { + global: { + plugins: [ + createTestingPinia({ + stubActions: (actionName, store) => { + // 存根所有以 'set' 开头的 action + if (actionName.startsWith('set')) return true + + // 根据初始 store 状态存根 action + if (store.isPremium) return false + + return true + }, + }), + ], + }, +}) + +const store = useSomeStore() + +// 以 'set' 开头的 action 被存根 +store.setValue(42) // 存根 + +// 其他 action 可能根据初始 store 状态执行 +store.fetchData() // 根据初始 store.isPremium 执行或存根 +``` + +::: tip + +- 空数组 `[]` 表示不存根任何 action(与 `false` 相同) +- 函数在 store 设置时被评估一次,接收处于初始状态的 store 实例 + +::: + +你也可以在创建 store 后手动模拟特定的 action: + +```ts +const store = useSomeStore() +vi.spyOn(store, 'increment').mockImplementation(() => {}) +// 或者如果使用带有存根 action 的测试 pinia +store.increment.mockImplementation(() => {}) +``` ### Mocking the returned value of an action diff --git a/packages/testing/src/testing.spec.ts b/packages/testing/src/testing.spec.ts index 75b40687..d5d87c86 100644 --- a/packages/testing/src/testing.spec.ts +++ b/packages/testing/src/testing.spec.ts @@ -21,6 +21,12 @@ describe('Testing', () => { increment(amount = 1) { this.n += amount }, + decrement() { + this.n-- + }, + setValue(newValue: number) { + this.n = newValue + }, }, }) @@ -35,6 +41,12 @@ describe('Testing', () => { function increment(amount = 1) { n.value += amount } + function decrement() { + n.value-- + } + function setValue(newValue: number) { + n.value = newValue + } function $reset() { n.value = 0 } @@ -45,6 +57,8 @@ describe('Testing', () => { double, doublePlusOne, increment, + decrement, + setValue, $reset, } }) @@ -326,6 +340,154 @@ describe('Testing', () => { storeToRefs(store) expect(store.doubleComputedCallCount).toBe(0) }) + + describe('selective action stubbing', () => { + it('stubs only actions in array', () => { + setActivePinia( + createTestingPinia({ + stubActions: ['increment', 'setValue'], + createSpy: vi.fn, + }) + ) + + const store = useStore() + + // Actions in array should be stubbed (not execute) + store.increment() + expect(store.n).toBe(0) // Should not change + expect(store.increment).toHaveBeenCalledTimes(1) + + store.setValue(42) + expect(store.n).toBe(0) // Should not change + expect(store.setValue).toHaveBeenCalledTimes(1) + expect(store.setValue).toHaveBeenLastCalledWith(42) + + // Actions not in array should execute normally but still be spied + store.decrement() + expect(store.n).toBe(-1) // Should change + expect(store.decrement).toHaveBeenCalledTimes(1) + }) + + it('handles empty array (same as false)', () => { + setActivePinia( + createTestingPinia({ + stubActions: [], + createSpy: vi.fn, + }) + ) + + const store = useStore() + + // All actions should execute normally + store.increment() + expect(store.n).toBe(1) // Should change + expect(store.increment).toHaveBeenCalledTimes(1) + + store.setValue(42) + expect(store.n).toBe(42) // Should change + expect(store.setValue).toHaveBeenCalledTimes(1) + }) + + it('handles non-existent action names gracefully', () => { + setActivePinia( + createTestingPinia({ + stubActions: ['increment', 'nonExistentAction'], + createSpy: vi.fn, + }) + ) + + const store = useStore() + + // Should work normally despite non-existent action in array + store.increment() + expect(store.n).toBe(0) // Should not change + expect(store.increment).toHaveBeenCalledTimes(1) + + store.setValue(42) + expect(store.n).toBe(42) // Should change (not in array) + expect(store.setValue).toHaveBeenCalledTimes(1) + }) + + it('stubs actions based on function predicate', () => { + setActivePinia( + createTestingPinia({ + stubActions: (actionName) => + actionName.startsWith('set') || actionName === 'decrement', + createSpy: vi.fn, + }) + ) + + const store = useStore() + + // setValue should be stubbed (starts with 'set') + store.setValue(42) + expect(store.n).toBe(0) // Should not change + expect(store.setValue).toHaveBeenCalledTimes(1) + + // increment should execute (doesn't match predicate) + store.increment() + expect(store.n).toBe(1) // Should change + expect(store.increment).toHaveBeenCalledTimes(1) + + // decrement should be stubbed (matches predicate) + store.decrement() + expect(store.n).toBe(1) // Should not change (stubbed) + expect(store.decrement).toHaveBeenCalledTimes(1) + }) + + it('function predicate receives correct store instance', () => { + const predicateSpy = vi.fn(() => false) + + setActivePinia( + createTestingPinia({ + stubActions: predicateSpy, + createSpy: vi.fn, + }) + ) + + const store = useStore() + + expect(predicateSpy).toHaveBeenCalledWith('increment', store) + }) + + it('can stub all actions (default)', () => { + setActivePinia( + createTestingPinia({ + stubActions: true, + createSpy: vi.fn, + }) + ) + + const store = useStore() + + store.increment() + expect(store.n).toBe(0) // Should not change + expect(store.increment).toHaveBeenCalledTimes(1) + + store.setValue(42) + expect(store.n).toBe(0) // Should not change + expect(store.setValue).toHaveBeenCalledTimes(1) + }) + + it('can not stub any action', () => { + setActivePinia( + createTestingPinia({ + stubActions: false, + createSpy: vi.fn, + }) + ) + + const store = useStore() + + store.increment() + expect(store.n).toBe(1) // Should change + expect(store.increment).toHaveBeenCalledTimes(1) + + store.setValue(42) + expect(store.n).toBe(42) // Should change + expect(store.setValue).toHaveBeenCalledTimes(1) + }) + }) } it('works with no actions', () => { diff --git a/packages/testing/src/testing.ts b/packages/testing/src/testing.ts index 8be334b7..0d675ee9 100644 --- a/packages/testing/src/testing.ts +++ b/packages/testing/src/testing.ts @@ -1,13 +1,14 @@ import { computed, createApp, isReactive, isRef, toRaw, triggerRef } from 'vue' import type { App, ComputedRef, WritableComputedRef } from 'vue' import { - Pinia, - PiniaPlugin, + type Pinia, + type PiniaPlugin, setActivePinia, createPinia, - StateTree, - _DeepPartial, - PiniaPluginContext, + type StateTree, + type _DeepPartial, + type PiniaPluginContext, + type StoreGeneric, } from 'pinia' // NOTE: the implementation type is correct and contains up to date types // while the other types hide internal properties @@ -28,12 +29,21 @@ export interface TestingOptions { /** * When set to false, actions are only spied, but they will still get executed. When - * set to true, actions will be replaced with spies, resulting in their code - * not being executed. Defaults to true. NOTE: when providing `createSpy()`, + * set to true, **all** actions will be replaced with spies, resulting in their code + * not being executed. When set to an array of action names, only those actions + * will be stubbed. When set to a function, it will be called for each action with + * the action name and store instance, and should return true to stub the action. + * + * NOTE: when providing `createSpy()`, * it will **only** make the `fn` argument `undefined`. You still have to * handle this in `createSpy()`. + * + * @default `true` */ - stubActions?: boolean + stubActions?: + | boolean + | string[] + | ((actionName: string, store: any) => boolean) /** * When set to true, calls to `$patch()` won't change the state. Defaults to @@ -139,7 +149,10 @@ export function createTestingPinia({ pinia._p.push(({ store, options }) => { Object.keys(options.actions).forEach((action) => { if (action === '$reset') return - store[action] = stubActions ? createSpy() : createSpy(store[action]) + + store[action] = shouldStubAction(stubActions, action, store) + ? createSpy() + : createSpy(store[action]) }) store.$patch = stubPatch ? createSpy() : createSpy(store.$patch) @@ -249,3 +262,25 @@ function WritableComputed({ store }: PiniaPluginContext) { } } } + +/** + * Should the given action be stubbed? + * + * @param stubActions - config option + * @param action - action name + * @param store - Store instance + */ +function shouldStubAction( + stubActions: TestingOptions['stubActions'], + action: string, + store: StoreGeneric +): boolean { + if (typeof stubActions === 'boolean') { + return stubActions + } else if (Array.isArray(stubActions)) { + return stubActions.includes(action) + } else if (typeof stubActions === 'function') { + return stubActions(action, store) + } + return false +}