Pinia Store Testing Strategies: Actions, Getters, and Component Integration

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-utils

The @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.

Read more

Start now free