Docs
Testing
Yoltra stores are easy to test because the reduce phase is synchronous and you can make effects deterministic. This guide shows the patterns.
#Use effects_mode="inline" for determinism
In tests, create the store with effects_mode="inline" so effects run
synchronously inside emit() — no threads, no waiting, no races:
from yoltra import create_store
from yoltra.types import ReducerSpec
def make_store():
return create_store(
"Todos",
reducer={
"todos": ReducerSpec(
name="todos",
state={"items": []},
when={"keys": [("ui", "add")]},
fn=lambda prev, e: {"items": [*prev["items"], e.payload]},
)
},
effects_mode="inline",
)
def test_add_commits_synchronously():
store = make_store()
store.emit("ui", "add", "milk")
assert store.get_state()["todos"]["items"] == ["milk"]You can also register with the decorators inside a factory — either style works.
#Assert atomic subscriptions
Collect Change objects and assert on the paths and values that fired. Remember
the list-length nuance: appending fires the list path, an in-place edit fires
the leaf.
def test_rename_fires_leaf_path():
store = create_store(
"Todos",
reducer={"todos": ReducerSpec(
"todos", {"items": []}, {"keys": [("ui", "add"), ("ui", "rename")]},
_todos_reducer)},
effects_mode="inline",
)
store.emit("ui", "add", {"title": "A"}) # length change → fires "items"
seen = []
store.connect(reducer="todos", property="items.*.title", handler=seen.append)
store.emit("ui", "rename", (0, "B")) # in-place → fires "items.0.title"
assert [c.new_value for c in seen] == ["B"]#Test effects
With inline mode, effect side effects are visible immediately after emit():
def test_effect_runs_after_commit():
store = make_store()
saved = []
store.on_effect("ui", "add", lambda e, get_state, emit: saved.append(get_state()["todos"]["items"]))
store.emit("ui", "add", "milk")
assert saved == [["milk"]]For async effects, use auto/asyncio mode and the handle:
import asyncio
def test_async_effect():
async def scenario():
store = create_store("App", effects_mode="asyncio", reducer={...})
done = []
store.on_effect("ui", "add", lambda e, gs, em: done.append(1)) # or an async def
await store.emit("ui", "add", "x") # awaits the effect
assert done == [1]
asyncio.run(scenario())From a synchronous test against auto mode, block with handle.wait():
handle = store.emit("ui", "add", "x")
handle.wait(timeout=2.0)#Test middleware vetoes
def test_veto_blocks_reducer():
store = make_store()
store.register_middleware(lambda state, event, emit: event.payload != "")
store.emit("ui", "add", "") # vetoed
assert store.get_state()["todos"]["items"] == []Uncommitted (vetoed) events are observable via on_event(..., phase="uncommitted").
#Concurrency
The store serializes emits under a re-entrant lock, so multi-threaded producers don't lose updates:
import threading
def test_no_lost_updates():
store = create_store(
"C",
reducer={"c": ReducerSpec("c", {"n": 0}, {"keys": [("x", "inc")]},
lambda s, e: {"n": s["n"] + 1})},
effects_mode="inline",
)
threads = [threading.Thread(target=lambda: [store.emit("x", "inc", None) for _ in range(500)])
for _ in range(8)]
for t in threads: t.start()
for t in threads: t.join()
assert store.get_state()["c"]["n"] == 8 * 500#Instrumentation as a test probe
instrument gives you the exact changed paths per event — useful for asserting
that an emit touched precisely the paths you expect:
def test_changed_paths():
store = make_store()
events = []
store.instrument(events.append)
store.emit("ui", "add", "milk")
assert events[0].changed_paths == ["todos.items"]#Tips
- Prefer
inlinemode unless you're specifically testing async behavior. - Reset between tests by creating a fresh store (they're cheap); or
dispose()one that started a daemon effect loop. set_dev(True)(the default) surfaces accidental state mutation asImmutableStateError— keep it on in tests.