From fd1a9320993c5949bbf25ab181d8f2f0f6a6ddda Mon Sep 17 00:00:00 2001 From: doubledare704 Date: Sun, 14 Sep 2025 19:16:57 +0300 Subject: [PATCH 1/5] style: fix formatting with prettier feat(testing): add selective action stubbing support - Add support for include/exclude options in stubActions parameter - Allow stubbing only specific actions or excluding specific actions from stubbing - Maintain backward compatibility with boolean stubActions values - Add comprehensive tests for all selective stubbing scenarios - Update documentation with examples and usage patterns - Fix workspace naming conflict in online-playground package Closes #2970 --- packages/docs/cookbook/testing.md | 64 ++++++++ packages/docs/zh/cookbook/testing.md | 64 +++++++- packages/online-playground/package.json | 2 +- packages/testing/src/testing.spec.ts | 203 ++++++++++++++++++++++++ packages/testing/src/testing.ts | 25 ++- 5 files changed, 353 insertions(+), 5 deletions(-) diff --git a/packages/docs/cookbook/testing.md b/packages/docs/cookbook/testing.md index 4c1a2a0f54..67a5d43268 100644 --- a/packages/docs/cookbook/testing.md +++ b/packages/docs/cookbook/testing.md @@ -166,6 +166,70 @@ 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 object with `include` or `exclude` arrays to the `stubActions` option: + +```js +// Only stub the 'increment' and 'reset' actions +const wrapper = mount(Counter, { + global: { + plugins: [ + createTestingPinia({ + stubActions: { include: ['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) +``` + +Alternatively, you can exclude specific actions from stubbing: + +```js +// Stub all actions except 'fetchData' +const wrapper = mount(Counter, { + global: { + plugins: [ + createTestingPinia({ + stubActions: { exclude: ['fetchData'] } + }) + ], + }, +}) + +const store = useSomeStore() + +// This action will execute normally +store.fetchData() // executed normally + +// Other actions will be stubbed +store.increment() // stubbed +store.reset() // stubbed +``` + +::: tip +If both `include` and `exclude` are provided, `include` takes precedence. If neither is provided or both arrays are empty, all actions will be stubbed (equivalent to `stubActions: true`). +::: + +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 810e3a666f..065586144e 100644 --- a/packages/docs/zh/cookbook/testing.md +++ b/packages/docs/zh/cookbook/testing.md @@ -173,7 +173,69 @@ store.someAction() expect(store.someAction).toHaveBeenCalledTimes(1) ``` - +### 选择性 action 存根 %{#selective-action-stubbing}% + +有时你可能只想存根特定的 action,而让其他 action 正常执行。你可以通过向 `stubActions` 选项传递一个包含 `include` 或 `exclude` 数组的对象来实现: + +```js +// 只存根 'increment' 和 'reset' action +const wrapper = mount(Counter, { + global: { + plugins: [ + createTestingPinia({ + stubActions: { include: ['increment', 'reset'] } + }) + ], + }, +}) + +const store = useSomeStore() + +// 这些 action 将被存根(不执行) +store.increment() // 存根 +store.reset() // 存根 + +// 其他 action 将正常执行但仍被监听 +store.fetchData() // 正常执行 +expect(store.fetchData).toHaveBeenCalledTimes(1) +``` + +或者,你可以排除特定的 action 不被存根: + +```js +// 存根所有 action 除了 'fetchData' +const wrapper = mount(Counter, { + global: { + plugins: [ + createTestingPinia({ + stubActions: { exclude: ['fetchData'] } + }) + ], + }, +}) + +const store = useSomeStore() + +// 这个 action 将正常执行 +store.fetchData() // 正常执行 + +// 其他 action 将被存根 +store.increment() // 存根 +store.reset() // 存根 +``` + +::: tip +如果同时提供了 `include` 和 `exclude`,`include` 优先。如果两者都没有提供或两个数组都为空,所有 action 都将被存根(等同于 `stubActions: true`)。 +::: + +你也可以在创建 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/online-playground/package.json b/packages/online-playground/package.json index 9b92f4510d..3966ec979d 100644 --- a/packages/online-playground/package.json +++ b/packages/online-playground/package.json @@ -1,5 +1,5 @@ { - "name": "@pinia/playground", + "name": "@pinia/online-playground", "version": "0.0.0", "type": "module", "private": true, diff --git a/packages/testing/src/testing.spec.ts b/packages/testing/src/testing.spec.ts index 75b40687b9..8b383d533f 100644 --- a/packages/testing/src/testing.spec.ts +++ b/packages/testing/src/testing.spec.ts @@ -24,6 +24,25 @@ describe('Testing', () => { }, }) + const useMultiActionStore = defineStore('multi-action', { + state: () => ({ count: 0, value: 0 }), + actions: { + increment() { + this.count++ + }, + decrement() { + this.count-- + }, + setValue(newValue: number) { + this.value = newValue + }, + reset() { + this.count = 0 + this.value = 0 + }, + }, + }) + const useCounterSetup = defineStore('counter-setup', () => { const n = ref(0) const doubleComputedCallCount = ref(0) @@ -389,4 +408,188 @@ describe('Testing', () => { b: { n: 0 }, }) }) + + describe('selective action stubbing', () => { + it('stubs only included actions', () => { + setActivePinia( + createTestingPinia({ + stubActions: { include: ['increment', 'setValue'] }, + createSpy: vi.fn, + }) + ) + + const store = useMultiActionStore() + + // Included actions should be stubbed (not execute) + store.increment() + expect(store.count).toBe(0) // Should not change + expect(store.increment).toHaveBeenCalledTimes(1) + + store.setValue(42) + expect(store.value).toBe(0) // Should not change + expect(store.setValue).toHaveBeenCalledTimes(1) + expect(store.setValue).toHaveBeenLastCalledWith(42) + + // Excluded actions should execute normally but still be spied + store.decrement() + expect(store.count).toBe(-1) // Should change + expect(store.decrement).toHaveBeenCalledTimes(1) + + store.reset() + expect(store.count).toBe(0) // Should change + expect(store.value).toBe(0) // Should change + expect(store.reset).toHaveBeenCalledTimes(1) + }) + + it('stubs all actions except excluded ones', () => { + setActivePinia( + createTestingPinia({ + stubActions: { exclude: ['increment', 'setValue'] }, + createSpy: vi.fn, + }) + ) + + const store = useMultiActionStore() + + // Excluded actions should execute normally but still be spied + store.increment() + expect(store.count).toBe(1) // Should change + expect(store.increment).toHaveBeenCalledTimes(1) + + store.setValue(42) + expect(store.value).toBe(42) // Should change + expect(store.setValue).toHaveBeenCalledTimes(1) + expect(store.setValue).toHaveBeenLastCalledWith(42) + + // Non-excluded actions should be stubbed (not execute) + store.decrement() + expect(store.count).toBe(1) // Should not change + expect(store.decrement).toHaveBeenCalledTimes(1) + + store.reset() + expect(store.count).toBe(1) // Should not change + expect(store.value).toBe(42) // Should not change + expect(store.reset).toHaveBeenCalledTimes(1) + }) + + it('handles empty include array (stubs all actions)', () => { + setActivePinia( + createTestingPinia({ + stubActions: { include: [] }, + createSpy: vi.fn, + }) + ) + + const store = useMultiActionStore() + + store.increment() + expect(store.count).toBe(0) // Should not change + expect(store.increment).toHaveBeenCalledTimes(1) + + store.setValue(42) + expect(store.value).toBe(0) // Should not change + expect(store.setValue).toHaveBeenCalledTimes(1) + }) + + it('handles empty exclude array (stubs all actions)', () => { + setActivePinia( + createTestingPinia({ + stubActions: { exclude: [] }, + createSpy: vi.fn, + }) + ) + + const store = useMultiActionStore() + + store.increment() + expect(store.count).toBe(0) // Should not change + expect(store.increment).toHaveBeenCalledTimes(1) + + store.setValue(42) + expect(store.value).toBe(0) // Should not change + expect(store.setValue).toHaveBeenCalledTimes(1) + }) + + it('handles both include and exclude (include takes precedence)', () => { + setActivePinia( + createTestingPinia({ + stubActions: { + include: ['increment'], + exclude: ['increment', 'setValue'], + }, + createSpy: vi.fn, + }) + ) + + const store = useMultiActionStore() + + // Include takes precedence - increment should be stubbed + store.increment() + expect(store.count).toBe(0) // Should not change + expect(store.increment).toHaveBeenCalledTimes(1) + + // Not in include list - should execute normally + store.setValue(42) + expect(store.value).toBe(42) // Should change + expect(store.setValue).toHaveBeenCalledTimes(1) + }) + + it('maintains backward compatibility with boolean true', () => { + setActivePinia( + createTestingPinia({ + stubActions: true, + createSpy: vi.fn, + }) + ) + + const store = useMultiActionStore() + + store.increment() + expect(store.count).toBe(0) // Should not change + expect(store.increment).toHaveBeenCalledTimes(1) + + store.setValue(42) + expect(store.value).toBe(0) // Should not change + expect(store.setValue).toHaveBeenCalledTimes(1) + }) + + it('maintains backward compatibility with boolean false', () => { + setActivePinia( + createTestingPinia({ + stubActions: false, + createSpy: vi.fn, + }) + ) + + const store = useMultiActionStore() + + store.increment() + expect(store.count).toBe(1) // Should change + expect(store.increment).toHaveBeenCalledTimes(1) + + store.setValue(42) + expect(store.value).toBe(42) // Should change + expect(store.setValue).toHaveBeenCalledTimes(1) + }) + + it('handles non-existent action names gracefully', () => { + setActivePinia( + createTestingPinia({ + stubActions: { include: ['increment', 'nonExistentAction'] }, + createSpy: vi.fn, + }) + ) + + const store = useMultiActionStore() + + // Should work normally despite non-existent action in include list + store.increment() + expect(store.count).toBe(0) // Should not change + expect(store.increment).toHaveBeenCalledTimes(1) + + store.setValue(42) + expect(store.value).toBe(42) // Should change (not in include list) + expect(store.setValue).toHaveBeenCalledTimes(1) + }) + }) }) diff --git a/packages/testing/src/testing.ts b/packages/testing/src/testing.ts index 8be334b7e0..0fe8e26ff0 100644 --- a/packages/testing/src/testing.ts +++ b/packages/testing/src/testing.ts @@ -29,11 +29,13 @@ 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()`, + * not being executed. When set to an object with `include` or `exclude` arrays, + * only the specified actions will be stubbed or excluded from stubbing. + * Defaults to true. NOTE: when providing `createSpy()`, * it will **only** make the `fn` argument `undefined`. You still have to * handle this in `createSpy()`. */ - stubActions?: boolean + stubActions?: boolean | { include?: string[]; exclude?: string[] } /** * When set to true, calls to `$patch()` won't change the state. Defaults to @@ -139,7 +141,24 @@ 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]) + + let shouldStub: boolean + if (typeof stubActions === 'boolean') { + shouldStub = stubActions + } else { + // Handle include/exclude logic + const { include, exclude } = stubActions + if (include && include.length > 0) { + shouldStub = include.includes(action) + } else if (exclude && exclude.length > 0) { + shouldStub = !exclude.includes(action) + } else { + // If both include and exclude are empty or undefined, default to true + shouldStub = true + } + } + + store[action] = shouldStub ? createSpy() : createSpy(store[action]) }) store.$patch = stubPatch ? createSpy() : createSpy(store.$patch) From 1478813bde6c953a04a1fdbb52fe07cb1bc8ab6a Mon Sep 17 00:00:00 2001 From: doubledare704 Date: Sun, 14 Sep 2025 20:09:36 +0300 Subject: [PATCH 2/5] fix(playground): reverted package name --- packages/online-playground/package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/online-playground/package.json b/packages/online-playground/package.json index 3966ec979d..9b92f4510d 100644 --- a/packages/online-playground/package.json +++ b/packages/online-playground/package.json @@ -1,5 +1,5 @@ { - "name": "@pinia/online-playground", + "name": "@pinia/playground", "version": "0.0.0", "type": "module", "private": true, From 65890473b89a25ac320427599c5885bfdf982076 Mon Sep 17 00:00:00 2001 From: Eduardo San Martin Morote Date: Mon, 3 Nov 2025 16:57:39 +0100 Subject: [PATCH 3/5] refactor: review --- packages/testing/src/testing.spec.ts | 357 ++++++++++++--------------- packages/testing/src/testing.ts | 70 ++++-- 2 files changed, 201 insertions(+), 226 deletions(-) diff --git a/packages/testing/src/testing.spec.ts b/packages/testing/src/testing.spec.ts index 8b383d533f..d5d87c8627 100644 --- a/packages/testing/src/testing.spec.ts +++ b/packages/testing/src/testing.spec.ts @@ -21,24 +21,11 @@ describe('Testing', () => { increment(amount = 1) { this.n += amount }, - }, - }) - - const useMultiActionStore = defineStore('multi-action', { - state: () => ({ count: 0, value: 0 }), - actions: { - increment() { - this.count++ - }, decrement() { - this.count-- + this.n-- }, setValue(newValue: number) { - this.value = newValue - }, - reset() { - this.count = 0 - this.value = 0 + this.n = newValue }, }, }) @@ -54,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 } @@ -64,6 +57,8 @@ describe('Testing', () => { double, doublePlusOne, increment, + decrement, + setValue, $reset, } }) @@ -345,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', () => { @@ -408,188 +551,4 @@ describe('Testing', () => { b: { n: 0 }, }) }) - - describe('selective action stubbing', () => { - it('stubs only included actions', () => { - setActivePinia( - createTestingPinia({ - stubActions: { include: ['increment', 'setValue'] }, - createSpy: vi.fn, - }) - ) - - const store = useMultiActionStore() - - // Included actions should be stubbed (not execute) - store.increment() - expect(store.count).toBe(0) // Should not change - expect(store.increment).toHaveBeenCalledTimes(1) - - store.setValue(42) - expect(store.value).toBe(0) // Should not change - expect(store.setValue).toHaveBeenCalledTimes(1) - expect(store.setValue).toHaveBeenLastCalledWith(42) - - // Excluded actions should execute normally but still be spied - store.decrement() - expect(store.count).toBe(-1) // Should change - expect(store.decrement).toHaveBeenCalledTimes(1) - - store.reset() - expect(store.count).toBe(0) // Should change - expect(store.value).toBe(0) // Should change - expect(store.reset).toHaveBeenCalledTimes(1) - }) - - it('stubs all actions except excluded ones', () => { - setActivePinia( - createTestingPinia({ - stubActions: { exclude: ['increment', 'setValue'] }, - createSpy: vi.fn, - }) - ) - - const store = useMultiActionStore() - - // Excluded actions should execute normally but still be spied - store.increment() - expect(store.count).toBe(1) // Should change - expect(store.increment).toHaveBeenCalledTimes(1) - - store.setValue(42) - expect(store.value).toBe(42) // Should change - expect(store.setValue).toHaveBeenCalledTimes(1) - expect(store.setValue).toHaveBeenLastCalledWith(42) - - // Non-excluded actions should be stubbed (not execute) - store.decrement() - expect(store.count).toBe(1) // Should not change - expect(store.decrement).toHaveBeenCalledTimes(1) - - store.reset() - expect(store.count).toBe(1) // Should not change - expect(store.value).toBe(42) // Should not change - expect(store.reset).toHaveBeenCalledTimes(1) - }) - - it('handles empty include array (stubs all actions)', () => { - setActivePinia( - createTestingPinia({ - stubActions: { include: [] }, - createSpy: vi.fn, - }) - ) - - const store = useMultiActionStore() - - store.increment() - expect(store.count).toBe(0) // Should not change - expect(store.increment).toHaveBeenCalledTimes(1) - - store.setValue(42) - expect(store.value).toBe(0) // Should not change - expect(store.setValue).toHaveBeenCalledTimes(1) - }) - - it('handles empty exclude array (stubs all actions)', () => { - setActivePinia( - createTestingPinia({ - stubActions: { exclude: [] }, - createSpy: vi.fn, - }) - ) - - const store = useMultiActionStore() - - store.increment() - expect(store.count).toBe(0) // Should not change - expect(store.increment).toHaveBeenCalledTimes(1) - - store.setValue(42) - expect(store.value).toBe(0) // Should not change - expect(store.setValue).toHaveBeenCalledTimes(1) - }) - - it('handles both include and exclude (include takes precedence)', () => { - setActivePinia( - createTestingPinia({ - stubActions: { - include: ['increment'], - exclude: ['increment', 'setValue'], - }, - createSpy: vi.fn, - }) - ) - - const store = useMultiActionStore() - - // Include takes precedence - increment should be stubbed - store.increment() - expect(store.count).toBe(0) // Should not change - expect(store.increment).toHaveBeenCalledTimes(1) - - // Not in include list - should execute normally - store.setValue(42) - expect(store.value).toBe(42) // Should change - expect(store.setValue).toHaveBeenCalledTimes(1) - }) - - it('maintains backward compatibility with boolean true', () => { - setActivePinia( - createTestingPinia({ - stubActions: true, - createSpy: vi.fn, - }) - ) - - const store = useMultiActionStore() - - store.increment() - expect(store.count).toBe(0) // Should not change - expect(store.increment).toHaveBeenCalledTimes(1) - - store.setValue(42) - expect(store.value).toBe(0) // Should not change - expect(store.setValue).toHaveBeenCalledTimes(1) - }) - - it('maintains backward compatibility with boolean false', () => { - setActivePinia( - createTestingPinia({ - stubActions: false, - createSpy: vi.fn, - }) - ) - - const store = useMultiActionStore() - - store.increment() - expect(store.count).toBe(1) // Should change - expect(store.increment).toHaveBeenCalledTimes(1) - - store.setValue(42) - expect(store.value).toBe(42) // Should change - expect(store.setValue).toHaveBeenCalledTimes(1) - }) - - it('handles non-existent action names gracefully', () => { - setActivePinia( - createTestingPinia({ - stubActions: { include: ['increment', 'nonExistentAction'] }, - createSpy: vi.fn, - }) - ) - - const store = useMultiActionStore() - - // Should work normally despite non-existent action in include list - store.increment() - expect(store.count).toBe(0) // Should not change - expect(store.increment).toHaveBeenCalledTimes(1) - - store.setValue(42) - expect(store.value).toBe(42) // Should change (not in include list) - expect(store.setValue).toHaveBeenCalledTimes(1) - }) - }) }) diff --git a/packages/testing/src/testing.ts b/packages/testing/src/testing.ts index 0fe8e26ff0..0d675ee96f 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,14 +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. When set to an object with `include` or `exclude` arrays, - * only the specified actions will be stubbed or excluded from stubbing. - * 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 | { include?: string[]; exclude?: string[] } + stubActions?: + | boolean + | string[] + | ((actionName: string, store: any) => boolean) /** * When set to true, calls to `$patch()` won't change the state. Defaults to @@ -142,23 +150,9 @@ export function createTestingPinia({ Object.keys(options.actions).forEach((action) => { if (action === '$reset') return - let shouldStub: boolean - if (typeof stubActions === 'boolean') { - shouldStub = stubActions - } else { - // Handle include/exclude logic - const { include, exclude } = stubActions - if (include && include.length > 0) { - shouldStub = include.includes(action) - } else if (exclude && exclude.length > 0) { - shouldStub = !exclude.includes(action) - } else { - // If both include and exclude are empty or undefined, default to true - shouldStub = true - } - } - - store[action] = shouldStub ? createSpy() : createSpy(store[action]) + store[action] = shouldStubAction(stubActions, action, store) + ? createSpy() + : createSpy(store[action]) }) store.$patch = stubPatch ? createSpy() : createSpy(store.$patch) @@ -268,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 +} From 6d8630d87c2e1f8c99c5ed108fbd310a97474adc Mon Sep 17 00:00:00 2001 From: Eduardo San Martin Morote Date: Mon, 3 Nov 2025 16:58:03 +0100 Subject: [PATCH 4/5] docs: refactor --- packages/docs/cookbook/testing.md | 30 ++++++++++++++++++---------- packages/docs/zh/cookbook/testing.md | 30 ++++++++++++++++++---------- 2 files changed, 38 insertions(+), 22 deletions(-) diff --git a/packages/docs/cookbook/testing.md b/packages/docs/cookbook/testing.md index 67a5d43268..b31fe4efef 100644 --- a/packages/docs/cookbook/testing.md +++ b/packages/docs/cookbook/testing.md @@ -168,7 +168,7 @@ 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 object with `include` or `exclude` arrays to the `stubActions` option: +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 @@ -176,7 +176,7 @@ const wrapper = mount(Counter, { global: { plugins: [ createTestingPinia({ - stubActions: { include: ['increment', 'reset'] } + stubActions: ['increment', 'reset'] }) ], }, @@ -193,15 +193,23 @@ store.fetchData() // executed normally expect(store.fetchData).toHaveBeenCalledTimes(1) ``` -Alternatively, you can exclude specific actions from stubbing: +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 all actions except 'fetchData' +// Stub actions based on custom logic const wrapper = mount(Counter, { global: { plugins: [ createTestingPinia({ - stubActions: { exclude: ['fetchData'] } + 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 + } }) ], }, @@ -209,16 +217,16 @@ const wrapper = mount(Counter, { const store = useSomeStore() -// This action will execute normally -store.fetchData() // executed normally +// Actions starting with 'set' are stubbed +store.setValue(42) // stubbed -// Other actions will be stubbed -store.increment() // stubbed -store.reset() // stubbed +// Other actions may execute based on the initial store state +store.fetchData() // executed or stubbed based on initial store.isPremium ``` ::: tip -If both `include` and `exclude` are provided, `include` takes precedence. If neither is provided or both arrays are empty, all actions will be stubbed (equivalent to `stubActions: true`). +- An empty array `[]` means no actions will be stubbed (all actions execute normally) +- 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: diff --git a/packages/docs/zh/cookbook/testing.md b/packages/docs/zh/cookbook/testing.md index 065586144e..d82c11c741 100644 --- a/packages/docs/zh/cookbook/testing.md +++ b/packages/docs/zh/cookbook/testing.md @@ -175,7 +175,7 @@ expect(store.someAction).toHaveBeenCalledTimes(1) ### 选择性 action 存根 %{#selective-action-stubbing}% -有时你可能只想存根特定的 action,而让其他 action 正常执行。你可以通过向 `stubActions` 选项传递一个包含 `include` 或 `exclude` 数组的对象来实现: +有时你可能只想存根特定的 action,而让其他 action 正常执行。你可以通过向 `stubActions` 选项传递一个 action 名称数组来实现: ```js // 只存根 'increment' 和 'reset' action @@ -183,7 +183,7 @@ const wrapper = mount(Counter, { global: { plugins: [ createTestingPinia({ - stubActions: { include: ['increment', 'reset'] } + stubActions: ['increment', 'reset'] }) ], }, @@ -200,15 +200,23 @@ store.fetchData() // 正常执行 expect(store.fetchData).toHaveBeenCalledTimes(1) ``` -或者,你可以排除特定的 action 不被存根: +对于更复杂的场景,你可以传递一个函数,该函数接收 action 名称和 store 实例,并返回是否应该存根该 action: ```js -// 存根所有 action 除了 'fetchData' +// 基于自定义逻辑存根 action const wrapper = mount(Counter, { global: { plugins: [ createTestingPinia({ - stubActions: { exclude: ['fetchData'] } + stubActions: (actionName, store) => { + // 存根所有以 'set' 开头的 action + if (actionName.startsWith('set')) return true + + // 根据初始 store 状态存根 action + if (store.isPremium) return false + + return true + } }) ], }, @@ -216,16 +224,16 @@ const wrapper = mount(Counter, { const store = useSomeStore() -// 这个 action 将正常执行 -store.fetchData() // 正常执行 +// 以 'set' 开头的 action 被存根 +store.setValue(42) // 存根 -// 其他 action 将被存根 -store.increment() // 存根 -store.reset() // 存根 +// 其他 action 可能根据初始 store 状态执行 +store.fetchData() // 根据初始 store.isPremium 执行或存根 ``` ::: tip -如果同时提供了 `include` 和 `exclude`,`include` 优先。如果两者都没有提供或两个数组都为空,所有 action 都将被存根(等同于 `stubActions: true`)。 +- 空数组 `[]` 表示不存根任何 action(所有 action 正常执行) +- 函数在 store 设置时被评估一次,接收处于初始状态的 store 实例 ::: 你也可以在创建 store 后手动模拟特定的 action: From d29f2211347254dde2febf0d98cdf8f18bcff4de Mon Sep 17 00:00:00 2001 From: Eduardo San Martin Morote Date: Mon, 3 Nov 2025 17:03:26 +0100 Subject: [PATCH 5/5] docs: format --- packages/docs/cookbook/testing.md | 12 +++++++----- packages/docs/zh/cookbook/testing.md | 12 +++++++----- 2 files changed, 14 insertions(+), 10 deletions(-) diff --git a/packages/docs/cookbook/testing.md b/packages/docs/cookbook/testing.md index b31fe4efef..1a78fdf0ff 100644 --- a/packages/docs/cookbook/testing.md +++ b/packages/docs/cookbook/testing.md @@ -176,8 +176,8 @@ const wrapper = mount(Counter, { global: { plugins: [ createTestingPinia({ - stubActions: ['increment', 'reset'] - }) + stubActions: ['increment', 'reset'], + }), ], }, }) @@ -209,8 +209,8 @@ const wrapper = mount(Counter, { if (store.isPremium) return false return true - } - }) + }, + }), ], }, }) @@ -225,8 +225,10 @@ store.fetchData() // executed or stubbed based on initial store.isPremium ``` ::: tip -- An empty array `[]` means no actions will be stubbed (all actions execute normally) + +- 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: diff --git a/packages/docs/zh/cookbook/testing.md b/packages/docs/zh/cookbook/testing.md index d82c11c741..229a920def 100644 --- a/packages/docs/zh/cookbook/testing.md +++ b/packages/docs/zh/cookbook/testing.md @@ -183,8 +183,8 @@ const wrapper = mount(Counter, { global: { plugins: [ createTestingPinia({ - stubActions: ['increment', 'reset'] - }) + stubActions: ['increment', 'reset'], + }), ], }, }) @@ -216,8 +216,8 @@ const wrapper = mount(Counter, { if (store.isPremium) return false return true - } - }) + }, + }), ], }, }) @@ -232,8 +232,10 @@ store.fetchData() // 根据初始 store.isPremium 执行或存根 ``` ::: tip -- 空数组 `[]` 表示不存根任何 action(所有 action 正常执行) + +- 空数组 `[]` 表示不存根任何 action(与 `false` 相同) - 函数在 store 设置时被评估一次,接收处于初始状态的 store 实例 + ::: 你也可以在创建 store 后手动模拟特定的 action: