Testing Pinia Stores: Unit and Integration Patterns
Pinia stores are plain JavaScript objects with reactivity — testing them does not require mounting a Vue component. This guide covers direct store unit tests, mocking stores in component tests, and testing async actions with side effects like API calls and notifications.
Key Takeaways
Test stores directly without mounting components. Call useMyStore() inside setActivePinia(createPinia()) — no component needed, no mounting overhead.
Reset state in beforeEach with store.$reset(). Pinia stores persist state between tests unless you reset them — this is the single biggest source of flaky Pinia tests.
Mock API calls at the module level, not inside actions. Spy on the fetch/axios module rather than on store methods — this keeps tests decoupled from store internals.
Use createTestingPinia for component-level store mocks. It stubs all actions by default and lets you set initialState per test without real store logic running.
Test getters as computed values, not functions. After setting state, read the getter property directly — getters are reactive and update synchronously when state changes.
Pinia is the official state management library for Vue 3. Its stores are simple, composable, and fully typed — which also makes them straightforward to test. This guide covers the complete Pinia testing workflow, from isolated store unit tests to integration tests with components.
Setup: Activating Pinia in Tests
Pinia requires an active instance to use stores outside a Vue app. Create a fresh Pinia instance before each test:
import { setActivePinia, createPinia } from 'pinia'
import { beforeEach, describe, test, expect } from 'vitest'
import { useCartStore } from '@/stores/cart'
describe('Cart Store', () => {
beforeEach(() => {
setActivePinia(createPinia())
})
test('starts with empty cart', () => {
const cart = useCartStore()
expect(cart.items).toEqual([])
expect(cart.totalPrice).toBe(0)
})
})setActivePinia(createPinia()) in beforeEach ensures each test gets a clean store instance. Without this, state from one test leaks into the next.
Testing State
State in Pinia stores is directly readable and writable in tests. Verify initial state and test mutations:
test('initial state has sensible defaults', () => {
const store = useUserStore()
expect(store.currentUser).toBeNull()
expect(store.isLoading).toBe(false)
expect(store.error).toBeNull()
})
test('state can be set directly for test setup', () => {
const store = useUserStore()
store.currentUser = { id: 1, name: 'Alice', role: 'admin' }
store.isLoading = false
expect(store.currentUser.name).toBe('Alice')
expect(store.isLoggedIn).toBe(true) // derived from currentUser
})Direct state mutation is intentional in tests — it is the fastest way to set up the preconditions for an assertion without running through actions.
Testing Getters
Getters are computed properties derived from state. Test them by setting state and reading the getter:
// Store definition
export const useCartStore = defineStore('cart', () => {
const items = ref<CartItem[]>([])
const totalPrice = computed(() =>
items.value.reduce((sum, item) => sum + item.price * item.quantity, 0)
)
const itemCount = computed(() =>
items.value.reduce((sum, item) => sum + item.quantity, 0)
)
const isEmpty = computed(() => items.value.length === 0)
return { items, totalPrice, itemCount, isEmpty }
})
// Tests
describe('Cart getters', () => {
beforeEach(() => setActivePinia(createPinia()))
test('totalPrice sums price * quantity for all items', () => {
const cart = useCartStore()
cart.items = [
{ id: 1, name: 'Widget', price: 10, quantity: 2 },
{ id: 2, name: 'Gadget', price: 25, quantity: 1 },
]
expect(cart.totalPrice).toBe(45)
})
test('isEmpty returns true when cart has no items', () => {
const cart = useCartStore()
expect(cart.isEmpty).toBe(true)
cart.items = [{ id: 1, name: 'Widget', price: 10, quantity: 1 }]
expect(cart.isEmpty).toBe(false)
})
test('itemCount accounts for quantity, not just number of products', () => {
const cart = useCartStore()
cart.items = [
{ id: 1, name: 'Widget', price: 10, quantity: 3 },
{ id: 2, name: 'Gadget', price: 25, quantity: 2 },
]
expect(cart.itemCount).toBe(5)
})
})Testing Actions
Actions are functions that mutate state. Test them by calling the action and asserting on the resulting state:
describe('Cart actions', () => {
beforeEach(() => setActivePinia(createPinia()))
test('addItem appends new product to cart', () => {
const cart = useCartStore()
cart.addItem({ id: 1, name: 'Widget', price: 10 })
expect(cart.items).toHaveLength(1)
expect(cart.items[0]).toMatchObject({ id: 1, name: 'Widget', quantity: 1 })
})
test('addItem increments quantity if item already exists', () => {
const cart = useCartStore()
cart.addItem({ id: 1, name: 'Widget', price: 10 })
cart.addItem({ id: 1, name: 'Widget', price: 10 })
expect(cart.items).toHaveLength(1)
expect(cart.items[0].quantity).toBe(2)
})
test('removeItem deletes product from cart', () => {
const cart = useCartStore()
cart.items = [{ id: 1, name: 'Widget', price: 10, quantity: 1 }]
cart.removeItem(1)
expect(cart.items).toHaveLength(0)
})
test('clearCart empties all items', () => {
const cart = useCartStore()
cart.items = [
{ id: 1, name: 'Widget', price: 10, quantity: 2 },
{ id: 2, name: 'Gadget', price: 25, quantity: 1 },
]
cart.clearCart()
expect(cart.items).toEqual([])
})
})Testing Async Actions
Async actions that call APIs need mocked fetch/axios calls. Spy on the API module and assert on both the state and the mock calls:
import { vi } from 'vitest'
import * as api from '@/api/users'
describe('User store async actions', () => {
beforeEach(() => {
setActivePinia(createPinia())
vi.restoreAllMocks()
})
test('fetchUser loads user and sets state', async () => {
const mockUser = { id: 1, name: 'Alice', email: 'alice@example.com' }
vi.spyOn(api, 'getUser').mockResolvedValue(mockUser)
const store = useUserStore()
expect(store.isLoading).toBe(false)
const promise = store.fetchUser(1)
expect(store.isLoading).toBe(true)
await promise
expect(store.isLoading).toBe(false)
expect(store.currentUser).toEqual(mockUser)
expect(store.error).toBeNull()
expect(api.getUser).toHaveBeenCalledWith(1)
})
test('fetchUser sets error state on API failure', async () => {
vi.spyOn(api, 'getUser').mockRejectedValue(new Error('Network error'))
const store = useUserStore()
await store.fetchUser(1)
expect(store.currentUser).toBeNull()
expect(store.error).toBe('Network error')
expect(store.isLoading).toBe(false)
})
})Testing Store Side Effects
Actions sometimes trigger side effects beyond state mutation — sending analytics events, showing notifications, or updating localStorage. Test these by mocking the side-effect interfaces:
import { useNotificationStore } from '@/stores/notifications'
test('checkout action shows success notification on completion', async () => {
setActivePinia(createPinia())
vi.spyOn(api, 'submitOrder').mockResolvedValue({ orderId: 'abc-123' })
const cart = useCartStore()
const notifications = useNotificationStore()
cart.items = [{ id: 1, name: 'Widget', price: 10, quantity: 1 }]
await cart.checkout()
// Assert notification store was updated
expect(notifications.messages).toHaveLength(1)
expect(notifications.messages[0].type).toBe('success')
expect(notifications.messages[0].text).toContain('Order placed')
})
test('checkout clears cart after successful order', async () => {
setActivePinia(createPinia())
vi.spyOn(api, 'submitOrder').mockResolvedValue({ orderId: 'abc-123' })
const cart = useCartStore()
cart.items = [{ id: 1, name: 'Widget', price: 10, quantity: 1 }]
await cart.checkout()
expect(cart.items).toEqual([])
})Mocking Stores in Component Tests
When testing components that use Pinia stores, you typically do not want real store logic running. Use @pinia/testing's createTestingPinia:
import { mount } from '@vue/test-utils'
import { createTestingPinia } from '@pinia/testing'
import { useCartStore } from '@/stores/cart'
import CartSummary from '@/components/CartSummary.vue'
test('shows item count from cart store', () => {
const wrapper = mount(CartSummary, {
global: {
plugins: [
createTestingPinia({
initialState: {
cart: {
items: [
{ id: 1, name: 'Widget', price: 10, quantity: 3 },
],
},
},
}),
],
},
})
expect(wrapper.find('.item-count').text()).toBe('3 items')
})
test('calls checkout action when button clicked', async () => {
const wrapper = mount(CartSummary, {
global: {
plugins: [createTestingPinia({ createSpy: vi.fn })],
},
})
const cart = useCartStore()
await wrapper.find('.checkout-btn').trigger('click')
expect(cart.checkout).toHaveBeenCalledOnce()
})createTestingPinia stubs all actions with spy functions by default. You can control initialState per test and assert that actions were called without running their implementation.
Allowing Specific Actions to Run
Sometimes you want to test that a component responds to real store state changes after an action:
createTestingPinia({
stubActions: false, // run real action implementations
initialState: { cart: { items: [] } },
})Use this for integration tests where the component-store interaction is what you are verifying.
Store Composition: Testing Stores That Use Other Stores
Pinia supports cross-store composition. Test the dependent store by providing controlled state in the parent store:
// orderStore uses cartStore and userStore
export const useOrderStore = defineStore('order', () => {
const cart = useCartStore()
const user = useUserStore()
const canCheckout = computed(
() => !cart.isEmpty && user.isLoggedIn
)
return { canCheckout }
})
test('canCheckout is false when cart is empty', () => {
setActivePinia(createPinia())
const cart = useCartStore()
const user = useUserStore()
const order = useOrderStore()
user.currentUser = { id: 1, name: 'Alice' }
cart.items = []
expect(order.canCheckout).toBe(false)
})
test('canCheckout is true when user logged in and cart has items', () => {
setActivePinia(createPinia())
const cart = useCartStore()
const user = useUserStore()
const order = useOrderStore()
user.currentUser = { id: 1, name: 'Alice' }
cart.items = [{ id: 1, name: 'Widget', price: 10, quantity: 1 }]
expect(order.canCheckout).toBe(true)
})Since all stores share the same Pinia instance (created in beforeEach), cross-store state dependencies work naturally in tests.
Testing $patch and $reset
Pinia's $patch applies partial state updates. Test it to verify that partial updates work correctly:
test('$patch applies partial state update', () => {
const store = useUserStore()
store.currentUser = { id: 1, name: 'Alice', role: 'viewer' }
store.$patch({ currentUser: { role: 'admin' } })
expect(store.currentUser.role).toBe('admin')
expect(store.currentUser.name).toBe('Alice') // unchanged
})
test('$reset restores initial state', () => {
const store = useUserStore()
store.currentUser = { id: 1, name: 'Alice' }
store.error = 'Something went wrong'
store.$reset()
expect(store.currentUser).toBeNull()
expect(store.error).toBeNull()
})Pinia stores are one of the most testable patterns in the Vue ecosystem. Direct state access, synchronous getters, and clean action boundaries make it possible to write fast, reliable tests without complex mocking infrastructure.