Testing Pinia Stores: Unit Testing, Mocking Stores in Component Tests

Testing Pinia Stores: Unit Testing, Mocking Stores in Component Tests

Pinia stores are plain JavaScript/TypeScript objects — state, getters, and actions — which makes them easy to test directly without mounting Vue components. For unit tests, call setActivePinia(createPinia()) in beforeEach and import your stores normally. For component tests, use createTestingPinia() from @pinia/testing to mount components with full control over store state and action behavior.

Key Takeaways

Stores are functions — call them directly in tests. useUserStore() returns a reactive store object. Modify state, call actions, assert on the result. No component mounting required.

setActivePinia(createPinia()) resets store state between tests. Without this, stores carry state from one test to the next. Put it in beforeEach.

createTestingPinia({ createSpy: vi.fn }) stubs all actions by default. In component tests, you usually want to control what actions do. This package gives you that control plus spy assertions.

store.$patch() is the clean way to set state in tests. It bypasses action logic and directly updates reactive state. Use it to set up test preconditions without triggering side effects.

Test getters independently, not through components. Computed getters are pure functions of state. Set state, read the getter, assert the value.

Pinia Store Basics

A typical Pinia store:

// src/stores/user.ts
import { defineStore } from 'pinia';
import { ref, computed } from 'vue';
import type { User } from '@/types';
import { userApi } from '@/api/users';

export const useUserStore = defineStore('user', () => {
  const users = ref<User[]>([]);
  const loading = ref(false);
  const error = ref<string | null>(null);

  const activeUsers = computed(() => users.value.filter(u => u.active));
  const userCount = computed(() => users.value.length);

  async function fetchUsers() {
    loading.value = true;
    error.value = null;
    try {
      users.value = await userApi.getAll();
    } catch (e) {
      error.value = 'Failed to load users';
    } finally {
      loading.value = false;
    }
  }

  function addUser(user: User) {
    users.value.push(user);
  }

  function removeUser(id: string) {
    users.value = users.value.filter(u => u.id !== id);
  }

  return { users, loading, error, activeUsers, userCount, fetchUsers, addUser, removeUser };
});

Unit Testing Pinia Stores

Setup

npm install --save-dev @pinia/testing vitest

The minimal test file:

// src/stores/user.test.ts
import { setActivePinia, createPinia } from 'pinia';
import { useUserStore } from './user';
import { vi, describe, it, expect, beforeEach } from 'vitest';

// Mock the API module
vi.mock('@/api/users', () => ({
  userApi: {
    getAll: vi.fn(),
  },
}));

import { userApi } from '@/api/users';
const mockUserApi = vi.mocked(userApi);

describe('useUserStore', () => {
  beforeEach(() => {
    setActivePinia(createPinia());  // Fresh Pinia instance per test
    vi.clearAllMocks();
  });

  // tests...
});

Testing Initial State

it('starts with an empty user list', () => {
  const store = useUserStore();
  expect(store.users).toEqual([]);
  expect(store.loading).toBe(false);
  expect(store.error).toBeNull();
});

Testing Actions

it('fetchUsers loads users from the API', async () => {
  const mockUsers = [
    { id: '1', name: 'Alice', active: true },
    { id: '2', name: 'Bob', active: false },
  ];
  mockUserApi.getAll.mockResolvedValue(mockUsers);

  const store = useUserStore();
  await store.fetchUsers();

  expect(store.users).toEqual(mockUsers);
  expect(store.loading).toBe(false);
  expect(store.error).toBeNull();
});

it('sets error state when fetchUsers fails', async () => {
  mockUserApi.getAll.mockRejectedValue(new Error('Network error'));

  const store = useUserStore();
  await store.fetchUsers();

  expect(store.users).toEqual([]);
  expect(store.error).toBe('Failed to load users');
  expect(store.loading).toBe(false);
});

it('shows loading state during fetchUsers', async () => {
  let resolvePromise: (value: User[]) => void;
  const promise = new Promise<User[]>(resolve => { resolvePromise = resolve; });
  mockUserApi.getAll.mockReturnValue(promise);

  const store = useUserStore();
  const fetchPromise = store.fetchUsers();

  expect(store.loading).toBe(true);  // still loading
  
  resolvePromise!([]);
  await fetchPromise;
  
  expect(store.loading).toBe(false);
});

Testing Getters

