Skip to content
TutorialTesting

Testing Pinia stores with createTestingPinia

Two tests cover most store bugs: the store on its own, and a component that uses it with the actions stubbed. @pinia/testing 1.0 makes the second one short, as long as you know what it stubs.

vuejs.school team10 Apr 20256 min readPinia 3.0 · @pinia/testing 1.0 · Vitest 3.1

A store is used by many components, so it is hard to tell what a test should cover: the store's logic, the component, or both at once. Split the work into two kinds of test:

  • The store on its own. Create it, call an action, check the state.
  • A component that uses the store. Mount it with a testing pinia that stubs the actions: each one becomes a spy, a function that records its calls, and the real code does not run. Then check what the component rendered and which action it called.

You will write both for a small cart store, with Pinia 3.0, Vitest 3.1, Vue Test Utils 2.4 and @pinia/testing 1.0.

The cart store and the component that uses it

The examples live in a project made with create-vue, with Vitest and jsdom. create-vue maps @ to src/, so @/stores/cart below means src/stores/cart.ts. It also leaves Vitest's globals option off, so each test file imports the helpers it uses, such as describe, it and vi, from vitest. That matters for the createSpy option later.

The store is a setup store with two getters, count and total, and two actions, add and clear:

src/stores/cart.ts
import { defineStore } from 'pinia'
import { computed, ref } from 'vue'

export interface CartItem {
  id: number
  name: string
  price: number
  quantity: number
}

export const useCartStore = defineStore('cart', () => {
  const items = ref<CartItem[]>([])

  const count = computed(() => items.value.reduce((sum, i) => sum + i.quantity, 0))
  const total = computed(() => items.value.reduce((sum, i) => sum + i.price * i.quantity, 0))

  function add(item: Omit<CartItem, 'quantity'>) {
    const existing = items.value.find(i => i.id === item.id)
    if (existing) existing.quantity++
    else items.value.push({ ...item, quantity: 1 })
  }

  function clear() {
    items.value = []
  }

  return { items, count, total, add, clear }
})

CartSummary reads the two getters through storeToRefs() and calls clear() when the button is pressed:

src/components/CartSummary.vue
<script setup lang="ts">
import { storeToRefs } from 'pinia'
import { useCartStore } from '@/stores/cart'

const cart = useCartStore()
const { count, total } = storeToRefs(cart)
</script>

<template>
  <p data-test="summary">{{ count }} items, {{ total.toFixed(2) }}</p>
  <button type="button" @click="cart.clear()">Empty cart</button>
</template>

Test the store on its own

A store cannot work without a pinia instance. In a component test the app provides one. In a plain store test you make one yourself and mark it active, so that useCartStore() finds it without being passed anything:

src/stores/__tests__/cart.spec.ts
import { createPinia, setActivePinia } from 'pinia'
import { beforeEach, describe, expect, it } from 'vitest'
import { useCartStore } from '../cart'

describe('cart store', () => {
  beforeEach(() => {
    setActivePinia(createPinia())
  })

  it('adds a new item with quantity 1', () => {
    const cart = useCartStore()
    cart.add({ id: 1, name: 'Mug', price: 12 })
    expect(cart.items).toHaveLength(1)
    expect(cart.count).toBe(1)
  })

  it('increments the quantity when the same item is added twice', () => {
    const cart = useCartStore()
    cart.add({ id: 1, name: 'Mug', price: 12 })
    cart.add({ id: 1, name: 'Mug', price: 12 })
    expect(cart.items).toHaveLength(1)
    expect(cart.total).toBe(24)
  })
})

The beforeEach is the whole trick. createPinia() makes a fresh, empty instance and setActivePinia() makes it the one every useStore() call uses. Because it runs before each test, the second test starts with an empty cart no matter what the first did. Create the pinia once for the whole file instead, and the two tests share one store: the second starts with the first test's mug already in the cart.

These tests are fast and need no DOM. They are the right place for every branch of an action and every calculation in a getter.

If your store relies on a Pinia plugin, the test needs an app as well. As the Pinia testing cookbook points out, plugins only run once the pinia is installed in an app. Create one app for the file with createApp({}). Then in beforeEach, create the pinia with your plugin, createPinia().use(somePlugin), and call app.use(pinia) before setActivePinia(pinia).

Test a component with createTestingPinia

For the component you do not want the real store logic. You want to put the store in a known state, render, and check that the component asked the store to do the right thing. That is what @pinia/testing is for:

npm install -D @pinia/testing

That installs 1.0.1, published on 9 April. It fixes a bug where storeToRefs() in a test computed the store's getters straight away, even ones nothing reads, which a pinia from createPinia() does not do. CartSummary uses storeToRefs(), so check that an older install is not still on 1.0.0.

The test file has two tests: one checks what the component shows for a given cart, the other checks that the button asks the store to empty it.

