Docs
Testing Guide
Yoltra is unusually easy to test: reducers are pure, the reduce phase is
synchronous (so getState() is correct the instant emit() returns), and
the store runs with zero framework dependencies — most of your logic can be
tested with no React at all. Examples use Vitest, but Jest
works the same way.
#Testing the store (no React)
Create a fresh store per test, emit events, and assert on getState().
import { afterEach, describe, expect, it } from "vitest";
import { createStore } from "@yoltra/core";
type AppEM = { counter: { increment: number; reset: null } };
function makeStore() {
return createStore({
name: "test",
reducer: {
counter: {
state: { value: 0 },
events: [["counter", "increment"], ["counter", "reset"]],
reducer: (s, e) =>
e.type === "increment" ? { value: s.value + e.payload }
: e.type === "reset" ? { value: 0 }
: s,
},
},
});
}
describe("counter", () => {
let store: ReturnType<typeof makeStore>;
afterEach(() => store?.dispose()); // clean up timers/resources
it("reduces increment synchronously", () => {
store = makeStore();
store.emit("counter", "increment", 5);
// No await needed — the reduce phase is synchronous.
expect(store.getState().counter.value).toBe(5);
});
it("resets", () => {
store = makeStore();
store.emit("counter", "increment", 3);
store.emit("counter", "reset", null);
expect(store.getState().counter.value).toBe(0);
});
});A makeStore() factory keeps every test isolated and doubles as the store you
pass to component tests below.
#Testing reducers as pure functions
Because a reducer is just (state, event) => nextState, you can test it with no
store at all:
const reducer = counterSpec.reducer;
expect(reducer({ value: 0 }, { type: "increment", payload: 2, channel: "counter" } as any))
.toEqual({ value: 2 });Prefer the store-level test above when you want the real event typing; drop to the raw function for exhaustive branch coverage.
#Testing effects (async)
Effects are the async layer. Mock the I/O, emit the trigger, and await the
emit — the returned promise resolves once that event's effects finish.
import { vi } from "vitest";
it("loads todos and reduces the result", async () => {
const api = { getTodos: vi.fn().mockResolvedValue([{ id: 1, title: "a" }]) };
const store = createStore({
name: "test",
reducer: {
todos: {
state: { items: [] as { id: number; title: string }[] },
events: [["todos", "loaded"]],
reducer: (s, e) => (e.type === "loaded" ? { items: e.payload } : s),
},
},
effects: [
{
when: { keys: [["todos", "fetch"]] },
effect: async (_e, _get, emit) => emit("todos", "loaded", await api.getTodos()),
},
],
});
await store.emit("todos", "fetch", null); // resolves after the effect completes
expect(api.getTodos).toHaveBeenCalledOnce();
expect(store.getState().todos.items).toHaveLength(1);
});Test the failure path the same way — have the effect emit a loadFailure
event and assert the reduced error state.
#Testing middleware (rejection)
Middleware is synchronous and returns a boolean. A false return rejects the
event (state does not change) and produces an uncommitted event, which you
can observe with onEvent(..., "uncommitted").
it("rejects boost below the battery threshold", () => {
const store = createStore({
name: "test",
reducer: {
sat: {
state: { battery: 10, boosting: false },
events: [["command", "boost"]],
reducer: (s, e) => (e.type === "boost" ? { ...s, boosting: true } : s),
},
},
middleware: [
{ when: { keys: [["command", "boost"]] }, middleware: (s) => s.sat.battery >= 20 },
],
});
const rejected = vi.fn();
store.onEvent("command", "boost", rejected, "uncommitted");
store.emit("command", "boost", null);
expect(store.getState().sat.boosting).toBe(false); // reducer never ran
expect(rejected).toHaveBeenCalledOnce(); // surfaced as uncommitted
});#Testing fine-grained subscriptions
Assert that a path subscription fires with the right leaf, and — just as important — that unrelated changes don't fire it:
it("notifies only the changed leaf", () => {
const store = makeStore();
const onValue = vi.fn();
store.connect({ reducer: "counter", property: "value" }, onValue);
store.emit("counter", "increment", 1);
expect(onValue).toHaveBeenCalledTimes(1);
store.emit("counter", "reset", null); // value 1 → 0, still a change
expect(onValue).toHaveBeenCalledTimes(2);
});#Testing components
Give each test its own store and provide it with <StoreProvider store={...}>
(the hooks prefer the context store over the module default). Uses
@testing-library/react.
import { render, screen, fireEvent } from "@testing-library/react";
import { StoreProvider, useAtomicProp, useEmit } from "@/state/yoltra";
import { makeStore } from "@/state/makeStore";
function Counter() {
const value = useAtomicProp({ reducer: "counter", property: "value" });
const emit = useEmit();
return <button onClick={() => emit("counter", "increment", 1)}>{value}</button>;
}
it("increments on click", () => {
const store = makeStore(); // fresh, isolated store
render(
<StoreProvider store={store}>
<Counter />
</StoreProvider>,
);
expect(screen.getByRole("button")).toHaveTextContent("0");
fireEvent.click(screen.getByRole("button"));
expect(screen.getByRole("button")).toHaveTextContent("1");
});Export a makeStore() from your app (the same factory used above) so app code
and tests build the store the same way.
#Asserting fine-grained re-renders
To prove only the right component re-rendered, count renders:
let renders = 0;
function Value() {
renders++;
const v = useAtomicProp({ reducer: "counter", property: "value" });
return <span>{v}</span>;
}
// emit an unrelated event → assert `renders` did NOT increase#Tips
dispose()inafterEachto release the store's timers and subscriptions.- Dedup / timing: dedup is off by default, so identical rapid emits are not
coalesced — no fake timers needed unless you set
dedupWindowMs. If you do, usevi.useFakeTimers(). - No
awaitfor reads: onlyawait emit()when you need the event's effects to have finished;getState()is already up to date for reducer results. - Prefer store-level tests for logic and reserve component tests for wiring — they're faster and don't need a DOM.
#Next steps
- Migration Guide — coming from Redux / Zustand / Jotai
- Next.js Guide — client-side usage in Pages and App Router
- @yoltra/core API · @yoltra/react API