Pinia Store Testing in Vue 3 and Nuxt
Pinia is the official state management library for Vue 3, and testing Pinia stores is straightforward once you understand the setup requirements. Unlike Vuex, Pinia stores are regular composables — you can import and call them directly in tests without complex mocking infrastructure.
This guide covers testing Pinia stores in isolation, testing components that use stores, and mocking stores in complex scenarios.
Setup
npm install -D vitest @vue/test-utils pinia @pinia/testing happy-domvitest.config.ts:
import { defineConfig } from 'vitest/config';
import vue from '@vitejs/plugin-vue';
export default defineConfig({
plugins: [vue()],
test: {
environment: 'happy-dom',
globals: true,
},
});Testing Stores Directly with setActivePinia
Before testing any store, create a fresh Pinia instance:
// stores/cart.ts
import { defineStore } from 'pinia';
interface CartItem {
id: number;
name: string;
price: number;
quantity: number;
}
export const useCartStore = defineStore('cart', {
state: () => ({
items: [] as CartItem[],
couponCode: null as string | null,
discount: 0,
}),
getters: {
itemCount: (state) => state.items.reduce((sum, item) => sum + item.quantity, 0),
subtotal: (state) => state.items.reduce((sum, item) => sum + item.price * item.quantity, 0),
total: (state) => {
const subtotal = state.items.reduce((sum, item) => sum + item.price * item.quantity, 0);
return subtotal * (1 - state.discount);
},
},
actions: {
addItem(item: Omit<CartItem, 'quantity'>) {
const existing = this.items.find((i) => i.id === item.id);
if (existing) {
existing.quantity++;
} else {
this.items.push({ ...item, quantity: 1 });
}
},
removeItem(id: number) {
this.items = this.items.filter((i) => i.id !== id);
},
async applyCoupon(code: string) {
const response = await fetch(`/api/coupons/${code}`);
if (!response.ok) throw new Error('Invalid coupon code');
const { discount } = await response.json();
this.couponCode = code;
this.discount = discount;
},
clearCart() {
this.items = [];
this.couponCode = null;
this.discount = 0;
},
},
});// tests/stores/cart.test.ts
import { describe, it, expect, beforeEach, vi } from 'vitest';
import { setActivePinia, createPinia } from 'pinia';
import { useCartStore } from '~/stores/cart';
describe('useCartStore', () => {
beforeEach(() => {
// Create a fresh Pinia before each test
setActivePinia(createPinia());
});
describe('state', () => {
it('starts with empty cart', () => {
const cart = useCartStore();
expect(cart.items).toHaveLength(0);
expect(cart.couponCode).toBeNull();
expect(cart.discount).toBe(0);
});
});
describe('getters', () => {
it('calculates itemCount correctly', () => {
const cart = useCartStore();
cart.addItem({ id: 1, name: 'Widget', price: 10 });
cart.addItem({ id: 1, name: 'Widget', price: 10 }); // Adds to quantity
cart.addItem({ id: 2, name: 'Gadget', price: 20 });
expect(cart.itemCount).toBe(3);
});
it('calculates subtotal correctly', () => {
const cart = useCartStore();
cart.addItem({ id: 1, name: 'Widget', price: 10 });
cart.addItem({ id: 1, name: 'Widget', price: 10 }); // quantity = 2
cart.addItem({ id: 2, name: 'Gadget', price: 20 }); // quantity = 1
expect(cart.subtotal).toBe(40); // 10*2 + 20*1
});
it('applies discount to total', async () => {
const cart = useCartStore();
cart.addItem({ id: 1, name: 'Widget', price: 100 });
// Directly mutate state to simulate applied coupon
cart.$patch({ discount: 0.1 }); // 10% off
expect(cart.total).toBe(90);
});
});
describe('actions', () => {
it('adds new item 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', price: 10, quantity: 1 });
});
it('increments quantity for existing item', () => {
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);
});
it('removes item by id', () => {
const cart = useCartStore();
cart.addItem({ id: 1, name: 'Widget', price: 10 });
cart.addItem({ id: 2, name: 'Gadget', price: 20 });
cart.removeItem(1);
expect(cart.items).toHaveLength(1);
expect(cart.items[0].id).toBe(2);
});
it('clears entire cart', () => {
const cart = useCartStore();
cart.addItem({ id: 1, name: 'Widget', price: 10 });
cart.$patch({ couponCode: 'SAVE10', discount: 0.1 });
cart.clearCart();
expect(cart.items).toHaveLength(0);
expect(cart.couponCode).toBeNull();
expect(cart.discount).toBe(0);
});
describe('applyCoupon', () => {
beforeEach(() => {
global.fetch = vi.fn();
});
it('applies valid coupon code', async () => {
vi.mocked(fetch).mockResolvedValue({
ok: true,
json: () => Promise.resolve({ discount: 0.15 }),
} as Response);
const cart = useCartStore();
await cart.applyCoupon('SAVE15');
expect(cart.couponCode).toBe('SAVE15');
expect(cart.discount).toBe(0.15);
});
it('throws on invalid coupon code', async () => {
vi.mocked(fetch).mockResolvedValue({
ok: false,
} as Response);
const cart = useCartStore();
await expect(cart.applyCoupon('INVALID')).rejects.toThrow('Invalid coupon code');
expect(cart.couponCode).toBeNull();
expect(cart.discount).toBe(0);
});
});
});
});Testing with Setup Stores (Composition API Style)
Pinia also supports composition API style stores. These test exactly the same way:
// stores/user.ts
import { defineStore } from 'pinia';
import { ref, computed } from 'vue';
export const useUserStore = defineStore('user', () => {
const currentUser = ref<{ id: string; name: string; role: string } | null>(null);
const isLoggedIn = computed(() => currentUser.value !== null);
const isAdmin = computed(() => currentUser.value?.role === 'admin');
async function login(email: string, password: string) {
const response = await fetch('/api/auth/login', {
method: 'POST',
body: JSON.stringify({ email, password }),
});
if (!response.ok) throw new Error('Login failed');
currentUser.value = await response.json();
}
function logout() {
currentUser.value = null;
}
return { currentUser, isLoggedIn, isAdmin, login, logout };
});// tests/stores/user.test.ts
import { describe, it, expect, beforeEach, vi } from 'vitest';
import { setActivePinia, createPinia } from 'pinia';
import { useUserStore } from '~/stores/user';
describe('useUserStore', () => {
beforeEach(() => {
setActivePinia(createPinia());
global.fetch = vi.fn();
});
it('starts with no user', () => {
const store = useUserStore();
expect(store.currentUser).toBeNull();
expect(store.isLoggedIn).toBe(false);
expect(store.isAdmin).toBe(false);
});
it('sets user on successful login', async () => {
vi.mocked(fetch).mockResolvedValue({
ok: true,
json: () => Promise.resolve({ id: 'u1', name: 'Alice', role: 'user' }),
} as Response);
const store = useUserStore();
await store.login('alice@example.com', 'password');
expect(store.currentUser).toEqual({ id: 'u1', name: 'Alice', role: 'user' });
expect(store.isLoggedIn).toBe(true);
expect(store.isAdmin).toBe(false);
});
it('recognizes admin role', async () => {
vi.mocked(fetch).mockResolvedValue({
ok: true,
json: () => Promise.resolve({ id: 'u2', name: 'Bob', role: 'admin' }),
} as Response);
const store = useUserStore();
await store.login('bob@example.com', 'password');
expect(store.isAdmin).toBe(true);
});
it('clears user on logout', async () => {
vi.mocked(fetch).mockResolvedValue({
ok: true,
json: () => Promise.resolve({ id: 'u1', name: 'Alice', role: 'user' }),
} as Response);
const store = useUserStore();
await store.login('alice@example.com', 'password');
store.logout();
expect(store.currentUser).toBeNull();
expect(store.isLoggedIn).toBe(false);
});
});Mocking Pinia Stores in Component Tests
Use @pinia/testing to create a test Pinia that auto-mocks store actions:
<!-- components/CartSummary.vue -->
<script setup lang="ts">
import { useCartStore } from '~/stores/cart';
const cart = useCartStore();
</script>
<template>
<div>
<p data-testid="item-count">{{ cart.itemCount }} items</p>
<p data-testid="total">€{{ cart.total.toFixed(2) }}</p>
<button data-testid="clear-btn" @click="cart.clearCart">Clear</button>
</div>
</template>// tests/components/CartSummary.test.ts
import { describe, it, expect, vi } from 'vitest';
import { mount } from '@vue/test-utils';
import { createTestingPinia } from '@pinia/testing';
import CartSummary from '~/components/CartSummary.vue';
import { useCartStore } from '~/stores/cart';
describe('CartSummary', () => {
it('displays item count and total from store', () => {
const wrapper = mount(CartSummary, {
global: {
plugins: [
createTestingPinia({
initialState: {
cart: {
items: [
{ id: 1, name: 'Widget', price: 10, quantity: 2 },
{ id: 2, name: 'Gadget', price: 20, quantity: 1 },
],
discount: 0,
},
},
}),
],
},
});
expect(wrapper.find('[data-testid="item-count"]').text()).toBe('3 items');
expect(wrapper.find('[data-testid="total"]').text()).toBe('€40.00');
});
it('calls clearCart when clear button clicked', async () => {
const wrapper = mount(CartSummary, {
global: {
plugins: [createTestingPinia({ createSpy: vi.fn })],
},
});
const cart = useCartStore();
await wrapper.find('[data-testid="clear-btn"]').trigger('click');
expect(cart.clearCart).toHaveBeenCalledOnce();
});
});Testing Store Interactions Between Stores
Real apps often have stores that interact with each other. Test those interactions explicitly:
// stores/notifications.ts
export const useNotificationsStore = defineStore('notifications', () => {
const messages = ref<{ id: string; text: string; type: 'success' | 'error' }[]>([]);
function add(text: string, type: 'success' | 'error') {
messages.value.push({ id: crypto.randomUUID(), text, type });
}
return { messages, add };
});// stores/orders.ts
export const useOrdersStore = defineStore('orders', () => {
const notifications = useNotificationsStore();
async function placeOrder(items: CartItem[]) {
try {
await fetch('/api/orders', { method: 'POST', body: JSON.stringify({ items }) });
notifications.add('Order placed successfully!', 'success');
} catch {
notifications.add('Failed to place order', 'error');
}
}
return { placeOrder };
});// tests/stores/orders.test.ts
import { describe, it, expect, beforeEach, vi } from 'vitest';
import { setActivePinia, createPinia } from 'pinia';
import { useOrdersStore } from '~/stores/orders';
import { useNotificationsStore } from '~/stores/notifications';
describe('useOrdersStore', () => {
beforeEach(() => {
setActivePinia(createPinia());
global.fetch = vi.fn();
});
it('adds success notification on successful order', async () => {
vi.mocked(fetch).mockResolvedValue({ ok: true } as Response);
const orders = useOrdersStore();
const notifications = useNotificationsStore();
await orders.placeOrder([{ id: 1, name: 'Widget', price: 10, quantity: 1 }]);
expect(notifications.messages).toHaveLength(1);
expect(notifications.messages[0].type).toBe('success');
});
it('adds error notification on failed order', async () => {
vi.mocked(fetch).mockRejectedValue(new Error('Network error'));
const orders = useOrdersStore();
const notifications = useNotificationsStore();
await orders.placeOrder([]);
expect(notifications.messages[0].type).toBe('error');
});
});Nuxt-Specific: Testing Pinia with nuxt/test-utils
In Nuxt 3, use mockNuxtImport for stores that depend on Nuxt composables:
import { mockNuxtImport } from '@nuxt/test-utils/runtime';
import { setActivePinia, createPinia } from 'pinia';
beforeEach(() => {
setActivePinia(createPinia());
});Summary
Testing Pinia stores in Vue 3:
- Always call
setActivePinia(createPinia())inbeforeEach— this gives each test a fresh, isolated store - Test stores directly — import and call them without mounting a component
- Use
$patchto set initial state in tests instead of going through actions - Use
@pinia/testingfor component tests — it auto-mocks actions and lets you set initial state - Test store interactions — when one store calls another, test both sides
Pinia's design makes stores easy to test. The composable syntax, explicit state, and predictable action flow mean you can cover all store behavior with fast, isolated unit tests.