it('activeUsers returns only active users', () => {
  const store = useUserStore();

  store.$patch({
    users: [
      { id: '1', name: 'Alice', active: true },
      { id: '2', name: 'Bob', active: false },
      { id: '3', name: 'Charlie', active: true },
    ],
  });

  expect(store.activeUsers).toHaveLength(2);
  expect(store.activeUsers.map(u => u.name)).toEqual(['Alice', 'Charlie']);
});

it('userCount returns the total number of users', () => {
  const store = useUserStore();
  
  store.$patch({ users: [{ id: '1' }, { id: '2' }] });
  
  expect(store.userCount).toBe(2);
});

Testing State Mutations

it('addUser appends a user to the list', () => {
  const store = useUserStore();
  store.$patch({ users: [{ id: '1', name: 'Alice', active: true }] });

  store.addUser({ id: '2', name: 'Bob', active: true });

  expect(store.users).toHaveLength(2);
  expect(store.users[1].name).toBe('Bob');
});

it('removeUser deletes the user with the given id', () => {
  const store = useUserStore();
  store.$patch({
    users: [
      { id: '1', name: 'Alice', active: true },
      { id: '2', name: 'Bob', active: true },
    ],
  });

  store.removeUser('1');

  expect(store.users).toHaveLength(1);
  expect(store.users[0].id).toBe('2');
});

Using $patch for State Setup

$patch lets you partially update store state without calling actions:

// Object patch — merges with current state
store.$patch({
  users: [...],
  error: null,
});

// Function patch — gives you full reactive state
store.$patch((state) => {
  state.users.push({ id: '99', name: 'New User', active: true });
  state.loading = false;
});

This is the preferred way to set up test preconditions. It's faster than calling actions and doesn't require mocking the API.

Testing Stores in Component Tests

import { mount } from '@vue/test-utils';
import { createTestingPinia } from '@pinia/testing';
import { vi } from 'vitest';
import { useUserStore } from '@/stores/user';
import UserList from '@/views/UserList.vue';

describe('UserList', () => {
  it('renders users from the store', () => {
    const wrapper = mount(UserList, {
      global: {
        plugins: [
          createTestingPinia({
            initialState: {
              user: {
                users: [
                  { id: '1', name: 'Alice', active: true },
                  { id: '2', name: 'Bob', active: false },
                ],
                loading: false,
              },
            },
          }),
        ],
      },
    });

    expect(wrapper.text()).toContain('Alice');
    expect(wrapper.text()).toContain('Bob');
  });

  it('calls fetchUsers on mount', () => {
    const wrapper = mount(UserList, {
      global: {
        plugins: [createTestingPinia({ createSpy: vi.fn })],
      },
    });

    const store = useUserStore();
    expect(store.fetchUsers).toHaveBeenCalledOnce();
  });

  it('calls removeUser when delete is clicked', async () => {
    const wrapper = mount(UserList, {
      global: {
        plugins: [
          createTestingPinia({
            createSpy: vi.fn,
            initialState: {
              user: {
                users: [{ id: '1', name: 'Alice', active: true }],
              },
            },
          }),
        ],
      },
    });

    await wrapper.get('[data-testid="delete-user-1"]').trigger('click');

    const store = useUserStore();
    expect(store.removeUser).toHaveBeenCalledWith('1');
  });
});

Making Actions Real in Component Tests

Sometimes you want an action to actually run (not be stubbed):

createTestingPinia({
  createSpy: vi.fn,
  stubActions: false,  // let actions run for real
});

Or stub only specific actions:

const pinia = createTestingPinia({ createSpy: vi.fn });
const store = useUserStore(pinia);

// Override just one action
store.fetchUsers = vi.fn().mockResolvedValue(undefined);

Testing Store Subscriptions

Pinia stores support $subscribe for watching state changes and $onAction for watching actions:

it('triggers subscription on state change', () => {
  const store = useUserStore();
  const callback = vi.fn();

  store.$subscribe((mutation, state) => {
    callback(mutation.type, state.users.length);
  });

  store.addUser({ id: '1', name: 'Alice', active: true });

  expect(callback).toHaveBeenCalledWith('patch function', 1);
});

Beyond Store Tests

Pinia store tests verify business logic in isolation. They don't verify how the store integrates with routing, authentication middleware, or the actual API.

HelpMeTest tests your live application — including the full stack from browser to API to database. Start with the free tier to monitor 10 user flows continuously.

Read more