Docs
You are reading the docs for 0.9. The latest version is 0.10. Read this page for 0.10 →

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().

ts
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:

ts
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.

ts
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.

ts
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:

ts
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.

tsx
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:

tsx
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() in afterEach to 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, use vi.useFakeTimers().
  • No await for reads: only await 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

Report a problem with this page