src/components/__tests__/CartSummary.spec.ts
import { createTestingPinia } from '@pinia/testing'
import { mount } from '@vue/test-utils'
import { describe, expect, it, vi } from 'vitest'
import { useCartStore } from '@/stores/cart'
import CartSummary from '../CartSummary.vue'

describe('CartSummary', () => {
  it('shows the count and total from the store', () => {
    const wrapper = mount(CartSummary, {
      global: {
        plugins: [
          createTestingPinia({
            createSpy: vi.fn,
            initialState: {
              cart: { items: [{ id: 1, name: 'Mug', price: 12, quantity: 2 }] },
            },
          }),
        ],
      },
    })

    expect(wrapper.get('[data-test="summary"]').text()).toBe('2 items, 24.00')
  })

  it('asks the store to clear the cart', async () => {
    const wrapper = mount(CartSummary, {
      global: {
        plugins: [
          createTestingPinia({
            createSpy: vi.fn,
            initialState: { cart: { items: [{ id: 1, name: 'Mug', price: 12, quantity: 2 }] } },
          }),
        ],
      },
    })
    const cart = useCartStore()

    await wrapper.get('button').trigger('click')

    expect(cart.clear).toHaveBeenCalledTimes(1)
    expect(cart.items).toHaveLength(1)
  })
})

Three things in this file do the work.

createTestingPinia() returns a pinia instance made for tests, and it goes in global.plugins so the mounted component installs it like the real one. It also becomes the active pinia, which is why useCartStore() called after mount in the second test returns the same store the component is using.

createSpy: vi.fn tells the testing pinia which function makes its spies. With Jest, or with Vitest when globals: true is set, it finds one on its own. With globals off, as in a create-vue project, createTestingPinia() throws as soon as it runs: "You must configure the createSpy option". Pass the function itself, vi.fn. Calling it, vi.fn(), hands over a single spy instead, and 1.0 rejects that with "Invalid createSpy option".

initialState is an object keyed by store id. Each entry is patched onto the store the moment it is created, before the component renders, so both tests start with two mugs already in the cart. The keys inside are the names you returned from the setup function, items here, and the getters compute from them as usual, which is why the summary reads 2 items, 24.00.

Stubbed actions, and when to turn them off

By default every action on a testing pinia is replaced with a spy that does nothing. That is why the second test can check that cart.clear was called once and also that the two mugs are still in the cart: the click reached the store, but the real clear never ran. For a component test this is what you want. The component's job is to call clear when the button is pressed. Whether clear empties the array is the store test's job: add a case for it next to the two add tests.

Sometimes you do want the real actions, for example when a component shows the result of an action in the same test. Set stubActions: false. These lines go inside a third, async test in the same file:

const wrapper = mount(CartSummary, {
  global: {
    plugins: [
      createTestingPinia({
        createSpy: vi.fn,
        stubActions: false,
        initialState: { cart: { items: [{ id: 1, name: 'Mug', price: 12, quantity: 2 }] } },
      }),
    ],
  },
})

await wrapper.get('button').trigger('click')

expect(wrapper.get('[data-test="summary"]').text()).toBe('0 items, 0.00')

The actions still run through a spy, so toHaveBeenCalledTimes keeps working; they just also execute. In 1.0 stubActions is a boolean: either every action is stubbed or none is. If one action has to change state while the rest stay stubbed, keep the default and give that one stub an implementation of your own: vi.spyOn(cart, 'add').mockImplementation(...). Your function replaces the store's add; the real one still does not run.

Set up state without calling a stubbed action

Getters are not stubbed: they compute from state exactly as in the app. That leads to a common mistake:

  1. Mount with the default stubbed actions.
  2. Call cart.add(...) in the test to put an item in the cart.
  3. Expect cart.count to be 1. It is 0.

The action was a stub, so the state never changed, and the getter correctly reports an empty cart. When the actions are stubs, set up state through initialState, or assign to the store directly: cart.items = [...] works because state on a testing pinia is writable like any other.

Force a getter's value in a test

When a test needs a getter to return a fixed value, assign one: getters on a testing pinia are writable. cart.total = 99 makes the summary show 99.00 whatever the items are. This works for the computed() values of a setup store as well as the getters of an option store. TypeScript still types getters as read-only, so in a .ts test file put a // @ts-expect-error comment above the assignment.

To undo an override, the cookbook sets the getter back to undefined. That works while only the test reads the getter, but not while a component is showing it: in CartSummary the reset leaves total as undefined, and the template's toFixed() throws on the next render. Override a getter in a test of its own instead. Each test creates a new testing pinia, so the override ends with the test.

Decide what each test covers

Put the logic in the store, and test it with setActivePinia(createPinia()): every branch of add, every number total can produce, every edge case, in a file with no DOM. Then test each component with createTestingPinia, with state set through initialState and actions left as stubs. Assert two things: what the component rendered from the state, and which action it called with which arguments.

Check it stuck3 questions from the Testing Pinia stores quiz.
Practice now
Was this helpful?

Keep reading