]> git.ipfire.org Git - thirdparty/vuejs/pinia.git/commitdiff
feat: add selective action stubbing support (#3040)
authorOleksii <doubledare704@gmail.com>
Mon, 3 Nov 2025 16:26:28 +0000 (18:26 +0200)
committerGitHub <noreply@github.com>
Mon, 3 Nov 2025 16:26:28 +0000 (17:26 +0100)
Co-authored-by: Eduardo San Martin Morote <posva13@gmail.com>
packages/docs/cookbook/testing.md
packages/docs/zh/cookbook/testing.md
packages/testing/src/testing.spec.ts
packages/testing/src/testing.ts

index 4c1a2a0f5474b9802ee93c195fdb2802e8610234..1a78fdf0fff5f9ac087fc961464de7e88edcd493 100644 (file)
@@ -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:
index 810e3a666f29d5247d212ae4a5d7711b9455ed7a..229a920def8ee7a302acc4a059e671a22e970546 100644 (file)
@@ -173,7 +173,79 @@ store.someAction()
 expect(store.someAction).toHaveBeenCalledTimes(1)
 ```
 
-<!-- TODO: translation -->
+### 选择性 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
 
index 75b40687b9f172b994c0cde8b34085b9b7fe3346..d5d87c8627a023354aee7372355f266b997a3cec 100644 (file)
@@ -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', () => {
index 8be334b7e007d71db01845cf06677566a07a65fd..0d675ee96ff7156c46193b418a455f0bd8bb85f5 100644 (file)
@@ -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
+}