Testing Stores and Hooks
How to unit test command handlers, event flows, failures, React hooks, and effects.
Testing Stores and Hooks
Commiq stores are plain TypeScript objects. No mocking framework is needed for store-level tests: create a store, dispatch a command, await the result, assert.
There are two ways to wait, and the choice matters:
| Waits for | Use when | |
|---|---|---|
await store.queue(...) | That one command settles | Asserting the outcome of a single dispatch |
await store.flush() | Full quiescence — queue and pending events empty | Event handlers queue follow-up commands |
queue() returns a CommandHandle — a Promise<CommandResult> that never rejects. flush() never rejects either. A test that expects a thrown error from either will always fail.
Testing commands
import { expect, test } from "vitest";
import { CounterCommand } from "./commands";
import { createCounterStore } from "./store";
test("increment adds one to the count", async () => {
const store = createCounterStore();
const result = await store.queue(CounterCommand.increment);
expect(result.status).toBe("handled");
expect(store.state.count).toBe(1);
});
test("incrementBy adds the given amount", async () => {
const store = createCounterStore();
await store.queue(CounterCommand.incrementBy, { amount: 5 });
expect(store.state.count).toBe(5);
});Asserting result.status as well as the state is worth the extra line: a command that never reached a handler leaves state untouched too, so a state-only assertion passes for the wrong reason when a name is misspelled.
Never assert state immediately after queue() without awaiting. The queue drains on a microtask, so state is unchanged on the line after queue() returns — even for a synchronous handler.
Testing failures
A handler that throws does not reject the handle. It settles as failed with the error attached:
import { expect, test, vi } from "vitest";
import { createStore } from "@naikidev/commiq";
test("a failing fetch settles as failed and reports through onError", async () => {
const onError = vi.fn();
const store = createStore<UserState>(initialState, { onError });
store.addCommandHandler(UserCommand.fetch, () => {
throw new Error("network down");
});
const result = await store.queue(UserCommand.fetch, { id: "1" });
expect(result.status).toBe("failed");
expect(result.error).toEqual(new Error("network down"));
expect(onError).toHaveBeenCalledWith(
expect.objectContaining({ source: "commandHandler", error: expect.any(Error) }),
);
});Passing an onError spy does double duty: it is the assertion, and it replaces the default reporter, which would otherwise log the expected error to the test output. Outside production that default logs to the console; in production it is silent.
onError is also the only channel that reports failures from stream listeners, extension hooks, and disposed contexts:
test("a throwing stream listener does not break the queue", async () => {
const onError = vi.fn();
const store = createStore<CounterState>(initialState, { onError });
store.addCommandHandler(CounterCommand.increment, (ctx) => {
ctx.setState((prev) => ({ count: prev.count + 1 }));
});
store.openStream(() => {
throw new Error("listener boom");
});
await store.queue(CounterCommand.increment);
expect(store.state.count).toBe(1);
expect(onError).toHaveBeenCalledWith(
expect.objectContaining({ source: "streamListener" }),
);
});Other statuses worth testing: invalid (no handler registered), interrupted (an interruptable command superseded), discarded (the store was destroyed first).
Asserting emitted events
Collect from the stream, then assert after awaiting. openStream returns an Unsubscribe:
import { expect, test } from "vitest";
import { matchEvent } from "@naikidev/commiq";
import type { StoreEvent } from "@naikidev/commiq";
import { CounterCommand } from "./commands";
import { CounterEvent } from "./events";
import { createCounterStore } from "./store";
test("reset emits a Reset event", async () => {
const store = createCounterStore();
const events: StoreEvent[] = [];
const unsubscribe = store.openStream((event) => events.push(event));
await store.queue(CounterCommand.reset);
unsubscribe();
expect(events.some((event) => matchEvent(event, CounterEvent.Reset))).toBe(true);
});stateChanged fires once per setState, not once per command. A handler calling setState three times publishes three stateChanged events. Counting them to count commands will over-count; filter on commandHandled instead.
Because matchEvent narrows the payload type, asserting on data needs no cast:
const incremented = events.find((event) =>
matchEvent(event, CounterEvent.Incremented),
);
expect(incremented?.data.amount).toBe(5);Testing event handlers
Event handlers react to events and queue commands. Trigger the command that emits the source event, then wait for the whole cascade — this is where flush() is required, because the handle from queue() settles before event handlers have run their follow-ups.
test("placing an order moves it to processing", async () => {
const store = createOrderStore();
store.queue(OrderCommand.place, { items: [{ id: "1", price: 29.99 }] });
await store.flush();
expect(store.state.status).toBe("processing");
});flush() throws synchronously if called from inside a command or event handler, rather than deadlocking as it used to. Inside a handler, await the handle returned by queue() instead. If a test helper calls flush(), make sure it is not reachable from a handler.
For handlers that queue onto a different store, await both:
import "./bus"; // connects the stores to the event bus
test("placing an order triggers invoice generation", async () => {
orderStore.queue(OrderCommand.place, { items: [{ id: "1", price: 29.99 }] });
await orderStore.flush();
await invoiceStore.flush();
expect(invoiceStore.state.generated).toBe(true);
});Order matters: orderStore.flush() has to resolve first so the bus has routed the event and queued the downstream command before invoiceStore.flush() decides it is quiescent.
Testing interruptable commands
import { expect, test } from "vitest";
test("a second search interrupts the first", async () => {
const store = createSearchStore();
const first = store.queue(SearchCommand.query, "first");
await Promise.resolve(); // let the first handler start
const second = store.queue(SearchCommand.query, "second");
expect((await first).status).toBe("interrupted");
expect((await second).status).toBe("handled");
expect(store.state.query).toBe("second");
});Awaiting the handles is what makes this deterministic — no timer and no polling. The superseded command settles as interrupted the moment it is aborted.
A command is only interrupted if it was already running. Two commands queued in the same tick may both settle as handled, because the second arrives before the first has started. await Promise.resolve() gives the queue a microtask to pick up the first; if the handler awaits real I/O you may need to await your stubbed promise instead.
Test { rollbackOnInterrupt: true } by asserting that state returned to its pre-command value, which the store publishes as its own stateChanged.
Testing suspension
suspend() returns a release and pauses command execution only. It is a clean way to assert on queue ordering:
test("commands queued while suspended run in order on release", async () => {
const store = createCounterStore();
const release = store.suspend();
store.queue(CounterCommand.increment);
store.queue(CounterCommand.incrementBy, { amount: 5 });
expect(store.state.count).toBe(0); // nothing has run
release();
await store.flush();
expect(store.state.count).toBe(6);
});The gate is counted, so every suspender must release. A gate held past suspendWarningMs (default 5000) reports once through onError with source: "suspendedQueue" — set suspendWarningMs: 0 to disable that in tests that hold a gate deliberately.
Testing React hooks
Use renderHook from @testing-library/react, and wrap dispatches in act:
import { act, renderHook } from "@testing-library/react";
import { expect, test } from "vitest";
import { useSelector } from "@naikidev/commiq-react";
import { CounterCommand } from "./commands";
import { createCounterStore } from "./store";
test("useSelector reflects state after a command", async () => {
const store = createCounterStore();
const { result } = renderHook(() => useSelector(store, (s) => s.count));
expect(result.current).toBe(0);
await act(async () => {
await store.queue(CounterCommand.increment);
});
expect(result.current).toBe(1);
});useCommandStatus is tested the same way. It starts idle and only reflects commands dispatched after it subscribed:
test("useCommandStatus reports pending then the error", async () => {
const store = createFailingStore();
const { result } = renderHook(() =>
useCommandStatus(store, UserCommand.fetch),
);
expect(result.current.pending).toBe(false);
await act(async () => {
await store.queue(UserCommand.fetch, { id: "1" });
});
expect(result.current.pending).toBe(false);
expect(result.current.error).toEqual(new Error("network down"));
expect(result.current.lastCompletedAt).toBeTypeOf("number");
});Testing useEvent:
test("useEvent fires when the Reset event is emitted", async () => {
const store = createCounterStore();
const handler = vi.fn();
renderHook(() => useEvent(store, CounterEvent.Reset, handler));
await act(async () => {
await store.queue(CounterCommand.reset);
});
expect(handler).toHaveBeenCalledTimes(1);
});For hooks that take a store name rather than an instance, render inside CommiqProvider and pass the test store in the registry:
import { CommiqProvider } from "@naikidev/commiq-react";
const store = createCounterStore();
const { result } = renderHook(() => useCounter(), {
wrapper: ({ children }) => (
<CommiqProvider stores={{ counter: store }}>{children}</CommiqProvider>
),
});This only works if the hook resolves its store by name. A hook that imports the module-level store directly ignores the provider — see domain hooks.
With Jest instead of Vitest, set testEnvironment: "jsdom". The test code is otherwise identical.
Testing effects
Effects are a separate error channel from the store, so give them an onError spy too:
import { afterEach, expect, test, vi } from "vitest";
import { createEffects } from "@naikidev/commiq-effects";
import type { Effects } from "@naikidev/commiq-effects";
let effects: Effects<SearchState>;
afterEach(() => {
effects.destroy();
});
test("a completed search is recorded in recent searches", async () => {
const store = createSearchStore();
effects = createEffects(store, { onError: vi.fn() });
effects.on(SearchEvent.Completed, (data, ctx) => {
ctx.queue(SearchCommand.addRecent, data.query);
});
store.queue(SearchCommand.query, "commiq");
await store.flush();
expect(store.state.recentSearches).toContain("commiq");
});flush() resolves on store quiescence, which is not the same as effect completion — an effect that awaits I/O may still be running. If the effect is debounced, flush() will resolve long before the debounce window elapses. Use fake timers, or await a promise the effect resolves, rather than sleeping:
vi.useFakeTimers();
store.queue(SearchCommand.query, "commiq");
await store.flush();
await vi.advanceTimersByTimeAsync(200); // the debounce window
await store.flush(); // let the queued command runRemember mode defaults to "switch". A test that fires the same event twice and expects two runs needs mode: "parallel".
Always destroy() effects in afterEach. An effects instance left attached to a module-level store keeps a stream subscription and will react to events from later tests.
Isolating tests with factories
Shared module-level stores accumulate state across tests. Export a factory:
export function createCounterStore(options?: StoreOptions) {
const _store = createStore<CounterState>(initialState, options);
_store.addCommandHandler(CounterCommand.increment, (ctx) => {
ctx.setState((prev) => ({ count: prev.count + 1 }));
});
return sealStore(_store);
}
export const counterStore = createCounterStore();Accepting StoreOptions is what lets each test supply its own onError. Every example on this page uses the factory for that reason.
destroy() lives on the raw StoreImpl, not on the sealed facade, so a factory returning sealStore(_store) gives tests no teardown hook. When a test needs one — to assert that in-flight handles settle as discarded, or to tear down extensions — return both:
export function createCounterStoreForTest(options?: StoreOptions) {
const _store = createStore<CounterState>(initialState, options);
_store.addCommandHandler(CounterCommand.increment, handleIncrement);
return { store: sealStore(_store), destroy: () => _store.destroy() };
}destroy() clears the queue, handlers, listeners, and extensions, settles every outstanding handle as discarded, and resolves pending flush() calls — so a teardown cannot leave an awaited promise hanging.