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 vitestThe 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.