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/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)