Testing Svelte Stores: Writable, Readable and Derived (2026)
Svelte stores hold application state. When store logic is wrong — a derived value computes incorrectly, a custom store doesn't clean up subscriptions, an async store gets stuck in a loading state — components using that store will fail in ways that are hard to trace. Testing stores in isolation catches these bugs before they reach components.
This guide covers testing all Svelte store types with Vitest.
Setup
npm install -D vitest @testing-library/svelte jsdom// vitest.config.ts
import { defineConfig } from 'vitest/config';
import { svelte } from '@sveltejs/vite-plugin-svelte';
export default defineConfig({
plugins: [svelte({ hot: !process.env.VITEST })],
test: {
environment: 'jsdom',
globals: true,
},
});For store tests, you often don't need jsdom — pure store logic runs in Node. Set environment: 'node' for faster store-only tests:
// src/lib/stores/cart.test.ts
// @vitest-environment node
import { describe, it, expect } from 'vitest';Testing Writable Stores
The simplest test: set a value, read it.
// src/lib/stores/counter.ts
import { writable } from 'svelte/store';
export const count = writable(0);
export function increment() {
count.update((n) => n + 1);
}
export function decrement() {
count.update((n) => n - 1);
}
export function reset() {
count.set(0);
}// src/lib/stores/counter.test.ts
import { describe, it, expect, beforeEach } from 'vitest';
import { get } from 'svelte/store';
import { count, increment, decrement, 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('decrements by 1', () => {
increment();
decrement();
expect(get(count)).toBe(0);
});
it('decrements below 0', () => {
decrement();
expect(get(count)).toBe(-1);
});
it('resets to 0 after multiple increments', () => {
increment();
increment();
increment();
reset();
expect(get(count)).toBe(0);
});
it('handles multiple operations correctly', () => {
increment();
increment();
increment();
decrement();
expect(get(count)).toBe(2);
});
});The get() function from svelte/store reads the current store value synchronously. Use it instead of subscriptions for simple value assertions.
Testing Custom Store Logic
Custom stores expose their own API through factory functions. Test the factory-returned object directly.
// src/lib/stores/cart.ts
import { writable, derived, get } from 'svelte/store';
export type CartItem = {
id: string;
name: string;
price: number;
quantity: number;
};
function createCart() {
const items = writable<CartItem[]>([]);
const total = derived(items, ($items) =>
$items.reduce((sum, item) => sum + item.price * item.quantity, 0)
);
const count = derived(items, ($items) =>
$items.reduce((sum, item) => sum + item.quantity, 0)
);
function addItem(item: Omit<CartItem, 'quantity'>) {
items.update(($items) => {
const existing = $items.find((i) => i.id === item.id);
if (existing) {
return $items.map((i) =>
i.id === item.id ? { ...i, quantity: i.quantity + 1 } : i
);
}
return [...$items, { ...item, quantity: 1 }];
});
}
function removeItem(id: string) {
items.update(($items) => $items.filter((i) => i.id !== id));
}
function updateQuantity(id: string, quantity: number) {
if (quantity <= 0) {
removeItem(id);
return;
}
items.update(($items) =>
$items.map((i) => (i.id === id ? { ...i, quantity } : i))
);
}
function clear() {
items.set([]);
}
return { items, total, count, addItem, removeItem, updateQuantity, clear };
}
export const cart = createCart();// src/lib/stores/cart.test.ts
// @vitest-environment node
import { describe, it, expect, beforeEach } from 'vitest';
import { get } from 'svelte/store';
import { cart } from './cart';
const { items, total, count, addItem, removeItem, updateQuantity, clear } = cart;
describe('cart store', () => {
beforeEach(() => {
clear();
});
describe('addItem', () => {
it('adds a new item with quantity 1', () => {
addItem({ id: 'p1', name: 'Widget', price: 9.99 });
const $items = get(items);
expect($items).toHaveLength(1);
expect($items[0]).toMatchObject({ id: 'p1', quantity: 1 });
});
it('increments quantity when same item is added again', () => {
addItem({ id: 'p1', name: 'Widget', price: 9.99 });
addItem({ id: 'p1', name: 'Widget', price: 9.99 });
const $items = get(items);
expect($items).toHaveLength(1);
expect($items[0].quantity).toBe(2);
});
it('adds different items as separate entries', () => {
addItem({ id: 'p1', name: 'Widget', price: 9.99 });
addItem({ id: 'p2', name: 'Gadget', price: 19.99 });
expect(get(items)).toHaveLength(2);
});
});
describe('removeItem', () => {
it('removes item by id', () => {
addItem({ id: 'p1', name: 'Widget', price: 9.99 });
removeItem('p1');
expect(get(items)).toHaveLength(0);
});
it('does not affect other items', () => {
addItem({ id: 'p1', name: 'Widget', price: 9.99 });
addItem({ id: 'p2', name: 'Gadget', price: 19.99 });
removeItem('p1');
const $items = get(items);
expect($items).toHaveLength(1);
expect($items[0].id).toBe('p2');
});
});
describe('updateQuantity', () => {
it('updates quantity for existing item', () => {
addItem({ id: 'p1', name: 'Widget', price: 9.99 });
updateQuantity('p1', 5);
expect(get(items)[0].quantity).toBe(5);
});
it('removes item when quantity is set to 0', () => {
addItem({ id: 'p1', name: 'Widget', price: 9.99 });
updateQuantity('p1', 0);
expect(get(items)).toHaveLength(0);
});
it('removes item when quantity is set to negative value', () => {
addItem({ id: 'p1', name: 'Widget', price: 9.99 });
updateQuantity('p1', -1);
expect(get(items)).toHaveLength(0);
});
});
describe('total derived store', () => {
it('is 0 when cart is empty', () => {
expect(get(total)).toBe(0);
});
it('computes correct total for single item', () => {
addItem({ id: 'p1', name: 'Widget', price: 10 });
expect(get(total)).toBe(10);
});
it('multiplies price by quantity', () => {
addItem({ id: 'p1', name: 'Widget', price: 10 });
updateQuantity('p1', 3);
expect(get(total)).toBe(30);
});
it('sums multiple items', () => {
addItem({ id: 'p1', name: 'Widget', price: 10 });
addItem({ id: 'p2', name: 'Gadget', price: 5 });
expect(get(total)).toBe(15);
});
});
describe('count derived store', () => {
it('is 0 when cart is empty', () => {
expect(get(count)).toBe(0);
});
it('counts total items including quantity', () => {
addItem({ id: 'p1', name: 'Widget', price: 10 });
addItem({ id: 'p1', name: 'Widget', price: 10 });
addItem({ id: 'p2', name: 'Gadget', price: 5 });
expect(get(count)).toBe(3);
});
});
});Testing Readable Stores
Readable stores derive their value from external sources — timers, WebSockets, or system APIs.
// src/lib/stores/clock.ts
import { readable } from 'svelte/store';
export const now = readable(new Date(), (set) => {
const interval = setInterval(() => set(new Date()), 1000);
return () => clearInterval(interval);
});// src/lib/stores/clock.test.ts
// @vitest-environment node
import { describe, it, expect, vi, afterEach } from 'vitest';
import { get } from 'svelte/store';
describe('clock store', () => {
afterEach(() => {
vi.useRealTimers();
vi.restoreAllMocks();
});
it('returns a Date value', async () => {
const { now } = await import('./clock');
expect(get(now)).toBeInstanceOf(Date);
});
it('updates value after each second', async () => {
vi.useFakeTimers();
const startTime = new Date('2026-01-01T00:00:00Z');
vi.setSystemTime(startTime);
const { now } = await import('./clock');
const initialTime = get(now);
vi.advanceTimersByTime(1000);
const updatedTime = get(now);
expect(updatedTime.getTime()).toBeGreaterThan(initialTime.getTime());
});
});Testing Async Stores
Stores that load data asynchronously are common in SvelteKit applications.
// src/lib/stores/user.ts
import { writable, derived } from 'svelte/store';
type UserState =
| { status: 'idle' }
| { status: 'loading' }
| { status: 'loaded'; user: { id: string; name: string; email: string } }
| { status: 'error'; message: string };
function createUserStore() {
const state = writable<UserState>({ status: 'idle' });
const isLoading = derived(state, ($state) => $state.status === 'loading');
const user = derived(state, ($state) =>
$state.status === 'loaded' ? $state.user : null
);
const error = derived(state, ($state) =>
$state.status === 'error' ? $state.message : null
);
async function load(userId: string) {
state.set({ status: 'loading' });
try {
const res = await fetch(`/api/users/${userId}`);
if (!res.ok) throw new Error(`HTTP ${res.status}`);
const userData = await res.json();
state.set({ status: 'loaded', user: userData });
} catch (e) {
state.set({
status: 'error',
message: e instanceof Error ? e.message : 'Unknown error',
});
}
}
function reset() {
state.set({ status: 'idle' });
}
return { state, isLoading, user, error, load, reset };
}
export const userStore = createUserStore();// src/lib/stores/user.test.ts
// @vitest-environment node
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { get } from 'svelte/store';
import { userStore } from './user';
const { state, isLoading, user, error, load, reset } = userStore;
describe('userStore', () => {
beforeEach(() => {
reset();
vi.spyOn(global, 'fetch');
});
afterEach(() => {
vi.restoreAllMocks();
});
it('starts in idle state', () => {
expect(get(state).status).toBe('idle');
expect(get(isLoading)).toBe(false);
expect(get(user)).toBeNull();
expect(get(error)).toBeNull();
});
it('transitions to loading state when load is called', async () => {
vi.mocked(fetch).mockResolvedValue({
ok: true,
json: async () => ({ id: '1', name: 'Ada', email: 'ada@example.com' }),
} as Response);
const loadPromise = load('1');
expect(get(isLoading)).toBe(true);
await loadPromise;
});
it('transitions to loaded state with user data on success', async () => {
const mockUser = { id: '1', name: 'Ada Lovelace', email: 'ada@example.com' };
vi.mocked(fetch).mockResolvedValue({
ok: true,
json: async () => mockUser,
} as Response);
await load('1');
expect(get(state).status).toBe('loaded');
expect(get(user)).toEqual(mockUser);
expect(get(isLoading)).toBe(false);
expect(get(error)).toBeNull();
});
it('transitions to error state on fetch failure', async () => {
vi.mocked(fetch).mockResolvedValue({ ok: false, status: 404 } as Response);
await load('nonexistent');
expect(get(state).status).toBe('error');
expect(get(error)).toMatch(/HTTP 404/);
expect(get(user)).toBeNull();
expect(get(isLoading)).toBe(false);
});
it('calls correct API endpoint', async () => {
vi.mocked(fetch).mockResolvedValue({
ok: true,
json: async () => ({ id: 'abc', name: 'Test', email: 'test@test.com' }),
} as Response);
await load('abc');
expect(fetch).toHaveBeenCalledWith('/api/users/abc');
});
it('reset returns to idle state', async () => {
vi.mocked(fetch).mockResolvedValue({
ok: true,
json: async () => ({ id: '1', name: 'Ada', email: 'ada@example.com' }),
} as Response);
await load('1');
reset();
expect(get(state).status).toBe('idle');
expect(get(user)).toBeNull();
});
});Testing Store Subscriptions
When you need to verify that a store emits specific values over time, collect emissions with a subscriber.
// src/lib/stores/notifications.test.ts
import { describe, it, expect } from 'vitest';
import { get } from 'svelte/store';
import { notifications, addNotification, dismissNotification } from './notifications';
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('notifications store', () => {
it('emits new value after each notification is added', () => {
const { values, unsubscribe } = collectValues(notifications);
addNotification({ type: 'success', message: 'Saved!' });
addNotification({ type: 'error', message: 'Failed!' });
expect(values).toHaveLength(3); // initial + 2 updates
expect(values[2]).toHaveLength(2);
unsubscribe();
});
});Testing Svelte 5 Runes
Svelte 5 introduces $state and $derived as alternatives to stores. Test them inside components using @testing-library/svelte, or extract state logic into plain classes that are easier to test directly.
// src/lib/CartState.svelte.ts (Svelte 5)
export class CartState {
items = $state<Array<{ id: string; name: string; price: number; qty: number }>>([]);
get total() {
return this.items.reduce((sum, item) => sum + item.price * item.qty, 0);
}
addItem(id: string, name: string, price: number) {
const existing = this.items.find((i) => i.id === id);
if (existing) {
existing.qty++;
} else {
this.items.push({ id, name, price, qty: 1 });
}
}
}// src/lib/CartState.test.ts
import { describe, it, expect } from 'vitest';
import { CartState } from './CartState.svelte';
describe('CartState', () => {
it('adds item with quantity 1', () => {
const cart = new CartState();
cart.addItem('p1', 'Widget', 10);
expect(cart.items[0].qty).toBe(1);
});
it('increments quantity for duplicate item', () => {
const cart = new CartState();
cart.addItem('p1', 'Widget', 10);
cart.addItem('p1', 'Widget', 10);
expect(cart.items[0].qty).toBe(2);
});
it('computes correct total', () => {
const cart = new CartState();
cart.addItem('p1', 'Widget', 10);
cart.addItem('p1', 'Widget', 10);
cart.addItem('p2', 'Gadget', 5);
expect(cart.total).toBe(25);
});
});Production Monitoring with HelpMeTest
Store logic that passes unit tests may behave differently in the browser under real network conditions. State that should persist across navigation may be lost. Loading states may get stuck when an API is slow.
HelpMeTest tests your app in a real browser on a schedule:
Go to https://myapp.com/shop
Add a product to the cart
Verify the cart count shows 1
Reload the page
Verify the cart count still shows 1When cart state breaks in production — a hydration mismatch, a serialization bug, a store reset on navigation — HelpMeTest catches it immediately.
Free tier: 10 tests, unlimited health checks.
Pro: $100/month — unlimited tests, 24/7 monitoring.
Start free at helpmetest.com — no credit card required.