Pinia Store Testing Strategies: Actions, Getters, and Component Integration
Pinia replaced Vuex as Vue's recommended state management solution, and it's dramatically more testable. Stores are plain objects with no magic mutations — actions are async functions, getters are computed properties, and state is just reactive data. This makes testing straightforward once you know the patterns.
Setup: Test Environment for Pinia
Install the dependencies:
npm install --save-dev @pinia/testing pinia vitest @vue/test-utilsThe @pinia/testing package provides createTestingPinia — a special Pinia instance for tests that gives you control over initial state and lets you spy on actions.
Configure your test setup file:
// vitest.setup.ts
import { beforeEach } from 'vitest'
import { setActivePinia, createPinia } from 'pinia'
beforeEach(() => {
// Creates a fresh Pinia instance for each test
setActivePinia(createPinia())
})// vite.config.ts
export default defineConfig({
test: {
setupFiles: ['./vitest.setup.ts'],
environment: 'jsdom',
globals: true,
},
})Defining a Real-World Store
Let's build a cart store that we'll test throughout this guide:
// stores/cart.ts
import { defineStore } from 'pinia'
import { computed, ref } from 'vue'
import { cartApi } from '@/api/cart'
export interface CartItem {
id: string
productId: string
name: string
price: number
quantity: number
}
export const useCartStore = defineStore('cart', () => {
// State
const items = ref<CartItem[]>([])
const isLoading = ref(false)
const lastError = ref<string | null>(null)
// Getters
const totalItems = computed(() =>
items.value.reduce((sum, item) => sum + item.quantity, 0)
)
const totalPrice = computed(() =>
items.value.reduce((sum, item) => sum + item.price * item.quantity, 0)
)
const isEmpty = computed(() => items.value.length === 0)
const itemById = computed(() => (id: string) =>
items.value.find((item) => item.id === id)
)
// Actions
async function fetchCart(userId: string) {
isLoading.value = true
lastError.value = null
try {
items.value = await cartApi.getCart(userId)
} catch (error) {
lastError.value = error instanceof Error ? error.message : 'Failed to fetch cart'
} finally {
isLoading.value = false
}
}
async function addItem(productId: string, quantity = 1) {
const existing = items.value.find((i) => i.productId === productId)
if (existing) {
existing.quantity += quantity
await cartApi.updateItem(existing.id, existing.quantity)
} else {
const newItem = await cartApi.addItem(productId, quantity)
items.value.push(newItem)
}
}
async function removeItem(itemId: string) {
await cartApi.removeItem(itemId)
items.value = items.value.filter((i) => i.id !== itemId)
}
function clearCart() {
items.value = []
}
return {
items,
isLoading,
lastError,
totalItems,
totalPrice,
isEmpty,
itemById,
fetchCart,
addItem,
removeItem,
clearCart,
}
})Testing State
Test state directly by manipulating the store after creation:
import { useCartStore } from '@/stores/cart'
import { createPinia, setActivePinia } from 'pinia'
describe('Cart Store — State', () => {
beforeEach(() => {
setActivePinia(createPinia())
})
test('initializes with empty cart', () => {
const store = useCartStore()
expect(store.items).toHaveLength(0)
expect(store.isLoading).toBe(false)
expect(store.lastError).toBeNull()
})
test('isEmpty reflects items state', () => {
const store = useCartStore()
expect(store.isEmpty).toBe(true)
store.items.push({
id: '1',
productId: 'prod-1',
name: 'Widget',
price: 9.99,
quantity: 1,
})
expect(store.isEmpty).toBe(false)
})
test('clearCart resets items to empty array', () => {
const store = useCartStore()
store.items.push({ id: '1', productId: 'p1', name: 'Item', price: 10, quantity: 2 })
store.clearCart()
expect(store.items).toHaveLength(0)
})
})Testing Getters
Getters are computed properties — test them by setting state and asserting the computed result:
describe('Cart Store — Getters', () => {
beforeEach(() => {
setActivePinia(createPinia())
})
function seedCart(store: ReturnType<typeof useCartStore>) {
store.items = [
{ id: '1', productId: 'p1', name: 'Widget', price: 10.00, quantity: 2 },
{ id: '2', productId: 'p2', name: 'Gadget', price: 25.50, quantity: 1 },
]
}
test('totalItems sums all quantities', () => {
const store = useCartStore()
seedCart(store)
expect(store.totalItems).toBe(3) // 2 + 1
})
test('totalPrice computes price × quantity for all items', () => {
const store = useCartStore()
seedCart(store)
expect(store.totalPrice).toBeCloseTo(45.50) // (10 × 2) + (25.50 × 1)
})
test('itemById returns correct item', () => {
const store = useCartStore()
seedCart(store)
const item = store.itemById('2')
expect(item?.name).toBe('Gadget')
expect(item?.price).toBe(25.50)
})
test('itemById returns undefined for missing id', () => {
const store = useCartStore()
seedCart(store)
expect(store.itemById('999')).toBeUndefined()
})
test('totalPrice is 0 for empty cart', () => {
const store = useCartStore()
expect(store.totalPrice).toBe(0)
})
})Testing Actions with Mocked API
Actions are where the real complexity lives. Mock the API module at the module level:
import { vi } from 'vitest'
import { useCartStore } from '@/stores/cart'
import { cartApi } from '@/api/cart'
vi.mock('@/api/cart', () => ({
cartApi: {
getCart: vi.fn(),
addItem: vi.fn(),
updateItem: vi.fn(),
removeItem: vi.fn(),
},
}))
describe('Cart Store — Actions', () => {
beforeEach(() => {
setActivePinia(createPinia())
vi.clearAllMocks()
})
describe('fetchCart', () => {
test('loads items from API', async () => {
const mockItems = [
{ id: '1', productId: 'p1', name: 'Widget', price: 10, quantity: 1 },
]
vi.mocked(cartApi.getCart).mockResolvedValue(mockItems)
const store = useCartStore()
await store.fetchCart('user-123')
expect(cartApi.getCart).toHaveBeenCalledWith('user-123')
expect(store.items).toEqual(mockItems)
expect(store.isLoading).toBe(false)
})
test('sets loading state during fetch', async () => {
let resolveCart!: (value: any[]) => void
const cartPromise = new Promise<any[]>((res) => { resolveCart = res })
vi.mocked(cartApi.getCart).mockReturnValue(cartPromise)
const store = useCartStore()
const fetchPromise = store.fetchCart('user-123')
expect(store.isLoading).toBe(true)
resolveCart([])
await fetchPromise
expect(store.isLoading).toBe(false)
})
test('captures error on API failure', async () => {
vi.mocked(cartApi.getCart).mockRejectedValue(new Error('Network error'))
const store = useCartStore()
await store.fetchCart('user-123')
expect(store.lastError).toBe('Network error')
expect(store.items).toHaveLength(0)
})
test('clears previous error on new fetch', async () => {
vi.mocked(cartApi.getCart)
.mockRejectedValueOnce(new Error('First failure'))
.mockResolvedValueOnce([])
const store = useCartStore()
await store.fetchCart('user-123')
expect(store.lastError).toBe('First failure')
await store.fetchCart('user-123')
expect(store.lastError).toBeNull()
})
})
describe('addItem', () => {
test('adds new item from API response', async () => {
const newItem = { id: '1', productId: 'p1', name: 'Widget', price: 10, quantity: 1 }
vi.mocked(cartApi.addItem).mockResolvedValue(newItem)
const store = useCartStore()
await store.addItem('p1')
expect(store.items).toHaveLength(1)
expect(store.items[0]).toEqual(newItem)
})
test('increments quantity for existing item', async () => {
const store = useCartStore()
store.items = [{ id: '1', productId: 'p1', name: 'Widget', price: 10, quantity: 2 }]
vi.mocked(cartApi.updateItem).mockResolvedValue(undefined)
await store.addItem('p1', 3)
expect(store.items[0].quantity).toBe(5)
expect(cartApi.updateItem).toHaveBeenCalledWith('1', 5)
expect(cartApi.addItem).not.toHaveBeenCalled()
})
})
describe('removeItem', () => {
test('removes item from state after API call', async () => {
vi.mocked(cartApi.removeItem).mockResolvedValue(undefined)
const store = useCartStore()
store.items = [
{ id: '1', productId: 'p1', name: 'Widget', price: 10, quantity: 1 },
{ id: '2', productId: 'p2', name: 'Gadget', price: 25, quantity: 1 },
]
await store.removeItem('1')
expect(store.items).toHaveLength(1)
expect(store.items[0].id).toBe('2')
expect(cartApi.removeItem).toHaveBeenCalledWith('1')
})
})
})Mocking Pinia Stores in Component Tests
When testing a component that uses a store, use createTestingPinia from @pinia/testing:
import { mount } from '@vue/test-utils'
import { createTestingPinia } from '@pinia/testing'
import { vi } from 'vitest'
import CartSummary from '@/components/CartSummary.vue'
import { useCartStore } from '@/stores/cart'
test('displays cart total and item count', () => {
const wrapper = mount(CartSummary, {
global: {
plugins: [
createTestingPinia({
initialState: {
cart: {
items: [
{ id: '1', productId: 'p1', name: 'Widget', price: 10, quantity: 2 },
{ id: '2', productId: 'p2', name: 'Gadget', price: 25, quantity: 1 },
],
},
},
}),
],
},
})
expect(wrapper.find('[data-testid="item-count"]').text()).toBe('3')
expect(wrapper.find('[data-testid="total-price"]').text()).toBe('$45.00')
})
test('remove button calls store removeItem', async () => {
const wrapper = mount(CartSummary, {
global: {
plugins: [
createTestingPinia({
createSpy: vi.fn, // spy on all actions automatically
initialState: {
cart: {
items: [{ id: '1', productId: 'p1', name: 'Widget', price: 10, quantity: 1 }],
},
},
}),
],
},
})
const store = useCartStore()
await wrapper.find('[data-testid="remove-btn"]').trigger('click')
expect(store.removeItem).toHaveBeenCalledWith('1')
})Stubbing Actions vs. Letting Them Run
createTestingPinia stubs all actions by default (they become no-ops). This is ideal for component tests where you only care that the component calls the right store method.
When you want actions to run (integration tests), pass stubActions: false:
test('full integration: add to cart updates UI', async () => {
vi.mocked(cartApi.addItem).mockResolvedValue({
id: '1', productId: 'prod-1', name: 'Widget', price: 10, quantity: 1,
})
const wrapper = mount(ProductCard, {
props: { productId: 'prod-1', name: 'Widget', price: 10 },
global: {
plugins: [
createTestingPinia({
stubActions: false, // let real action logic run
}),
],
},
})
await wrapper.find('[data-testid="add-to-cart"]').trigger('click')
await flushPromises()
const store = useCartStore()
expect(store.items).toHaveLength(1)
expect(wrapper.find('[data-testid="cart-count"]').text()).toBe('1')
})Testing Store Subscriptions and Plugins
If your store uses $subscribe or $onAction:
test('$onAction tracks action calls', async () => {
setActivePinia(createPinia())
const store = useCartStore()
const actionLog: string[] = []
store.$onAction(({ name }) => {
actionLog.push(name)
})
store.clearCart()
vi.mocked(cartApi.removeItem).mockResolvedValue(undefined)
store.items = [{ id: '1', productId: 'p1', name: 'Widget', price: 10, quantity: 1 }]
await store.removeItem('1')
expect(actionLog).toEqual(['clearCart', 'removeItem'])
})
test('$subscribe fires when state changes', async () => {
setActivePinia(createPinia())
const store = useCartStore()
const stateChanges: number[] = []
store.$subscribe(() => {
stateChanges.push(store.items.length)
})
store.items.push({ id: '1', productId: 'p1', name: 'Widget', price: 10, quantity: 1 })
await nextTick()
expect(stateChanges).toContain(1)
})Key Patterns Summary
| Test Type | Tool | When to Use |
|---|---|---|
| Store state | Direct mutation + assert | Testing getters and initial state |
| Actions (unit) | vi.mock('@/api/...') |
Testing action logic in isolation |
| Actions (in component) | createTestingPinia with createSpy: vi.fn |
Verifying component calls correct actions |
| Full integration | createTestingPinia({ stubActions: false }) |
Testing component + store + mock API together |
| Initial state | createTestingPinia({ initialState: {...} }) |
Component tests needing prefilled state |
| Subscriptions | $onAction / $subscribe callbacks |
Testing side effects of state changes |
The Pinia testing model is clean: store logic tests use real stores with mocked APIs, component tests use createTestingPinia with stubbed actions, and integration tests combine real stores with stubbed network calls. Pick the right layer for each test.