machina-test
machina-test gives you two things:
- Graph matchers —
toAlwaysReach,toNeverReach,toHaveNoUnreachableStates. These check the FSM’s topology: does a path exist from A to B? walkAll— property-based runtime testing. Feeds randomized inputs into a live FSM and checks a rule you define after every transition.
The matchers answer “is the wiring right?” walkAll answers “does it actually work when things happen in a random order?”
Install
Section titled “Install”machina >= 6.1.0 is a peer dependency. Either jest or vitest must be available as the host test runner.
Import machina-test in your test files. The import registers the matchers via expect.extend() as a side effect:
Or add it to your test runner’s setup file (jest.setup.ts / vitest.setup.ts) to make the matchers available everywhere.
TypeScript users get autocomplete automatically — the package ships ambient type augmentation for both jest.Matchers and Vitest’s Assertion<T>.
Matchers
Section titled “Matchers”toHaveNoUnreachableStates()
Section titled “toHaveNoUnreachableStates()”Asserts that every state in the FSM is reachable from initialState.
This matcher delegates to inspectGraph() and filters for unreachable-state findings. It recurses into child FSM graphs — an orphaned state in a _child FSM surfaces as a failure when called on the parent.
toAlwaysReach(targetState, { from })
Section titled “toAlwaysReach(targetState, { from })”Asserts that a path exists from from to targetState in the FSM’s top-level graph.
BFS follows all edges — both "definite" (string shorthand, single return) and "possible" (conditional returns). The question is “does the plumbing exist?”, not “will this path definitely execute at runtime?”
toNeverReach(targetState, { from })
Section titled “toNeverReach(targetState, { from })”Asserts that no path exists from from to targetState. The logical inverse of toAlwaysReach.
.not variants
Section titled “.not variants”Standard Jest/Vitest negation works as expected:
Invalid state names
Section titled “Invalid state names”Typos in state names produce clean test failures (not thrown exceptions) with actionable messages listing available states:
Testing hierarchical FSMs
Section titled “Testing hierarchical FSMs”toAlwaysReach and toNeverReach operate on the top-level graph only. They do not traverse into _child FSMs.
The pattern: test parent and child independently by passing each to expect() separately.
Why top-level only?
Section titled “Why top-level only?”Consider a parent with state "checkout" and a child with state "processing". What would toAlwaysReach("processing", { from: "browsing" }) mean?
- “Can the parent reach a state called
processing?” — No, it doesn’t have one. But the child does. - “Can
browsingeventually lead to the child’sprocessing?” — That’s a composite-state question, not a graph-topology question.
By keeping reachability matchers top-level-only, the answer is always unambiguous: you’re asking about the states in the graph you passed to expect().
walkAll — Runtime Testing
Section titled “walkAll — Runtime Testing”The matchers above check graph topology — they look at the map. walkAll actually runs the FSM with random inputs and checks that a rule you define holds true after every single transition. If the rule breaks, you get the exact sequence of inputs that caused it, plus a magic number that lets you replay the failure on demand.
This matters because graph matchers can’t see conditional logic. If a handler has an if statement that decides where to go next, the matchers see both branches as possible. They can’t tell you whether the branch logic is actually correct. walkAll can.
Key concepts
Section titled “Key concepts”Before diving into the API, here’s what the terminology means in plain language:
Factory function — A function that creates a fresh FSM every time it’s called. walkAll calls your factory once per walk so each walk starts clean. If you passed a single instance instead, mutations from walk #1 would bleed into walk #2 and your test results would be meaningless.
Invariant — A rule that must always be true, no matter what state the FSM is in. “Balance must never go negative.” “User must not be in state admin without a role.” You write it as a function — if the rule is violated, throw an error (or return false). walkAll runs your invariant after every transition.
Walk — One complete run through the FSM. Create a fresh FSM, fire a sequence of random inputs, check the invariant after each transition. A single walkAll call runs many walks (default: 100).
Step — One handle() call within a walk. If maxSteps is 20, each walk fires up to 20 random inputs before moving on to the next walk.
Seed — A number that makes “random” repeatable. Computers can’t actually generate random numbers — they use a formula that looks random but always produces the same sequence from the same starting number. That starting number is the seed. Same seed = same sequence of input names picked each step. This is what makes failures reproducible.
Payload generator — A function that produces data to pass along with a specific input. Without generators, inputs fire with no payload — which is fine for inputs like reset but useless for inputs like begin that expect a transfer amount. You configure generators per input name.
Basic usage
Section titled “Basic usage”That’s the minimal case. walkAll auto-extracts all input names from your FSM’s states config (skipping reserved keys like _onEnter, _onExit, _child, and *), picks one at random each step, fires it via handle(), and runs your invariant after every transition.
Payload generators
Section titled “Payload generators”Without generators, every input fires with no arguments. If your handler expects data (a transfer amount, a user ID, a payload object), you need to tell walkAll how to generate it.
Only inputs that need payloads get generators. Everything else fires with no arguments.
Filtering inputs
Section titled “Filtering inputs”By default, walkAll fires every input it finds. You can narrow the set with include or exclude (mutually exclusive):
Unknown names in include throw immediately — catches typos before the walk even starts.
Seed replay
Section titled “Seed replay”The killer feature. When an invariant violation occurs, walkAll throws a WalkFailureError that carries the seed, the step number, and the full input sequence. Pass that seed back to walkAll and you get the exact same walk — same inputs, same order, same failure.
This is particularly useful in CI — a test fails, you grab the seed from the error output, paste it into your test, and you have a deterministic reproduction. No more “works on my machine.”
BehavioralFsm support
Section titled “BehavioralFsm support”walkAll detects BehavioralFsm instances automatically. Pass a client factory to create a fresh client per walk:
If you don’t provide a client factory, walkAll uses an empty object {}.
WalkFailureError
Section titled “WalkFailureError”Thrown when the invariant fails. All properties are readonly:
| Property | Type | Description |
|---|---|---|
seed | number | The seed that produced this walk |
step | number | Which handle() call caused the violation (1-indexed) |
state | string | State the FSM was in when the invariant broke |
previousState | string | State before the violating transition |
compositeState | string | Full dot-delimited state path (includes child states) |
ctx | unknown | The FSM context (or client for BehavioralFsm) at violation time |
inputSequence | Array<{ input, payload }> | Every input fired up to and including the failing step |
The error message itself is human-readable and includes the seed, states, cause, and full input sequence — designed for CI log output.
Configuration reference
Section titled “Configuration reference”The InvariantArgs object passed to your invariant:
| Property | Type | Description |
|---|---|---|
state | string | State the FSM just transitioned TO |
previousState | string | State the FSM transitioned FROM |
compositeState | string | Dot-delimited composite state path |
ctx | unknown | The FSM context or BehavioralFsm client |
input | string | The input name that triggered this transition |
payload | unknown | The payload from the generator, or undefined |
step | number | Which handle() call produced this transition (1-indexed) |
When to use what
Section titled “When to use what”| Question | Tool |
|---|---|
| Is every state reachable? | toHaveNoUnreachableStates() |
| Can state A reach state B? | toAlwaysReach / toNeverReach |
| Does the FSM behave correctly under random inputs? | walkAll |
| Is there a conditional logic bug the graph can’t see? | walkAll |
Will the guard in _onEnter actually prevent bad transitions? | walkAll |
The matchers and walkAll are complementary. Use matchers for fast structural sanity checks. Use walkAll when your FSM has handler logic (conditionals, context mutations, guards) that matters.
See also
Section titled “See also”- machina-inspect — the graph analysis engine the matchers are built on
- ESLint plugin — catch structural issues at lint time in your editor