Composable Svelte Testing
This skill covers testing patterns for Composable Svelte applications using TestStore and mock dependencies.
TESTSTORE API
Core Pattern: send/receive
TestStore provides exhaustive action testing with the send/receive pattern:
import { createTestStore } from '@composable-svelte/core/test';
describe('Feature', () => { it('loads items successfully', async () => { const store = createTestStore({ initialState: { items: [], isLoading: false, error: null }, reducer: featureReducer, dependencies: { api: { getItems: async () => ({ ok: true, data: [mockItem1, mockItem2] }) } } });
// User initiates action
await store.send({ type: 'loadItems' }, (state) => {
expect(state.isLoading).toBe(true);
expect(state.error).toBeNull();
});
// Effect dispatches action
await store.receive({ type: 'itemsLoaded' }, (state) => {
expect(state.items).toHaveLength(2);
expect(state.isLoading).toBe(false);
});
// Assert no more pending actions
await store.finish();
}); });
TestStore Methods
interface TestStore<State, Action> { // Send an action and assert resulting state send(action: Action, assert: (state: State) => void): Promise<void>;
// Receive an action from effects and assert state receive(action: Action, assert: (state: State) => void): Promise<void>;
// Assert no more pending actions finish(): Promise<void>;
// Advance time for debounced/delayed effects advanceTime(ms: number): Promise<void>;
// Get current state get state(): State; }
TESTING PATTERNS
- Loading Data with Error Handling
it('handles load failure', async () => { const store = createTestStore({ initialState: { items: [], isLoading: false, error: null }, reducer: featureReducer, dependencies: { api: { getItems: async () => ({ ok: false, error: 'Network error' }) } } });
await store.send({ type: 'loadItems' }, (state) => { expect(state.isLoading).toBe(true); });
await store.receive({ type: 'loadFailed' }, (state) => { expect(state.error).toBe('Network error'); expect(state.isLoading).toBe(false); });
await store.finish(); });
- Debounced Search
import { vi, beforeEach, afterEach } from 'vitest';
beforeEach(() => { vi.useFakeTimers(); });
afterEach(() => { vi.restoreAllMocks(); });
it('debounces search input', async () => {
const store = createTestStore({
initialState: { query: '', results: [] },
reducer: searchReducer,
dependencies: {
api: {
search: vi.fn(async (q) => ({ ok: true, data: [result for ${q}] }))
}
}
});
await store.send({ type: 'queryChanged', query: 'a' }, (state) => { expect(state.query).toBe('a'); });
// Advance 100ms - should not trigger search await store.advanceTime(100);
await store.send({ type: 'queryChanged', query: 'ab' }, (state) => { expect(state.query).toBe('ab'); });
// Advance 300ms - should trigger search await store.advanceTime(300);
await store.receive({ type: 'searchResults' }, (state) => { expect(state.results).toEqual(['result for ab']); });
await store.finish(); });
- Form Submission
it('validates and submits form', async () => { const store = createTestStore({ initialState: { data: { email: '' }, errors: {}, isSubmitting: false }, reducer: formReducer, dependencies: { api: { submitForm: vi.fn(async (data) => ({ ok: true })) } } });
// Invalid email await store.send({ type: 'fieldChanged', field: 'email', value: 'invalid' }, (state) => { expect(state.data.email).toBe('invalid'); expect(state.errors.email).toBe('Invalid email address'); });
// Valid email await store.send({ type: 'fieldChanged', field: 'email', value: 'test@example.com' }, (state) => { expect(state.data.email).toBe('test@example.com'); expect(state.errors.email).toBeUndefined(); });
// Submit await store.send({ type: 'submit' }, (state) => { expect(state.isSubmitting).toBe(true); });
await store.receive({ type: 'submissionSucceeded' }, (state) => { expect(state.isSubmitting).toBe(false); });
await store.finish(); });
- Navigation Flows
it('opens and closes modal', async () => { const store = createTestStore({ initialState: { destination: null, items: [] }, reducer: appReducer, dependencies: {} });
// Open modal await store.send({ type: 'addButtonTapped' }, (state) => { expect(state.destination).not.toBeNull(); expect(state.destination.name).toBe(''); });
// User types name await store.send({ type: 'destination', action: { type: 'presented', action: { type: 'nameChanged', name: 'New Item' } } }, (state) => { expect(state.destination.name).toBe('New Item'); });
// Save and close await store.send({ type: 'destination', action: { type: 'presented', action: { type: 'saveButtonTapped' } } }, (state) => { expect(state.destination).toBeNull(); expect(state.items).toHaveLength(1); expect(state.items[0].name).toBe('New Item'); });
await store.finish(); });
- Animations (PresentationState)
it('animates modal presentation', async () => { const store = createTestStore({ initialState: { content: null, presentation: { status: 'idle' } }, reducer: modalReducer, dependencies: {} });
// Show modal await store.send({ type: 'show', content: { title: 'Hello' } }, (state) => { expect(state.presentation.status).toBe('presenting'); expect(state.content).toEqual({ title: 'Hello' }); });
// Animation completes await store.receive({ type: 'presentation', event: { type: 'presentationCompleted' } }, (state) => { expect(state.presentation.status).toBe('presented'); });
// Hide modal await store.send({ type: 'hide' }, (state) => { expect(state.presentation.status).toBe('dismissing'); });
// Dismissal completes await store.receive({ type: 'presentation', event: { type: 'dismissalCompleted' } }, (state) => { expect(state.presentation.status).toBe('idle'); expect(state.content).toBeNull(); });
await store.finish(); });
MOCK DEPENDENCIES
MockClock
import { MockClock } from '@composable-svelte/core/test';
it('uses mock clock for time-based effects', async () => { const mockClock = new MockClock();
const store = createTestStore({ initialState: { toast: null }, reducer: toastReducer, dependencies: { clock: mockClock } });
await store.send({ type: 'showToast', message: 'Hello' }, (state) => { expect(state.toast).toBe('Hello'); });
// Advance time by 3 seconds await mockClock.advance(3000);
await store.receive({ type: 'hideToast' }, (state) => { expect(state.toast).toBeNull(); });
await store.finish(); });
MockAPIClient
import { MockAPIClient } from '@composable-svelte/core/test';
it('uses mock API client', async () => { const mockAPI = new MockAPIClient(); mockAPI.mock('GET', '/users', { ok: true, data: [user1, user2] }); mockAPI.mock('POST', '/users', { ok: true, data: newUser });
const store = createTestStore({ initialState: { users: [] }, reducer: usersReducer, dependencies: { api: mockAPI } });
await store.send({ type: 'loadUsers' }, (state) => { expect(state.isLoading).toBe(true); });
await store.receive({ type: 'usersLoaded' }, (state) => { expect(state.users).toHaveLength(2); });
await store.finish(); });
MockWebSocket
import { MockWebSocket } from '@composable-svelte/core/test';
it('uses mock WebSocket', async () => { const mockWS = new MockWebSocket();
const store = createTestStore({ initialState: { messages: [], connectionStatus: 'disconnected' }, reducer: chatReducer, dependencies: { ws: mockWS } });
await store.send({ type: 'connect' }, (state) => { expect(state.connectionStatus).toBe('connecting'); });
// Simulate connection mockWS.simulateOpen();
await store.receive({ type: 'connected' }, (state) => { expect(state.connectionStatus).toBe('connected'); });
// Simulate message mockWS.simulateMessage({ type: 'chat', text: 'Hello' });
await store.receive({ type: 'messageReceived' }, (state) => { expect(state.messages).toHaveLength(1); });
await store.finish(); });
TESTING COMPOSITION STRATEGIES
Testing scope() Composition
it('composes child reducer with scope', async () => { const store = createTestStore({ initialState: { counter: { count: 0 }, theme: 'light' }, reducer: appReducer, dependencies: {} });
await store.send({ type: 'counter', action: { type: 'increment' } }, (state) => { expect(state.counter.count).toBe(1); });
await store.send({ type: 'toggleTheme' }, (state) => { expect(state.theme).toBe('dark'); });
await store.finish(); });
Testing forEach() Composition
it('updates individual todo in collection', async () => { const store = createTestStore({ initialState: { todos: [ { id: '1', text: 'Buy milk', completed: false }, { id: '2', text: 'Walk dog', completed: false } ] }, reducer: todosReducer, dependencies: {} });
await store.send({ type: 'todo', id: '1', action: { type: 'toggle' } }, (state) => { expect(state.todos[0].completed).toBe(true); expect(state.todos[1].completed).toBe(false); });
await store.finish(); });
TESTING BEST PRACTICES
- Test Reducer Logic, Not Components
❌ WRONG:
import { render, fireEvent } from '@testing-library/svelte';
test('increments counter', async () => { const { getByText } = render(Counter); const button = getByText('Increment'); await fireEvent.click(button); expect(getByText('1')).toBeInTheDocument(); });
✅ CORRECT:
test('increments counter', async () => { const store = createTestStore({ initialState: { count: 0 }, reducer: counterReducer });
await store.send({ type: 'increment' }, (state) => { expect(state.count).toBe(1); });
await store.finish(); });
WHY: TestStore tests are faster, more focused, and test reducer logic in isolation.
- Use finish() to Catch Pending Actions
it('catches unexpected effects', async () => { const store = createTestStore({ initialState: { count: 0 }, reducer: counterReducer, dependencies: {} });
await store.send({ type: 'increment' }, (state) => { expect(state.count).toBe(1); });
// This will fail if there are pending actions from effects await store.finish(); });
- Test Error Cases
it('handles network errors gracefully', async () => { const store = createTestStore({ initialState: { data: null, error: null }, reducer: dataReducer, dependencies: { api: { getData: async () => ({ ok: false, error: 'Network error' }) } } });
await store.send({ type: 'load' }, (state) => { expect(state.isLoading).toBe(true); });
await store.receive({ type: 'loadFailed' }, (state) => { expect(state.error).toBe('Network error'); expect(state.data).toBeNull(); });
await store.finish(); });
- Test Edge Cases
it('prevents double submission', async () => { const submitSpy = vi.fn(async () => ({ ok: true }));
const store = createTestStore({ initialState: { isSubmitting: false }, reducer: formReducer, dependencies: { api: { submit: submitSpy } } });
await store.send({ type: 'submit' }, (state) => { expect(state.isSubmitting).toBe(true); });
// Try to submit again while submitting await store.send({ type: 'submit' }, (state) => { // Should still be submitting, not duplicate expect(state.isSubmitting).toBe(true); });
// Should only call API once expect(submitSpy).toHaveBeenCalledTimes(1);
await store.receive({ type: 'submissionSucceeded' }, (state) => { expect(state.isSubmitting).toBe(false); });
await store.finish(); });
COMMON ANTI-PATTERNS
- Not Using TestStore
❌ WRONG: Component tests for business logic
import { render, fireEvent } from '@testing-library/svelte';
test('loads data on mount', async () => { const { getByText } = render(DataView); await waitFor(() => { expect(getByText('Item 1')).toBeInTheDocument(); }); });
✅ CORRECT: TestStore for reducer logic
test('loads data on mount', async () => { const store = createTestStore({ initialState: { items: [], isLoading: false }, reducer: dataReducer, dependencies: { api: mockAPI } });
await store.send({ type: 'loadData' }, (state) => { expect(state.isLoading).toBe(true); });
await store.receive({ type: 'dataLoaded' }, (state) => { expect(state.items).toHaveLength(2); });
await store.finish(); });
- Not Testing Effects
❌ WRONG: Only testing state updates
test('loads data', async () => { const store = createTestStore({ initialState: { isLoading: false }, reducer: dataReducer, dependencies: {} });
await store.send({ type: 'loadData' }, (state) => { expect(state.isLoading).toBe(true); });
// Missing: await store.receive for effect dispatch await store.finish(); // Will fail! });
✅ CORRECT: Testing state + effects
test('loads data', async () => { const store = createTestStore({ initialState: { isLoading: false }, reducer: dataReducer, dependencies: { api: mockAPI } });
await store.send({ type: 'loadData' }, (state) => { expect(state.isLoading).toBe(true); });
// Test effect dispatch await store.receive({ type: 'dataLoaded' }, (state) => { expect(state.items).toBeDefined(); });
await store.finish(); });
- Not Using Mock Dependencies
❌ WRONG: Real dependencies in tests
const store = createTestStore({ initialState: { users: [] }, reducer: usersReducer, dependencies: { api: createRealAPIClient() // ❌ Real HTTP requests! } });
✅ CORRECT: Mock dependencies
const store = createTestStore({ initialState: { users: [] }, reducer: usersReducer, dependencies: { api: { getUsers: async () => ({ ok: true, data: [mockUser1, mockUser2] }) } } });
CHECKLISTS
Pre-Commit Testing Checklist
-
- NO $state in components (except DOM refs)
-
- All application state in store
-
- All state changes via actions
-
- Immutable updates (no mutations)
-
- Effects as data structures
-
- Exhaustiveness checks in reducers
-
- TestStore tests (not component tests)
-
- All actions tested with send/receive
-
- All effects tested (receive after send)
-
- Error cases tested
-
- Edge cases tested
-
- finish() called in all tests
SUMMARY
This skill covers testing patterns for Composable Svelte:
-
TestStore API: send/receive pattern for exhaustive testing
-
Testing Patterns: Loading, debouncing, forms, navigation, animations
-
Mock Dependencies: MockClock, MockAPIClient, MockWebSocket
-
Testing Composition: scope(), forEach(), tree helpers
-
Best Practices: Test reducers not components, use finish(), test errors
-
Anti-Patterns: Component tests, not testing effects, real dependencies
Remember: Use TestStore for ALL business logic tests. Component tests are only for visual/accessibility testing.
For core architecture, see composable-svelte-core skill. For navigation testing, see composable-svelte-navigation skill. For form testing, see composable-svelte-forms skill.