Testing Pinia stores
Test a store with a fresh Pinia in every test, and test the components that use it with createTestingPinia(), where actions are spies and state starts where you need it.
What you'll learn
When you finish, you can do these.- 1Test a store with a fresh Pinia in every test
- 2Test an action's success and failure with a fake fetch
- 3Test a component in any state with createTestingPinia()
- 4Spot and fix a missing Pinia and a $reset() that throws
Picture a sticker shop, a small online shop that keeps its cart in a store, one place for data that every part of the app shares. The store is written with Pinia, Vue's official store library: it keeps the items, adds up the total and sends the order. CartButton, a small component, shows the count and the total, with two buttons: Add a sticker and Check out. Test the two together, and once many components use the cart, one bug in the store fails tests all over the project, most of them for components that work.
Test each on its own instead: the store with a real Pinia, the component with a Pinia whose actions, the store's functions, are spies. A spy is the one idea you need from Testing async code and mocks: a fake function, made with vi.fn(), that records its calls and returns what the test chooses. The Code tab holds the store, CartButton and a test file for each: seven tests you can run in a project of your own.
Test the store with a fresh Pinia
The store's own tests run its real code, with a new Pinia for every test.
The store, src/stores/cart.ts in the Code tab, is a setup store: one function in which refs are its state (items, status), computed values its getters (count, total) and plain functions its actions (add(), checkout()). Its id is cart, and useCartStore() returns it.
A store only works once a Pinia exists, the object that holds every store's state. In the app, app.use(createPinia()) creates one for the whole app. A store test has no app, so it passes a new Pinia to setActivePinia(), and useCartStore() picks up that active one.
The @pinia/testing package adds a second kind of Pinia, for component tests.
setActivePinia(createPinia())A real Pinia: actions run, getters compute, state starts as the store defines it. Use it to test the store.
createTestingPinia()Every action is a spy that records its calls and does nothing, and the test can set the state, even the getters. Use it to test a component.
Create the Pinia in beforeEach, a Vitest hook that runs before each test, so every test gets a new, empty one.
beforeEach(() => {
setActivePinia(createPinia()) // a new, empty Pinia for every test
})
The first test adds a sticker and a mug, then checks both getters.
const cart = useCartStore()
cart.add('Sticker', 6)
cart.add('Mug', 14)
expect(cart.count).toBe(2)
expect(cart.total).toBe(20)
Both checks pass: two items, 20 euros. The second test, starts empty in every test, finds count at 0, whatever the first one added.
checkout() sends the order with fetch, the browser's request function, and a test has no server. Its tests swap fetch for a spy with vi.spyOn(), and mockResolvedValue() sets its answer: a Response, what fetch returns, with the status the test picks. The action is async, so the test awaits it before checking status.
it('empties the cart after a successful checkout', async () => {
vi.spyOn(globalThis, 'fetch').mockResolvedValue(new Response(null, { status: 201 }))
const cart = useCartStore()
cart.add('Sticker', 6)
await cart.checkout()
expect(fetch).toHaveBeenCalledWith('/api/orders', expect.objectContaining({ method: 'POST' }))
expect(cart.status).toBe('done')
expect(cart.count).toBe(0)
})
The spy answers 201, a success. The test checks that fetch sent a POST to /api/orders, that the status is done and that the cart is empty.
The other checkout test answers 503, a server error, and expects failed with the sticker still there: the case people forget. vi.restoreAllMocks() in afterEach, the hook that runs after each test, puts the real fetch back.
Test the component with createTestingPinia()
A component test checks what CartButton asks of the store, so it uses a testing Pinia whose actions only record their calls. Install its package with npm i -D @pinia/testing (version 2, for Pinia 4).
mount(), the Vue Test Utils function that puts a component on a page in memory, takes createTestingPinia() as a plugin, an add-on for the app. useCartStore() in the test then returns the store the component uses.
const wrapper = mount(CartButton, {
global: { plugins: [createTestingPinia({ createSpy: vi.fn })] },
})
const cart = useCartStore()
await wrapper.get('button').trigger('click')
expect(cart.add).toHaveBeenCalledWith('Sticker', 6)
expect(wrapper.get('p').text()).toBe('0 in the cart, 0 euros') // add is a stub
Actions are stubbed by default: the click on Add a sticker, the first button, calls add, which only records the call, so the paragraph still reads 0 in the cart, 0 euros. The test checks the component's own job, calling the right action with the right arguments.
With stubActions: false the real actions run, and they stay spies, so call checks still work.
createSpy: vi.fn makes the spies with vi.fn(). In a default Vitest setup, leaving it out throws PINIA_TESTING_C0001: You must configure the "createSpy" option.
A failed checkout is hard to reach through clicks, since Check out only calls a stub. In the test shows a failed checkout, the initialState option of createTestingPinia() sets it up in one line, under the store's id, cart.
initialState: { cart: { items: [{ name: 'Mug', price: 14 }], status: 'failed' } },
CartButton shows 1 in the cart, 14 euros, computed from that one mug, and the alert The order did not go through.
Getters can also be forced. The test shows a forced total sets cart.total to 120, then waits with nextTick(), which lets Vue update the page.
const cart = useCartStore()
// @ts-expect-error: getters are read-only in the store's types
cart.total = 120
await nextTick()
expect(wrapper.get('p').text()).toBe('0 in the cart, 120 euros')
The paragraph says 120 euros with nothing in the cart. In the store's types a getter is read-only, so the @ts-expect-error comment tells TypeScript to expect an error on the next line. Without it, type checking fails with Cannot assign to 'total' because it is a read-only property.
Actions have the same problem: a spy method such as cart.add.mockClear() runs but does not type-check. vi.mocked(cart.add) gives you the spy's type.
What goes wrong
Two mistakes break the store's tests.
The first is calling the store before any Pinia is active, in a test file with no beforeEach.
it('adds items and sums the total', () => {
const cart = useCartStore()
The test fails with [🍍]: "getActivePinia()" was called but there was no active Pinia. Are you trying to use a store before calling "app.use(pinia)"?
Creating the Pinia in beforeAll, which runs once before all tests, is a quieter version: the tests share one store, and starts empty in every test fails with expected 2 to be +0, with the first test's items still there. Create it in beforeEach instead.
beforeEach(() => {
setActivePinia(createPinia()) // a new, empty Pinia for every test
})
Each test now gets its own empty cart, and all four pass.
The second mistake is tidying up with $reset(), a store method that puts the state back where it started. Only option stores, the other way to write a store, have it: Pinia calls their state() function again.
afterEach(() => {
useCartStore().$reset()
})
Every test now fails after it runs, with 🍍: Store "cart" is built using the setup syntax and does not implement $reset(). You need no reset: a fresh Pinia already starts every test empty, so afterEach only puts the real fetch back.
afterEach(() => {
vi.restoreAllMocks() // put the real fetch back
})
All four tests pass again.
What to remember
- Test a store with
setActivePinia(createPinia())inbeforeEach, neverbeforeAll, so no state leaks between tests. - Fake
fetchin an action's test, and test the failure as well as the success. - In component tests,
createTestingPinia({ createSpy: vi.fn })stubs every action: check that the component called it, not what it did. initialStateputs a store in any state in one line, and getters can be forced;vi.mocked()and@ts-expect-errorkeep TypeScript happy.- Setup stores have no built-in
$reset(); a fresh Pinia per test makes it unnecessary.
In your project
Do these in a real project. Each one says how to confirm it worked.- Run
npx vitest run src/stores: four tests pass. ChangebeforeEachtobeforeAllincart.spec.ts, in the import and the call, and run it again:starts empty in every testfails withexpected 2 to be +0. Change it back. - In the first
CartButtontest, passstubActions: falsetocreateTestingPinia(). The text check fails because the realaddran and the paragraph reads1 in the cart, 6 euros. Update the expected text: the test passes, its call check included. - Delete the
@ts-expect-errorline inshows a forced totaland runnpm run type-check. It reports the read-only error ontotal, whilenpx vitest runstill passes. Put the comment back.