Testing Pinia Stores and Composables
In a Vue 3 application the logic that matters usually lives outside the components: in Pinia stores that hold shared state and orchestrate API calls, and in composables that package reusable reactive behaviour. Both are ordinary TypeScript with a thin reactive layer, which makes them far cheaper to test directly than through a rendered component — provided each test gets a clean instance and reactive effects are given a scope to run in. This guide covers testing stores in isolation with a fresh Pinia per test, testing composables outside components, handling getters and async actions, and deciding in component tests whether a store’s actions should run for real or be stubbed. It sits under Vue and Svelte component testing.
Root Cause Analysis
Stores are singletons by design, and that is the root of most store-testing problems. useCartStore() returns the same instance to every caller within an active Pinia, so a test that adds an item leaves it there for the next test. Tests pass alone and fail together, and the order in which they fail changes when files are shuffled — the classic signature of shared state.
Composables have a different problem. A composable that uses watch, computed or lifecycle hooks expects to run inside a component’s setup, where Vue provides an effect scope that owns those reactive effects and disposes of them when the component unmounts. Called from a plain test, it either warns about missing lifecycle context or, worse, leaks watchers that keep running into later tests.
The third problem is choosing the tier. Testing a store’s logic through a rendered component means every store case pays the cost of rendering and must be asserted through the DOM, which is awkward for things like “the total getter excludes cancelled lines”. Testing it directly is faster and clearer, leaving component tests to verify that the component displays and triggers what the store provides.
Reproducible Setup
A cart store with state, a getter, and an async action that talks to the API.
// src/stores/cart.ts
import { defineStore } from 'pinia';
type Line = { sku: string; qty: number; unitPence: number; cancelled?: boolean };
export const useCartStore = defineStore('cart', {
state: () => ({ lines: [] as Line[], status: 'idle' as 'idle' | 'saving' | 'error' }),
getters: {
totalPence: (s) => s.lines.filter((l) => !l.cancelled).reduce((n, l) => n + l.qty * l.unitPence, 0),
itemCount: (s) => s.lines.filter((l) => !l.cancelled).reduce((n, l) => n + l.qty, 0),
},
actions: {
add(sku: string, unitPence: number) {
const line = this.lines.find((l) => l.sku === sku);
if (line) line.qty++; else this.lines.push({ sku, qty: 1, unitPence });
},
async checkout() {
this.status = 'saving';
const res = await fetch('/api/checkout', { method: 'POST', body: JSON.stringify({ lines: this.lines }) });
this.status = res.ok ? 'idle' : 'error';
if (res.ok) this.lines = [];
},
},
});
Implementation
Step 1 — Activate a fresh Pinia before each test. This is the whole isolation story for store tests; every useCartStore() call after it returns a new store.
// src/stores/cart.test.ts
import { setActivePinia, createPinia } from 'pinia';
import { beforeEach, test, expect } from 'vitest';
import { useCartStore } from './cart';
beforeEach(() => setActivePinia(createPinia()));
Step 2 — Test actions and getters directly. No rendering, no DOM — call the action, read the state and getters.
test('adding the same SKU twice increments the quantity', () => {
const cart = useCartStore();
cart.add('MUG', 1200);
cart.add('MUG', 1200);
expect(cart.lines).toEqual([{ sku: 'MUG', qty: 2, unitPence: 1200 }]);
expect(cart.itemCount).toBe(2);
});
test('the total excludes cancelled lines', () => {
const cart = useCartStore();
cart.$patch({ lines: [{ sku: 'A', qty: 1, unitPence: 500 }, { sku: 'B', qty: 2, unitPence: 300, cancelled: true }] });
expect(cart.totalPence).toBe(500);
});
Step 3 — Test async actions against intercepted HTTP. The action’s contract includes the status transitions and what happens to state on success and on failure.
import { http, HttpResponse } from 'msw';
import { server } from '../../test/msw/server';
test('clears the cart after a successful checkout', async () => {
server.use(http.post('/api/checkout', () => HttpResponse.json({ ok: true })));
const cart = useCartStore();
cart.add('MUG', 1200);
const pending = cart.checkout();
expect(cart.status).toBe('saving');
await pending;
expect(cart.status).toBe('idle');
expect(cart.lines).toEqual([]);
});
test('keeps the lines and flags an error when checkout fails', async () => {
server.use(http.post('/api/checkout', () => new HttpResponse(null, { status: 500 })));
const cart = useCartStore();
cart.add('MUG', 1200);
await cart.checkout();
expect(cart.status).toBe('error');
expect(cart.lines).toHaveLength(1);
});
Asserting on the intermediate saving status is worth the extra line. Components typically show a spinner or disable a button based on it, and a store that forgets to set it — or sets it only after the request completes — produces a double-submission bug that no end-state assertion would catch. Capturing the promise before awaiting it is what gives the test a moment to look at the state in flight.
Step 4 — Run composables inside an effect scope. A scope owns the watchers and computeds the composable creates, and stopping it disposes of them — exactly what an unmounting component would do.
// src/composables/use-debounced-search.ts
import { ref, watch } from 'vue';
export function useDebouncedSearch(fetcher: (q: string) => Promise<string[]>, wait = 300) {
const query = ref('');
const results = ref<string[]>([]);
let timer: ReturnType<typeof setTimeout> | undefined;
watch(query, (q) => { clearTimeout(timer); timer = setTimeout(async () => { results.value = await fetcher(q); }, wait); });
return { query, results };
}
// src/composables/use-debounced-search.test.ts
import { effectScope, nextTick } from 'vue';
test('searches once after the query settles', async () => {
vi.useFakeTimers();
const fetcher = vi.fn().mockResolvedValue(['mug']);
const scope = effectScope();
const { query, results } = scope.run(() => useDebouncedSearch(fetcher))!;
query.value = 'm'; await nextTick();
query.value = 'mu'; await nextTick();
await vi.advanceTimersByTimeAsync(300);
expect(fetcher).toHaveBeenCalledExactlyOnceWith('mu');
expect(results.value).toEqual(['mug']);
scope.stop(); // disposes the watcher
vi.useRealTimers();
});
Step 5 — In component tests, choose real or stubbed actions deliberately. With createTestingPinia, actions are stubbed by default: the component’s call is recorded but the store logic does not run. That suits a test about the component; a test about the whole interaction wants stubActions: false.
import { createTestingPinia } from '@pinia/testing';
test('the checkout button calls checkout', async () => {
render(CartSummary, { global: { plugins: [createTestingPinia({ createSpy: vi.fn })] } });
const cart = useCartStore();
await userEvent.setup().click(screen.getByRole('button', { name: 'Check out' }));
expect(cart.checkout).toHaveBeenCalledOnce();
});
Step 6 — Seed store state for component tests with initialState. Rather than calling actions to build up state, start the component from the state the test is about.
render(CartSummary, { global: { plugins: [createTestingPinia({
initialState: { cart: { lines: [{ sku: 'MUG', qty: 2, unitPence: 1200 }] } },
stubActions: false,
})] } });
expect(screen.getByText('£24.00')).toBeInTheDocument();
A useful rule for deciding between the two modes: if the test’s name mentions the store’s behaviour — “clears the cart”, “applies the discount” — let the actions run; if it mentions only the component’s behaviour — “calls checkout”, “shows the total” — stub them. Mixing the two in one test is how suites end up with component tests that fail whenever store logic changes, even though nothing about the component did.
Verification
Confirm isolation by running the store tests in shuffled order; every seed must pass, which only happens when each test has its own Pinia.
npx vitest run src/stores --sequence.shuffle --sequence.seed=11
# ✓ 6 passed
Then confirm the composable test cleans up. Remove scope.stop() and add a second test that changes a shared ref; the first test’s watcher firing into the second is the leak the scope exists to prevent.
Troubleshooting
Symptom: “getActivePinia was called with no active Pinia”. Diagnosis: the store was used before any Pinia was activated. Fix: activate one in beforeEach for store tests, or install createTestingPinia in the render for component tests.
Symptom: a stubbed action changes nothing and the test expects it to. Diagnosis: createTestingPinia stubs actions by default. Fix: pass stubActions: false when the test is about the combined behaviour, and keep the default when it is about the component alone.
Symptom: createTestingPinia complains about createSpy. Diagnosis: it cannot find a global spy function. Fix: pass createSpy: vi.fn explicitly, which is required when Vitest globals are disabled.
Symptom: a composable warns about onMounted being called outside setup. Diagnosis: it uses lifecycle hooks, which need a component instance rather than just a scope. Fix: test it through a small host component that calls the composable in its setup, or refactor lifecycle-dependent logic into a separate composable.
FAQ
Should stores be tested with setup stores or option stores differently?
No — the testing approach is the same. Setup stores define state with ref and actions as functions, but useStore() returns the same shape and a fresh Pinia resets them identically. Choose the style for the codebase’s sake, not the tests’.
How do I test store plugins?
Create a Pinia, install the plugin with pinia.use(...), activate it, and assert on the behaviour the plugin adds — persistence, logging, reset methods. Keep plugin tests separate from store tests, so a plugin change does not break unrelated store cases.
Is mocking fetch inside a store action acceptable?
Prefer intercepting the request with MSW, as in Step 3, which keeps the action’s real request-building code under test. Mocking fetch directly works but couples the test to the transport, and it cannot catch a malformed request body the server would reject.
How do Svelte stores compare?
Svelte’s writable stores and Svelte 5’s rune-based state in .svelte.ts modules are plain modules, so they need no equivalent of setActivePinia — but module-level state still leaks, and the same fresh-instance discipline applies, as covered in testing Svelte components with Vitest.
Related
- Back to Vue & Svelte Component Testing
- Testing Vue 3 components with Testing Library — rendering components that use these stores.
- Testing debounce and throttle with fake timers — the timing technique used in Step 4.
- Testing React Query hooks with a fresh cache — the same isolation principle for server-state caches.