Testing Svelte Stores: Writable, Readable, Derived, and Custom Stores
Svelte stores are observable values with a simple contract: a subscribe method, and optionally set and update. Because stores are plain JavaScript objects, they're easy to test — subscribe, trigger changes, and assert the emitted values.
Setup
npm install -D vitest// vite.config.ts
import { defineConfig } from 'vite';
import { svelte } from '@sveltejs/vite-plugin-svelte';
export default defineConfig({
plugins: [svelte({ hot: !process.env.VITEST })],
test: {
globals: true,
environment: 'jsdom',
},
});Testing Writable Stores
// src/stores/counter.ts
import { writable } from 'svelte/store';
export const count = writable(0);
export function increment() {
count.update((n) => n + 1);
}
export function reset() {
count.set(0);
}// src/stores/counter.test.ts
import { get } from 'svelte/store';
import { describe, it, expect, beforeEach } from 'vitest';
import { count, increment, reset } from './counter';
describe('counter store', () => {
beforeEach(() => reset());
it('starts at 0', () => {
expect(get(count)).toBe(0);
});
it('increments by 1', () => {
increment();
expect(get(count)).toBe(1);
});
it('increments multiple times', () => {
increment();
increment();
increment();
expect(get(count)).toBe(3);
});
it('resets to 0', () => {
increment();
increment();
reset();
expect(get(count)).toBe(0);
});
});get() from svelte/store reads the current value synchronously — essential for testing without subscriptions.
Collecting Emitted Values
For stores that change over time, collect all emitted values with a subscription:
import { writable } from 'svelte/store';
import { describe, it, expect } from 'vitest';
function collectValues<T>(store: { subscribe: (fn: (v: T) => void) => () => void }) {
const values: T[] = [];
const unsubscribe = store.subscribe((v) => values.push(v));
return { values, unsubscribe };
}
describe('writable store subscription', () => {
it('emits initial value and updates', () => {
const name = writable('Alice');
const { values, unsubscribe } = collectValues(name);
name.set('Bob');
name.set('Charlie');
unsubscribe();
expect(values).toEqual(['Alice', 'Bob', 'Charlie']);
});
});Testing Readable Stores
// src/stores/clock.ts
import { readable } from 'svelte/store';
export const time = readable(new Date(), (set) => {
const interval = setInterval(() => set(new Date()), 1000);
return () => clearInterval(interval);
});import { get } from 'svelte/store';
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
describe('clock store', () => {
beforeEach(() => {
vi.useFakeTimers();
vi.setSystemTime(new Date('2024-01-01T12:00:00Z'));
});
afterEach(() => {
vi.useRealTimers();
});
it('emits current time on subscribe', async () => {
const { time } = await import('./clock');
const initial = get(time);
expect(initial.toISOString()).toBe('2024-01-01T12:00:00.000Z');
});
it('updates every second', async () => {
const { time } = await import('./clock');
const values: Date[] = [];
const unsub = time.subscribe((v) => values.push(v));
vi.advanceTimersByTime(3000);
unsub();
expect(values).toHaveLength(4); // initial + 3 updates
expect(values[3].getSeconds()).toBe(3);
});
});Testing Derived Stores
// src/stores/cart.ts
import { writable, derived } from 'svelte/store';
export interface CartItem {
id: string;
name: string;
price: number;
quantity: number;
}
export const cartItems = writable<CartItem[]>([]);
export const cartTotal = derived(
cartItems,
($items) => $items.reduce((sum, item) => sum + item.price * item.quantity, 0)
);
export const cartCount = derived(
cartItems,
($items) => $items.reduce((sum, item) => sum + item.quantity, 0)
);
export const isEmpty = derived(cartItems, ($items) => $items.length === 0);import { get } from 'svelte/store';
import { describe, it, expect, beforeEach } from 'vitest';
import { cartItems, cartTotal, cartCount, isEmpty } from './cart';
describe('cart derived stores', () => {
beforeEach(() => cartItems.set([]));
it('isEmpty is true when cart is empty', () => {
expect(get(isEmpty)).toBe(true);
});
it('calculates total from multiple items', () => {
cartItems.set([
{ id: '1', name: 'Widget', price: 10, quantity: 2 },
{ id: '2', name: 'Gadget', price: 25, quantity: 1 },
]);
expect(get(cartTotal)).toBe(45); // 10*2 + 25*1
});
it('counts total quantity', () => {
cartItems.set([
{ id: '1', name: 'Widget', price: 10, quantity: 3 },
{ id: '2', name: 'Gadget', price: 25, quantity: 2 },
]);
expect(get(cartCount)).toBe(5);
});
it('updates derived values when source changes', () => {
cartItems.set([{ id: '1', name: 'Widget', price: 10, quantity: 1 }]);
expect(get(cartTotal)).toBe(10);
cartItems.update((items) =>
items.map((item) => (item.id === '1' ? { ...item, quantity: 3 } : item))
);
expect(get(cartTotal)).toBe(30);
});
});Testing Custom Stores
Custom stores expose domain-specific methods:
// src/stores/notifications.ts
import { writable } from 'svelte/store';
export interface Notification {
id: string;
message: string;
type: 'success' | 'error' | 'info';
}
function createNotifications() {
const { subscribe, update } = writable<Notification[]>([]);
return {
subscribe,
add(message: string, type: Notification['type'] = 'info') {
update((n) => [
...n,
{ id: crypto.randomUUID(), message, type },
]);
},
dismiss(id: string) {
update((n) => n.filter((notif) => notif.id !== id));
},
clear() {
update(() => []);
},
};
}
export const notifications = createNotifications();import { get } from 'svelte/store';
import { describe, it, expect, beforeEach } from 'vitest';
import { notifications } from './notifications';
describe('notifications store', () => {
beforeEach(() => notifications.clear());
it('starts empty', () => {
expect(get(notifications)).toHaveLength(0);
});
it('adds a notification', () => {
notifications.add('Upload complete', 'success');
const items = get(notifications);
expect(items).toHaveLength(1);
expect(items[0].message).toBe('Upload complete');
expect(items[0].type).toBe('success');
expect(items[0].id).toBeTruthy();
});
it('dismisses a specific notification', () => {
notifications.add('First');
notifications.add('Second');
const [first] = get(notifications);
notifications.dismiss(first.id);
const remaining = get(notifications);
expect(remaining).toHaveLength(1);
expect(remaining[0].message).toBe('Second');
});
it('clears all notifications', () => {
notifications.add('One');
notifications.add('Two');
notifications.clear();
expect(get(notifications)).toHaveLength(0);
});
});Testing Async Stores
// src/stores/user.ts
import { writable } from 'svelte/store';
interface UserStore {
data: User | null;
loading: boolean;
error: string | null;
}
function createUserStore() {
const { subscribe, set, update } = writable<UserStore>({
data: null,
loading: false,
error: null,
});
return {
subscribe,
async fetch(userId: string) {
update((s) => ({ ...s, loading: true, error: null }));
try {
const res = await fetch(`/api/users/${userId}`);
if (!res.ok) throw new Error('Failed to fetch user');
const data = await res.json();
set({ data, loading: false, error: null });
} catch (err) {
update((s) => ({
...s,
loading: false,
error: err instanceof Error ? err.message : 'Unknown error',
}));
}
},
reset() {
set({ data: null, loading: false, error: null });
},
};
}
export const userStore = createUserStore();import { get } from 'svelte/store';
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { userStore } from './user';
describe('user store', () => {
beforeEach(() => userStore.reset());
it('sets loading state during fetch', async () => {
const fetchPromise = new Promise<Response>((resolve) =>
setTimeout(() => resolve({ ok: true, json: async () => ({ id: '1', name: 'Alice' }) } as Response), 50)
);
vi.spyOn(global, 'fetch').mockReturnValue(fetchPromise);
const fetchCall = userStore.fetch('1');
expect(get(userStore).loading).toBe(true);
await fetchCall;
expect(get(userStore).loading).toBe(false);
});
it('populates data on success', async () => {
vi.spyOn(global, 'fetch').mockResolvedValue({
ok: true,
json: async () => ({ id: '1', name: 'Alice' }),
} as Response);
await userStore.fetch('1');
const state = get(userStore);
expect(state.data).toEqual({ id: '1', name: 'Alice' });
expect(state.error).toBeNull();
});
it('sets error on failed fetch', async () => {
vi.spyOn(global, 'fetch').mockResolvedValue({ ok: false } as Response);
await userStore.fetch('999');
const state = get(userStore);
expect(state.error).toBe('Failed to fetch user');
expect(state.data).toBeNull();
});
});Mocking Stores in Component Tests
// Mock stores when testing components that depend on them
import { writable } from 'svelte/store';
import { vi } from 'vitest';
vi.mock('./stores/user', () => ({
userStore: {
subscribe: writable({
data: { id: '1', name: 'Test User' },
loading: false,
error: null,
}).subscribe,
},
}));Svelte 5 Runes
With Svelte 5, $state and $derived runes replace stores for component-local state. For shared application state, use a reactive class pattern:
// src/state/counter.svelte.ts
export class CounterState {
count = $state(0);
doubled = $derived(this.count * 2);
increment() {
this.count++;
}
reset() {
this.count = 0;
}
}
export const counter = new CounterState();// Test rune-based state
import { describe, it, expect, beforeEach } from 'vitest';
import { counter } from './counter.svelte';
describe('CounterState', () => {
beforeEach(() => counter.reset());
it('starts at 0', () => {
expect(counter.count).toBe(0);
});
it('increments', () => {
counter.increment();
expect(counter.count).toBe(1);
expect(counter.doubled).toBe(2);
});
});Key Patterns
- Use
get()fromsvelte/storefor synchronous one-off reads - Subscribe and collect values to test sequences of state changes
- Always unsubscribe after tests — leaked subscriptions cause false positives
- Use
beforeEachto reset stores to a known state before each test - Wrap stores in factory functions to create isolated instances per test
- For Svelte 5 runes, test the class methods directly — no subscription needed
Store logic is usually pure and synchronous, making it the most testable layer in a Svelte application. Thorough store tests catch the majority of bugs before you ever need a component or E2E test.