Skip to content

machina-test

machina-test gives you two things:

  1. Graph matcherstoAlwaysReach, toNeverReach, toHaveNoUnreachableStates. These check the FSM’s topology: does a path exist from A to B?
  2. 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?”

npm install --save-dev machina-test
# or
pnpm add -D machina-test

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:

import "machina-test";

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

Asserts that every state in the FSM is reachable from initialState.

import "machina-test";
import { createFsm } from "machina";

const fsm = createFsm({
    id: "traffic-light",
    initialState: "green",
    states: {
        green: { timeout: "yellow" },
        yellow: { timeout: "red" },
        red: { timeout: "green" },
    },
});

expect(fsm).toHaveNoUnreachableStates(); // passes

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.

Asserts that a path exists from from to targetState in the FSM’s top-level graph.

expect(fsm).toAlwaysReach("delivered", { from: "placed" });

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?”

Asserts that no path exists from from to targetState. The logical inverse of toAlwaysReach.

// Terminal states should stay terminal
expect(fsm).toNeverReach("shipped", { from: "cancelled" });

Standard Jest/Vitest negation works as expected:

expect(fsm).not.toAlwaysReach("shipped", { from: "cancelled" }); // no path exists
expect(fsm).not.toNeverReach("delivered", { from: "placed" }); // a path does exist

Typos in state names produce clean test failures (not thrown exceptions) with actionable messages listing available states:

State 'shiped' does not exist in FSM 'order-workflow'.
Available states: placed, validating, processing, shipped, delivered, cancelled, refunded.

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.

import "machina-test";
import { createFsm } from "machina";

// Create the child FSM
const paymentFsm = createFsm({
    id: "payment",
    initialState: "entering-details",
    context: {},
    states: {
        "entering-details": { "submit-payment": "processing" },
        processing: { success: "authorized", failure: "declined" },
        authorized: {},
        declined: { retry: "entering-details" },
    },
});

// Create the parent FSM with the child
const checkoutFsm = createFsm({
    id: "checkout",
    initialState: "browsing",
    context: {},
    states: {
        browsing: { "begin-checkout": "checkout" },
        checkout: {
            _child: paymentFsm,
            "order-placed": "confirmation",
            abandon: "browsing",
        },
        confirmation: { "new-order": "browsing" },
    },
});

// Test the PARENT — only sees browsing, checkout, confirmation
expect(checkoutFsm).toAlwaysReach("confirmation", { from: "browsing" });

// Test the CHILD — only sees entering-details, processing, authorized, declined
expect(paymentFsm).toAlwaysReach("authorized", { from: "entering-details" });
expect(paymentFsm).toNeverReach("entering-details", { from: "authorized" });

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 browsing eventually lead to the child’s processing?” — 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().


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.

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.

import { walkAll } from "machina-test";
import { createAccountTransfer } from "./account-transfer";

const result = walkAll(
    // Factory: each walk gets a fresh FSM
    () => createAccountTransfer(1000),
    {
        walks: 200, // run 200 independent walks
        maxSteps: 20, // up to 20 handle() calls per walk
        seed: 42, // deterministic — same sequence every run
        invariant({ ctx }) {
            const { balance } = ctx as { balance: number };
            if (balance < 0) {
                throw new Error(`balance went negative: ${balance}`);
            }
        },
    }
);

// On success: result.seed and result.walksCompleted
expect(result.walksCompleted).toBe(200);

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.

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.

walkAll(() => createAccountTransfer(500), {
    walks: 300,
    maxSteps: 30,
    seed: 7,
    inputs: {
        // "begin" gets a random transfer amount between 0 and 200.
        // "reset" doesn't need a payload — omit it.
        begin: () => Math.floor(Math.random() * 200),
    },
    invariant({ ctx }) {
        const { balance } = ctx as { balance: number };
        if (balance < 0) {
            throw new Error(`balance went negative: ${balance}`);
        }
    },
});

Only inputs that need payloads get generators. Everything else fires with no arguments.

By default, walkAll fires every input it finds. You can narrow the set with include or exclude (mutually exclusive):

// Only fire these two inputs
walkAll(factory, {
    include: ["begin", "reset"],
    invariant,
});

// Fire everything except "admin-override"
walkAll(factory, {
    exclude: ["admin-override"],
    invariant,
});

Unknown names in include throw immediately — catches typos before the walk even starts.

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.

import { walkAll, WalkFailureError } from "machina-test";

try {
    walkAll(createBuggyFsm, { seed: 1, invariant });
} catch (err) {
    if (err instanceof WalkFailureError) {
        console.log(err.seed); // the seed that found the bug
        console.log(err.step); // which handle() call broke it (1-indexed)
        console.log(err.state); // state at the time of violation
        console.log(err.inputSequence); // full sequence: [{ input, payload }, ...]

        // Replay the exact same walk:
        walkAll(createBuggyFsm, { seed: err.seed, invariant });
        // Same failure, same step, same state.
    }
}

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

walkAll detects BehavioralFsm instances automatically. Pass a client factory to create a fresh client per walk:

walkAll(
    () =>
        createBehavioralFsm({
            /* ... */
        }),
    {
        client: () => ({ balance: 1000, transferAmount: 0 }),
        invariant({ ctx }) {
            // ctx is the client object for BehavioralFsm walks
        },
    }
);

If you don’t provide a client factory, walkAll uses an empty object {}.

Thrown when the invariant fails. All properties are readonly:

PropertyTypeDescription
seednumberThe seed that produced this walk
stepnumberWhich handle() call caused the violation (1-indexed)
statestringState the FSM was in when the invariant broke
previousStatestringState before the violating transition
compositeStatestringFull dot-delimited state path (includes child states)
ctxunknownThe FSM context (or client for BehavioralFsm) at violation time
inputSequenceArray<{ 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.

interface WalkConfig<TClient extends object = object> {
    /** How many independent walks to run. Default: 100 */
    walks?: number;

    /** Max handle() calls per walk. Default: 50 */
    maxSteps?: number;

    /** Seed for deterministic input selection. Default: random */
    seed?: number;

    /** Only fire these inputs. Mutually exclusive with exclude. */
    include?: string[];

    /** Don't fire these inputs. Mutually exclusive with include. */
    exclude?: string[];

    /** Payload generators keyed by input name. */
    inputs?: Record<string, () => unknown>;

    /** BehavioralFsm client factory. Omit for Fsm walks. */
    client?: () => TClient;

    /** Checked after every transition. Throw or return false to fail. */
    invariant: (args: InvariantArgs) => boolean | void;
}

The InvariantArgs object passed to your invariant:

PropertyTypeDescription
statestringState the FSM just transitioned TO
previousStatestringState the FSM transitioned FROM
compositeStatestringDot-delimited composite state path
ctxunknownThe FSM context or BehavioralFsm client
inputstringThe input name that triggered this transition
payloadunknownThe payload from the generator, or undefined
stepnumberWhich handle() call produced this transition (1-indexed)

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

  • machina-inspect — the graph analysis engine the matchers are built on
  • ESLint plugin — catch structural issues at lint time in your editor