How to test reducers, selectors, and sagas.
The testing approach for each layer follows from its design:
| Layer | Nature | Testing Approach |
|---|---|---|
| Reducers | Pure functions | Call directly with state + action, assert result |
| Selectors | Pure functions | Call .select(state) with mock state, assert result |
| Sagas | Generators with effects | Use redux-saga-test-plan or step through manually |
Use Vitest as the test runner.
Reducers are pure functions: given (state, action) → new state. Test them directly.
import { describe, it, expect } from "vitest";
import { counterReducer, increment, decrement, setCount } from "./counter-slice";
describe("counterReducer", () => {
const initialState = { value: 0, lastUpdated: null };
it("should return initial state for undefined state", () => {
const state = counterReducer(undefined, { type: "@@INIT" });
expect(state).toEqual(initialState);
});
it("should handle increment", () => {
const state = counterReducer(initialState, increment());
expect(state.value).toBe(1);
});
it("should handle setCount with payload", () => {
const state = counterReducer(initialState, setCount(42));
expect(state.value).toBe(42);
});
it("should return same state for unknown action", () => {
const state = counterReducer(initialState, { type: "unknown" });
expect(state).toBe(initialState); // Same reference
});
});import { itemsReducer, fetchItems } from "./items-slice";
describe("itemsReducer async actions", () => {
const initialState = { items: [], isLoading: false, error: null };
it("should set loading on fetch request", () => {
const state = itemsReducer(initialState, fetchItems("query"));
expect(state.isLoading).toBe(true);
expect(state.error).toBeNull();
});
it("should store items on success", () => {
const items = [{ id: "1", name: "Item 1" }];
const state = itemsReducer(
{ ...initialState, isLoading: true },
fetchItems.success({ response: { items, total: 1 } })
);
expect(state.isLoading).toBe(false);
expect(state.items).toEqual(items);
});
it("should store error on failure", () => {
const state = itemsReducer(
{ ...initialState, isLoading: true },
fetchItems.failure({ error: new Error("Network error") })
);
expect(state.isLoading).toBe(false);
expect(state.error).toBe("Network error");
});
});import { todosReducer, addTodo, removeTodo } from "./todos-slice";
import { getItem, getItems } from "@augmentcode/themis/utils/collections/collection-utils";
describe("todosReducer collections", () => {
it("should add a todo to the collection", () => {
const state = todosReducer(undefined, addTodo({ id: "1", title: "Test", completed: false }));
expect(getItem(state.collection, "1")).toEqual({
id: "1", title: "Test", completed: false,
});
});
it("should remove a todo from the collection", () => {
let state = todosReducer(undefined, addTodo({ id: "1", title: "Test", completed: false }));
state = todosReducer(state, removeTodo("1"));
expect(getItems(state.collection)).toHaveLength(0);
});
});Selectors are pure functions. Call .select(state) with a mock state object.
import { describe, it, expect } from "vitest";
import { selectCompletedTodos, selectTodoById } from "./todos-selectors";
import { createCollection } from "@augmentcode/themis/utils/collections/collection-utils";
describe("selectCompletedTodos", () => {
const mockState = {
todos: {
collection: createCollection("id", [
{ id: "1", title: "Done", completed: true },
{ id: "2", title: "Pending", completed: false },
]),
},
};
it("should return only completed todos", () => {
const result = selectCompletedTodos.select(mockState);
expect(result).toHaveLength(1);
expect(result[0].id).toBe("1");
});
});
### Manual Generator Stepping
For more control, step through the generator manually:
```typescript
import { describe, it, expect } from "vitest";
import { call, put } from "typed-redux-saga";
import { handleFetch } from "./my-saga";
import * as api from "../api";
describe("handleFetch (manual)", () => {
it("should call API and dispatch success", () => {
const action = fetchItems("query");
const gen = handleFetch(action);
// Step 1: call the API
expect(gen.next().value).toEqual(call(api.fetchItems, "query"));
// Step 2: dispatch success with the result
const items = [{ id: "1" }];
expect(gen.next(items).value).toEqual(put(setItems(items)));
// Step 3: done
expect(gen.next().done).toBe(true);
});
it("should dispatch failure on error", () => {
const action = fetchItems("query");
const gen = handleFetch(action);
gen.next(); // Advance to call
const error = new Error("Network error");
expect(gen.throw(error).value).toEqual(put(fetchFailed(error.message)));
});
});For integration-style tests that need a real store:
import { describe, it, expect } from "vitest";
import { Store } from "@augmentcode/themis/svelte-store";
function createTestStore(overrides = {}) {
const store = new Store({
todos: todosReducer,
...overrides,
});
store.init();
return store;
}
describe("integration test", () => {
it("should handle full action flow", () => {
const store = createTestStore();
store.dispatch(addTodo({ id: "1", title: "Test", completed: false }));
const state = store.state;
expect(selectTodoById.select(state, "1")?.title).toBe("Test");
});
});Use Store from @augmentcode/themis/svelte-store when a test needs Svelte-readable selector behavior. Use StreamingStore from @augmentcode/themis/streaming-store when asserting Kefir stream emissions from store.createSelector(...) calls; continue to use .select(mockState, ...args) for pure selector assertions. For direct streaming emission tests, use controlled timers or a mocked requestAnimationFrame to advance the selector throttle and assert that rapid Store or observable argument updates coalesce to the latest value.
-
Test reducers exhaustively — They are pure and fast. Cover every action type, edge cases, and identity returns.
-
Test selector composition — If
selectBdepends onselectA, test both independently AND together. -
Use
.select()in tests — Never callselector()(the readable form) in tests, as it requires Svelte component context. -
Assert immutability — Verify that the original state is never mutated:
it("should not mutate original state", () => { const original = { ...initialState }; const frozen = Object.freeze(original); // Should not throw const newState = myReducer(frozen, myAction()); expect(newState).not.toBe(frozen); });
-
Test serialization — Ensure state is always JSON-serializable:
it("should produce serializable state", () => { const state = myReducer(undefined, myAction()); expect(() => JSON.stringify(state)).not.toThrow(); expect(JSON.parse(JSON.stringify(state))).toEqual(state); });
-
Use
silentRun()in saga tests — Suppresses timeout warnings for faster tests. -
Review refactor cleanup — When a change moves, renames, or splits modules, inspect the old paths from the diff. Remove old modules that are now only one-line re-exports, import/export proxies, or delegate-only wrappers, or add an adjacent compatibility-shim comment that states the consumer/release window and sunset/removal condition. Summaries should call out either “no pass-through wrappers” or list the justified shims.
-
File naming convention — Test files live next to the code:
my-slice.test.ts,my-saga.test.ts. -
Run the architecture-backed test examples check — Changes to tests, README snippets, docs, or skills should run
npm run validate:architecture. This command includes ESLint-backed test-pattern rules for selector tests andtyped-redux-sagamocks. A failure points at the source test file or Markdown fence and includesWhy:plusHow to fix:guidance. Fix selector-test failures by usingselectFoo.select(mockState, ...args); fixtyped-redux-sagamock failures by preserving theArray.isArray(fnOrDescriptor)branch for tuple call descriptors. Mark intentionally bad Markdown teaching examples as wrong/bad rather than leaving them indistinguishable from recommended snippets.