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, eventKeys } from "@yoltra/core";
type AppEM = { counter: { increment: number; reset: null } };
function makeStore() {
return createStore({
name: "test",
reducer: {
counter: {
state: { value: 0 },
when: { keys: eventKeys<AppEM>()([["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 }[] },
when: { keys: [["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. Only an explicit false rejects the event (state
does not change), producing an uncommitted event you can observe with
onEvent(..., "uncommitted"). Returning true, or returning nothing at all,
allows it, so middleware that only logs or measures needs no return.
Test the allow path as well as the veto. A guard that stops rejecting is a silent failure, and so is one that rejects everything.
it("rejects boost below the battery threshold", () => {
const store = createStore({
name: "test",
reducer: {
sat: {
state: { battery: 10, boosting: false },
when: { keys: [["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