skip to content

When a toast component test mounts with createTestingPinia() from @pinia/testing, what happens to the notifications store's actions, and how do you assert on them?

level: middleimportance: must knowfreq 44%

answer

  1. actions become spies
  2. stubbed means the body never runs
  3. stubActions accepts more than a boolean
  4. createSpy is vi.fn or jest.fn
  5. the testing pinia is already active

basics

~10 s

By default createTestingPinia() replaces every action with a stub spy, so calls are recorded but the real code never runs. Get the store with useNotificationsStore() in the test and assert with expect(store.dismiss).toHaveBeenCalledWith(id).

solid answer

~40 s

`createTestingPinia()` returns a pinia for **component** tests. Every store created on it has its actions replaced by spies made with `createSpy`, which defaults to `jest.fn` or, with Vitest globals, `vi.fn`; otherwise you pass `createSpy: vi.fn` (the function, not `vi.fn()`). With the default `stubActions: true` the spies are empty, so clicking the toast's close button records `dismiss('n1')` but leaves `items` unchanged. `$patch` and `$reset` are wrapped in spies too, and `stubPatch`/`stubReset` turn them into no-ops. The testing pinia also becomes the active pinia, so after mounting with it `useNotificationsStore()` in the test returns the same store the component uses, and I assert `expect(store.dismiss).toHaveBeenCalledWith('n1')`. `stubActions: false` runs real actions while still spying; an array or a function stubs selectively.

code

ts · 24 lines
ts
import { mount } from '@vue/test-utils'
import { createTestingPinia } from '@pinia/testing'
import { expect, it, vi } from 'vitest'
import ToastList from '@/components/ToastList.vue'
import { useNotificationsStore } from '@/stores/notifications'

it('asks the store to dismiss a toast', async () => {
  const wrapper = mount(ToastList, {
    global: {
      plugins: [createTestingPinia({
        createSpy: vi.fn,
        initialState: {
          notifications: { items: [{ id: 'n1', text: 'Saved', level: 'info', read: false }] },
        },
      })],
    },
  })
  const store = useNotificationsStore()

  await wrapper.get('[aria-label="Dismiss"]').trigger('click')

  expect(store.dismiss).toHaveBeenCalledWith('n1')
  expect(store.items).toHaveLength(1)
})

go deeper

for a junior

Recall that createTestingPinia() from @pinia/testing stubs store actions by default, and that you read the store in the test with the same useStore().

for a middle

Explain what stubbing means (spies with no body, state unchanged), the stubActions forms, and why createSpy needs vi.fn or jest.fn.

for a senior

Anticipate the traps: stubs returning undefined for async actions, typing spies, $patch and $reset spies, and setup-store internal calls bypassing stubs.

for a principal

Set the team's split between isolated component tests with stubbed stores and integration tests with real ones, so each behaviour is tested once.

## What createTestingPinia is for `@pinia/testing` exports `createTestingPinia()`, a pinia built for **unit-testing components** that use stores. The idea is to test the component and the store separately: the component test checks that the component calls the right actions with the right arguments and renders the store's state; the store's own unit tests check what the actions do. Install it as a dev dependency. `@pinia/testing` 2.0 is ESM only and expects `pinia` 4.0.2 or later. ## What it does by default 1. It creates a pinia and makes it the **active pinia**, so store calls in the test body find it. 2. For every store created on it, it replaces each action with a **spy**. With `stubActions: true` (the default) the spy has no implementation: the action body never runs and the spy returns `undefined`. 3. It wraps `$patch()` and `$reset()` in spies as well. They still work unless `stubPatch: true` or `stubReset: true` makes them do nothing. 4. It makes getters writable, so a test can force a getter's value. ```ts const wrapper = mount(ToastList, { global: { plugins: [createTestingPinia({ createSpy: vi.fn })] }, }) const store = useNotificationsStore() ``` Passing the testing pinia to the mount installs it into the component's app (the mounting options themselves belong to Vue Test Utils). Because the testing pinia is also active, `useNotificationsStore()` in the test returns the same store instance the component uses. ## Asserting on actions ```ts await wrapper.get('[aria-label="Dismiss"]').trigger('click') expect(store.dismiss).toHaveBeenCalledWith('n1') expect(store.items).toHaveLength(1) ``` The second assertion documents the stub: `dismiss` was called but did not run, so the notice is still there. That is the point; whether `dismiss` removes the notice belongs to the store's unit test. ## Choosing what to stub | `stubActions` value | Effect | |---|---| | `true` (default) | every action is an empty spy | | `false` | every action runs for real, wrapped in a spy | | `['dismiss']` | only the named actions are stubbed; the rest run and are spied | | `(name, store) => boolean` | decided per action, once, when the store is set up | Use `false` for an integration-style component test where the store's behaviour matters, and the array or function forms when one action talks to the network and the others are pure. ## createSpy `createTestingPinia()` needs a spy factory. It uses `jest.fn` when Jest's global is present, or `vi.fn` when Vitest runs with `globals: true`. Otherwise it throws the coded error `PINIA_TESTING_C0001`, and you pass one explicitly: `createTestingPinia({ createSpy: vi.fn })`. Passing a called spy such as `vi.fn()` instead of the function throws `PINIA_TESTING_C0002`. ## Typing the spies Spied actions are real spies at runtime but keep their original types, so `store.load.mockResolvedValue(...)` is a type error. The Pinia docs show a small helper, `mockedStore(useStore)`, that maps each action's type to the test framework's mock type; with it, `mockedStore(useNotificationsStore).load.mockResolvedValue(undefined)` is typed. For one-off cases a cast works too. `vi.spyOn(store, 'load')` is an alternative when a test wants to mock a single action on a store that was not stubbed. ## When not to stub Stubbing isolates the component, but it also means the test cannot catch a mismatch between what the component expects and what the action really does. Two habits keep that risk small: 1. Cover each action in the store's own unit tests, so stubbing it elsewhere is safe. 2. Keep a few integration-style component tests with `stubActions: false` for the critical paths, such as a toast that must disappear when dismissed. ## Things that surprise people - An empty spy returns `undefined`. A component that does `store.load().then(...)` crashes under the default stubs; give the spy a resolved value first, or turn stubbing off for that action. - Spied actions are still typed as the original actions, so calling `.mockResolvedValue()` needs a typed helper or a cast. - In a setup store, an action that calls another action through its local variable bypasses the spy on the store; option stores calling through `this` do not have that gap. - A setup store's own `$reset` function is left to `stubReset`, not `stubActions`. ## Common mistakes - Asserting that state changed after a stubbed action ran. - Creating a second, plain pinia in the test and asserting against a different store. - Passing `vi.fn()` instead of `vi.fn`.

  • The toast component calls store.load().then(showFirst); why does its test crash under the default createTestingPinia() settings?
    With `stubActions: true`, `load` is an empty spy that returns `undefined`, not a promise, so `.then` is called on `undefined`. Configure the spy before mounting, for example `store.load.mockResolvedValue(undefined)` via a typed helper, or leave `load` unstubbed with the array or function form of `stubActions`.
  • What does stubPatch: true change in a component test?
    It turns `store.$patch()` into a spy with no implementation, so a component that updates the store through `$patch` records the call without changing state. Without it, `$patch` is still spied but applies the patch normally. `stubReset` does the same for `$reset()`.

saying these in an interview costs you the question

  • createTestingPinia runs the real actions by default and only records calls
  • A stubbed async action returns a resolved promise
  • createSpy should be passed as vi.fn() so each store gets its own spy
  • The test must create its own pinia to read the component's store
  • stubActions only accepts true or false