| name | composable-svelte-testing |
| description | Testing patterns for Composable Svelte. Use when writing tests, using TestStore, mocking dependencies, or testing reducers and effects. Covers the send/receive pattern, mock implementations, testing composition strategies, and testing best practices. |
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] })
}
}
});
await store.send({ type: 'loadItems' }, (state) => {
expect(state.isLoading).toBe(true);
expect(state.error).toBeNull();
});
await store.receive({ type: 'itemsLoaded' }, (state) => {
expect(state.items).toHaveLength(2);
expect(state.isLoading).toBe(false);
});
await store.finish();
});
});
TestStore Methods
interface TestStore<State, Action> {
send(action: Action, assert: (state: State) => void): Promise<void>;
receive(action: Action, assert: (state: State) => void): Promise<void>;
finish(): Promise<void>;
assertNoPendingActions(): void;
advanceTime(ms: number): Promise<void>;
getState(): State;
getHistory(): ReadonlyArray<Action>;
exhaustivity: 'on' | 'off';
}
TESTING PATTERNS
1. 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();
});
2. 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');
});
await store.advanceTime(100);
await store.send({ type: 'queryChanged', query: 'ab' }, (state) => {
expect(state.query).toBe('ab');
});
await store.advanceTime(300);
await store.receive({ type: 'searchResults' }, (state) => {
expect(state.results).toEqual(['result for ab']);
});
await store.finish();
});
3. 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 }))
}
}
});
await store.send({ type: 'fieldChanged', field: 'email', value: 'invalid' }, (state) => {
expect(state.data.email).toBe('invalid');
expect(state.errors.email).toBe('Invalid email address');
});
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();
});
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();
});
4. Navigation Flows
it('opens and closes modal', async () => {
const store = createTestStore({
initialState: { destination: null, items: [] },
reducer: appReducer,
dependencies: {}
});
await store.send({ type: 'addButtonTapped' }, (state) => {
expect(state.destination).not.toBeNull();
expect(state.destination.name).toBe('');
});
await store.send({
type: 'destination',
action: { type: 'presented', action: { type: 'nameChanged', name: 'New Item' } }
}, (state) => {
expect(state.destination.name).toBe('New Item');
});
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();
});
5. Animations (PresentationState)
it('animates modal presentation', async () => {
const store = createTestStore({
initialState: {
content: null,
presentation: { status: 'idle' }
},
reducer: modalReducer,
dependencies: {}
});
await store.send({ type: 'show', content: { title: 'Hello' } }, (state) => {
expect(state.presentation.status).toBe('presenting');
expect(state.content).toEqual({ title: 'Hello' });
});
await store.receive({ type: 'presentation', event: { type: 'presentationCompleted' } }, (state) => {
expect(state.presentation.status).toBe('presented');
});
await store.send({ type: 'hide' }, (state) => {
expect(state.presentation.status).toBe('dismissing');
});
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 { createMockClock } from '@composable-svelte/core';
it('uses mock clock for time-based effects', async () => {
const mockClock = createMockClock();
const store = createTestStore({
initialState: { toast: null },
reducer: toastReducer,
dependencies: { clock: mockClock }
});
await store.send({ type: 'showToast', message: 'Hello' }, (state) => {
expect(state.toast).toBe('Hello');
});
await mockClock.advance(3000);
await store.receive({ type: 'hideToast' }, (state) => {
expect(state.toast).toBeNull();
});
await store.finish();
});
MockAPIClient
import { createMockAPI } from '@composable-svelte/core';
it('uses mock API client', async () => {
const mockAPI = createMockAPI({
'GET /users': { ok: true, data: [user1, user2] },
'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 { createMockWebSocket } from '@composable-svelte/core';
it('uses mock WebSocket', async () => {
const mockWS = createMockWebSocket();
const store = createTestStore({
initialState: { messages: [], connectionStatus: 'disconnected' },
reducer: chatReducer,
dependencies: { ws: mockWS }
});
await store.send({ type: 'connect' }, (state) => {
expect(state.connectionStatus).toBe('connecting');
});
mockWS.simulateOpen();
await store.receive({ type: 'connected' }, (state) => {
expect(state.connectionStatus).toBe('connected');
});
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
1. 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.
2. 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);
});
await store.finish();
});
3. 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();
});
4. 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);
});
await store.send({ type: 'submit' }, (state) => {
expect(state.isSubmitting).toBe(true);
});
expect(submitSpy).toHaveBeenCalledTimes(1);
await store.receive({ type: 'submissionSucceeded' }, (state) => {
expect(state.isSubmitting).toBe(false);
});
await store.finish();
});
COMMON ANTI-PATTERNS
1. 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();
});
2. 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);
});
await store.finish();
});
✅ 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);
});
await store.receive({ type: 'dataLoaded' }, (state) => {
expect(state.items).toBeDefined();
});
await store.finish();
});
3. Not Using Mock Dependencies
❌ WRONG: Real dependencies in tests
const store = createTestStore({
initialState: { users: [] },
reducer: usersReducer,
dependencies: {
api: createRealAPIClient()
}
});
✅ CORRECT: Mock dependencies
const store = createTestStore({
initialState: { users: [] },
reducer: usersReducer,
dependencies: {
api: {
getUsers: async () => ({ ok: true, data: [mockUser1, mockUser2] })
}
}
});
CHECKLISTS
Pre-Commit Testing Checklist
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.