In a Pinia setup store, notifyError() calls push() directly; why does a test's stub of push never see that call, and what can you do?
answer
- the stub lives on the store
- closures keep the original
- option stores call through this
- route through the store instance
- or test the real result
basics
~20 screateTestingPinia replaces actions on the store object, but inside a setup store notifyError() calls the local push function it closed over, not store.push. The real push runs; option stores avoid this because this.push goes through the store.
solid answer
~40 sStubbing works by assigning a spy to `store.push`. In a setup store, `notifyError` was written as `push(String(err), 'error')`, which calls the **local function** captured in the setup closure, not the property on the store, so replacing `store.push` changes nothing for that call: with `stubActions: ['push']`, calling the real `notifyError()` still runs the real `push`, and `expect(store.push).toHaveBeenCalled()` fails. Option stores do not have this gap, because actions call each other through `this`, which is the store, so `this.push()` hits the spy. The fixes: assert on the real outcome instead (a new error notice in `items`), or route the internal call through the store with `useNotificationsStore().push(...)`, which the Pinia docs call overkill just to satisfy a test.
code
ts · 14 linesimport { createTestingPinia } from '@pinia/testing'
import { expect, it, vi } from 'vitest'
import { useNotificationsStore } from '@/stores/notifications'
it('shows why the push stub is bypassed', () => {
createTestingPinia({ createSpy: vi.fn, stubActions: ['push'] })
const store = useNotificationsStore()
store.notifyError(new Error('offline'))
expect(store.push).not.toHaveBeenCalled()
expect(store.items).toHaveLength(1)
expect(store.items[0].level).toBe('error')
})go deeper
Recall that the testing pinia stubs actions on the store object, and that setup-store actions calling each other directly are not affected.
Explain the closure: the setup function's local push is a different reference from store.push, while option stores call through this.
Choose the right fix: assert on outcomes, route through the store only when justified, and recognise refactor-induced test failures for what they are.
Set a testing guideline that favours outcome assertions over call-count assertions between store internals, so store refactors do not break tests.
## How stubbing works `createTestingPinia()` registers an internal plugin that runs when each store is created. It walks the store's action names and **assigns a spy to the store property**: `store.push = createSpy()` for a stubbed action, or a spy wrapping the original for one that should still run. Anything that calls `store.push(...)` afterwards reaches the spy. That is enough for components, which always call actions through the store. It is not always enough inside the store itself. ## The setup-store gap A **setup store** is a function whose local variables become the store: ```ts export const useNotificationsStore = defineStore('notifications', () => { const items = ref<Notice[]>([]) function push(text: string, level: Notice['level'] = 'info') { items.value.push({ id: crypto.randomUUID(), text, level, read: false }) } function notifyError(err: unknown) { push(String(err), 'error') } return { items, push, notifyError } }) ``` `notifyError` refers to `push` through the setup function's **closure**. Pinia copies the returned functions onto the store (wrapped for action hooks), and the testing plugin later replaces `store.push`, but the closure inside `notifyError` still points at the original local function. So in a test that stubs only `push`: 1. `store.notifyError(new Error('offline'))` runs the real `notifyError`. 2. It calls the local `push`, not `store.push`. 3. The real `push` adds a notice; the spy on `store.push` records nothing. ## Why option stores are different In an **option store**, actions call each other through `this`: ```ts actions: { notifyError(err: unknown) { this.push(String(err), 'error') }, } ``` `this` is the store, so `this.push` reads the property the testing plugin replaced, and the spy sees the call. | Store kind | Internal call | Hits the stub on `store.push`? | |---|---|---| | setup store | `push(...)` via closure | no | | setup store | `useNotificationsStore().push(...)` | yes | | option store | `this.push(...)` | yes | ## What to do about it - **Assert on the outcome instead.** For `notifyError`, the observable result is a new notice with level `error`. Checking `items` tests behaviour and survives refactors; this is usually the best answer. - **Route the call through the store.** Inside `notifyError`, `const store = useNotificationsStore(); store.push(...)` makes the call stubbable. The Pinia docs show this but call it overkill if its only purpose is a test. - **Question the need to stub.** If `push` is pure and cheap, there is little reason to stub it. Stubbing pays off for actions with side effects, such as network calls, which are often better faked at the module boundary anyway. - **Do not expect the array or function form of `stubActions` to help.** They decide which store properties become stubs; they cannot reach a closure. ## Rewriting the failing test A test that tried to prove "`notifyError` delegates to `push`" can be rewritten around the outcome: ```ts createTestingPinia({ createSpy: vi.fn, stubActions: false }) const store = useNotificationsStore() store.notifyError(new Error('offline')) expect(store.items).toEqual([ expect.objectContaining({ text: 'Error: offline', level: 'error' }), ]) ``` This version passes whether `notifyError` calls `push` through its closure, through the store, or builds the notice itself. It states what users see: an error notice with the message. If the concern is that `push` has a side effect the test must avoid (for example, it also calls a logging endpoint), fake that dependency at its module boundary rather than stubbing `push`. The same reasoning applies to any setup store that composes its own actions: the more a test asserts about which internal function called which, the more it breaks on harmless refactors. ## Where this shows up - Component tests with `stubActions: false` that expect an internal call to be recorded. - Store tests using the testing pinia to count calls between actions. - Migrations from option stores to setup stores, where previously passing tests start failing without any behaviour change. The last case is a useful interview signal: the tests did not catch a bug, they coupled to an implementation detail that the refactor changed. ## Common mistakes - Concluding that stubbing is broken for setup stores in general; calls through the store are stubbed normally. - Rewriting store internals purely to make a spy assertion pass. - Believing the stub also replaces the local function captured by other actions.
- If notifyError is itself left stubbed by the default stubActions: true, does the gap matter?No. With every action stubbed, calling `store.notifyError()` runs nothing, so no internal call happens at all. The gap only appears when an action runs for real (`stubActions: false`, or an array or function that leaves it unstubbed) and calls a sibling through its closure.
- Does $onAction see the closed-over push call inside notifyError?Not through that call. Pinia wraps the copy of `push` it puts on the store; the closure inside the setup function still holds the unwrapped original, so action hooks see `notifyError` but not the inner `push`. Calling `store.push()` instead would be observed.
saying these in an interview costs you the question
- createTestingPinia cannot stub any action of a setup store
- stubActions: ['push'] also replaces the local push captured by notifyError
- Option stores have the same gap because this is the raw action object
- The fix is always to rewrite the store as an option store
- A spy on store.push records every call made inside the store