Testing Svelte Stores: Writable, Readable, Derived, and Custom Stores

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() from svelte/store for synchronous one-off reads
  • Subscribe and collect values to test sequences of state changes
  • Always unsubscribe after tests — leaked subscriptions cause false positives
  • Use beforeEach to 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.

Read more

Start now free