Skip to content

Testing Transitions & Paths

Test state transitions, guard behavior, and complete lifecycle paths using Machine::test().

Single Transitions

assertTransition() sends the named event to the machine and verifies that the machine lands in the expected target state. It is the simplest way to confirm that a single event produces the correct outcome.

php
AllInvocationPointsMachine::test()
    ->assertTransition('PROCESS', 'active');

Guard Testing

A guarded transition is one that a guard condition has rejected: the event is received but the machine stays in its current state without transitioning. assertGuarded() confirms this blocking behavior, while assertTransition() confirms the transition succeeds when the guard passes.

php
// Guard blocks — state unchanged
AllInvocationPointsMachine::test(['count' => 0])
    ->assertGuarded('PROCESS');

// Guard passes — transition occurs
AllInvocationPointsMachine::test(['count' => 5])
    ->assertTransition('PROCESS', 'active');

// Force guard result via faking
IsCountPositiveGuard::shouldReturn(true);
AllInvocationPointsMachine::test(['count' => 0])
    ->assertTransition('PROCESS', 'active');  // guard bypassed

Guard-Specific Assertions

Verify which guard blocked an event with assertGuardedBy():

php
// Assert a specific guard blocked the transition
AllInvocationPointsMachine::test(['count' => 0])
    ->assertGuardedBy('PROCESS', IsCountPositiveGuard::class);

// Debug all guard results
$test = AllInvocationPointsMachine::test(['count' => 0]);
$results = $test->debugGuards('PROCESS');
// ['IsCountPositiveGuard' => false]

Validation Guard Testing

ValidationGuardBehavior differs from a regular guard in one important way: instead of silently blocking the transition, it throws a validation exception with structured error messages. assertValidationFailed() catches that exception and lets you assert which field caused the failure.

php
OrderMachine::test()
    ->assertValidationFailed(
        ['type' => 'PAY', 'payload' => ['amount' => -1]],
        'amount',  // expected error key
    );

Path Testing — Full Lifecycle

assertPath() drives the machine through an entire sequence in one call: it sends each event in order and immediately asserts the expected state and context after each step. This makes it the primary tool for verifying multi-step workflows, because a single assertPath() replaces a chain of individual send() + assertState() calls.

php
TrafficLightsMachine::test()
    ->assertPath([
        ['event' => 'INCREASE', 'state' => 'active', 'context' => ['count' => 1]],
        ['event' => 'INCREASE', 'state' => 'active', 'context' => ['count' => 2]],
    ]);

Table-Driven Transition Testing

Machine::assertTransitions() verifies a set of independent edges — each row boots a fresh machine at from via startingAt(), sends the event, and asserts the target. It formalizes the gold-standard "one edge per test" pattern without one test method per edge:

php
FindeksMachine::assertTransitions([
    ['from' => 'findeks.report_retrieval.syncing_phones',  'event' => 'PHONES_SYNCED',   'to' => 'findeks.report_retrieval.checking_consent'],
    ['from' => 'findeks.report_retrieval.checking_consent', 'event' => 'CONSENT_MISSING', 'to' => 'findeks.awaiting_consent'],
    // Guarded edge: transition must be BLOCKED (guard fails or validation guard rejects)
    ['from' => 'findeks.awaiting_consent', 'event' => 'RETRY_REQUESTED', 'to' => null, 'guarded' => true],
    // Row-level context overrides the shared context for that row only
    ['from' => 'findeks.awaiting_consent', 'event' => 'CONSENT_GRANTED', 'to' => 'findeks.report_retrieval', 'context' => ['consent' => true]],
], context: ['tckn' => '12345678901'], faking: [StorePhonesAction::class]);

Semantics:

  • Fresh machine per row — rows never share mutated context or state; persistence is disabled (inherited from startingAt()). Rows run in order and the first failing row fails the test.
  • Row context is array_replace'd over the shared context: (row keys win).
  • Unhandled events fail loudly — an event with no transition from from fails the row with a distinct "event not handled" message (guarded or not), catching event-name typos.
  • Guard-blocked rows fail unless guarded: true — even when to equals from, so guard-blocked self-transitions can't pass vacuously. Guarded rows accept both regular guard blocks (TRANSITION_FAIL) and ValidationGuardBehavior rejections.
  • Row shape is validated up front — empty tables, missing from/event/to keys, guarded: true with a non-null to, to: null without guarded, and non-behavior faking: entries all throw InvalidArgumentException naming the row.
  • Path coverage — rows are tracked by PathCoverageTracker exactly like individual startingAt() + send() tests; guarded rows record only the from state.

assertPath() vs assertTransitions()

  • assertPath() — ONE sequential journey: each step continues from the previous step's state. Use it to verify a multi-step workflow end to end.
  • assertTransitions() — INDEPENDENT edges: every row starts fresh at its own from. Use it to cover a machine's transition table (state × event → target) systematically.

Hierarchical State Transitions

Nested (compound) states are identified with dot notation, where the parent state name and child state name are joined by a dot (e.g., checkout.shipping). Use the same notation in assertState() and assertTransition() to target or verify any level of the hierarchy.

php
CheckoutMachine::test()
    ->assertState('checkout.shipping')
    ->assertTransition('CONTINUE', 'checkout.payment')
    ->assertTransition('CONTINUE', 'checkout.review')
    ->assertTransition('CONFIRM', 'completed');

@always Transitions

@always transitions fire automatically when their guard condition is met:

php
SyncMachine::test(['is_ready' => false])
    ->assertState('waiting')
    ->send(['type' => 'UPDATE', 'payload' => ['is_ready' => true]])
    ->assertState('processing');  // @always transition fired

Verify transient router states were visited using assertTransitionedThrough():

php
// @always states appear in history even though they resolve immediately
OrderMachine::test()
    ->send('SUBMIT')
    ->assertTransitionedThrough(['idle', 'router', 'processing'])
    ->assertState('processing');

Testing Event Preservation (v8+)

Verify that @always actions receive the original event payload:

php
// Action on @always transition captures the original event
OrderMachine::test()
    ->send(['type' => 'SUBMIT', 'payload' => ['tckn' => '12345678901']])
    ->assertState('verification')
    ->assertContext('captured_payload', ['tckn' => '12345678901']);

Raised Events

An action can push additional events onto the machine's internal queue using raise(). Those raised events are processed immediately after the current transition completes, exactly as if they had been sent from outside — enabling a single external event to trigger a chain of further transitions. assertHistoryContains() lets you verify that a raised event was processed during that chain.

php
OrderMachine::test()
    ->send('PROCESS')
    ->assertState('completed')
    ->assertHistoryContains('PROCESSING_COMPLETE');

Path Coverage Analysis

EventMachine can statically enumerate all paths through a machine definition and track which paths your tests exercise.

Enumerating Paths

bash
php artisan machine:paths "App\Machines\FindeksMachine"

This produces a complete list of all possible paths grouped by type: HAPPY, FAIL, TIMEOUT, LOOP, GUARD_BLOCK, DEAD_END, TRUNCATED.

Parallel States

A parallel state is analysed at two levels, and the distinction matters when reading the output.

Machine level covers the parallel state's own continuations: its @done and @fail branches, and any transition declared on the state itself or inherited from an ancestor. A transition that re-enters a parallel state the path has already visited terminates as a LOOP, exactly as it would anywhere else.

Region level is scoped to each region's own subtree and appears under the PARALLEL: heading, one block per region. A region's paths describe that region, never the whole machine, and each carries its own type:

Region path typeMeaning
HAPPY, LOOP, GUARD_BLOCK, DEAD_ENDthe usual outcomes, reached without leaving the region
REGION_EXITa transition declared inside the region targets a state outside it. At runtime this re-points that region's slot while the parallel state stays active. The final step names the escaping event and its target.
REGION_DEFERREDevery continuation of that state is declared at or above the parallel state, so machine-level enumeration owns it. This is not a dead end — the runtime can leave the state; it is simply represented one level up.

The two types divide the work differently. A transition inherited from at or above the parallel state is followed at machine level only, which is why a region records REGION_DEFERRED rather than repeating it. A transition declared inside a region that leaves it works the other way round: the region records the edge as REGION_EXIT, and machine-level enumeration continues from that target, so this one edge does appear at both levels — once as the region's exit, once as the continuation beyond it. Without that second half, everything downstream of a region escape would be absent from the analysis with no truncation flag to show for it.

Final States Are Not Where Paths Stop

A final state is an outcome, not necessarily an ending. findTransitionDefinition walks the parent chain with no special case for FINAL, so the runtime can leave a final state three ways, and all three are enumerated:

ShapeWhat the runtime doesWhat the analysis records
A handler declared on an ancestorThe machine rests at the final child until that event arrives, then leavesThe terminal path and the continuation past the event — both are real outcomes
A compound @done whose branches are all guardedIf every guard fails the @done does not fire and the machine rests at the final childSame: the @done continuation, the rest-at-final path, and anything reachable from there
A compound @done with an unguarded branchIt always fires; the machine never restsOnly the continuation — no terminal path, because resting never happens
A final state that delegates (machine/job)Its @done/@fail still routeThe terminal path and both delegation outcomes

This is why a machine with any of those shapes enumerates substantially more paths than it used to. Before, a final state ended the walk unconditionally, so those routes were missing from the analysis with no truncation flag to say so — and a coverage figure computed over the shorter list read higher than it should have. See Upgrading for the re-baselining advice.

The scenario resolver follows the same rules, so machine:scenario can now route through a final state to a target beyond it.

When the Analysis Stops Early

Enumeration is bounded on two axes, so it terminates whatever shape a definition takes:

bash
php artisan machine:paths "App\Machines\CarSales\CarSalesMachine" --max-paths=2000 --max-depth=400

A branch cut at the depth ceiling is recorded as a TRUNCATED path rather than dropped. The path ceiling necessarily works the other way: once the budget is spent there is no room left to record anything, so further branches are not recorded at all — path_limit_reached is what tells you they existed. Either way the command says so, and a partial analysis is never presented as a complete one. The console prints which ceiling fired and how to raise it. In --json, the flags path_limit_reached, depth_limit_reached, analysis_truncated and truncated_paths live inside the stats object alongside the existing counts, and region paths arrive under a top-level parallel_groups array. stats.terminal_paths keeps its existing meaning — the size of the paths array — rather than being redefined to exclude truncated entries.

Truncated paths are excluded from path-coverage accounting: an incomplete prefix is one no test run could ever match, so counting it would put 100% permanently out of reach. machine:coverage reports analysis_truncated in both its human and JSON output for the same reason — a percentage computed over an enumeration that stopped early is not a coverage guarantee, even when it reads well.

The reverse mismatch is reported too. An observed signature with no enumerated path to match it means a run took a route the analysis does not know about, so the enumeration is incomplete whatever the percentage says. machine:coverage warns and lists them, --json carries them as unmatched_observed, and PathCoverageReport::unmatchedObservations() exposes them. The assertions deliberately do not fail on it: the tracker can produce a signature no enumerated path matches for reasons of its own — a parallel machine records a region leaf id where enumeration records the parallel container — so gating on it would fail suites whose analysis is perfectly complete. Read it as a hint that something is worth checking, not as a verdict.

Tracking Coverage in Tests

Add the TracksPathCoverage trait to your test suite. It automatically enables the tracker, cleans stale data, and exports coverage when the process exits:

php
// In tests/Pest.php:
use Tarfinlabs\EventMachine\Testing\TracksPathCoverage;

uses(TracksPathCoverage::class)->in('Feature', 'Unit');

// Or in a PHPUnit base TestCase:
use Tarfinlabs\EventMachine\Testing\TracksPathCoverage;

abstract class TestCase extends BaseTestCase
{
    use TracksPathCoverage;
}

The trait works with both PHPUnit and Pest, including parallel test runners (Paratest). Each worker writes a separate coverage file; the machine:coverage command merges them automatically.

The tracker records state transitions through TestMachine. Paths are completed when assertFinished() or assertState() (on a FINAL state) is called.

Adopting Path Coverage in Your Application

Path coverage is not package-internal tooling — wire it into your Laravel app's test suite in three steps:

  1. Enable tracking — add TracksPathCoverage to your app's tests/Pest.php (uses(TracksPathCoverage::class)->in('Machines')) or to the base TestCase your machine tests extend. Scope it to the directories containing machine tests; it is a no-op elsewhere.
  2. Enumerate what "all paths" means — run php artisan machine:paths "App\Machines\CarSales\CarSalesMachine" locally to see every HAPPY/FAIL/TIMEOUT/GUARD_BLOCK path your tests should exercise. Uncovered path types are usually missing test scenarios, not tooling noise.
  3. Assert coverage — add a dedicated coverage test per machine (CarSalesMachine::assertPathCoverage(minimum: 90.0) or assertAllPathsCovered()), and/or gate it in CI after the suite: php artisan machine:coverage "App\Machines\CarSales\CarSalesMachine" --min=90.

Start with a low --min and ratchet it up — turning on assertAllPathsCovered() for an existing machine usually surfaces genuinely untested branches (@fail routes, guard blocks, timeouts) rather than false gaps.

Coverage Assertions

php
// Assert all enumerated paths are covered by tests
FindeksMachine::assertAllPathsCovered();

// Assert at least 90% of paths are covered
FindeksMachine::assertPathCoverage(minimum: 90.0);

minimum is a percentage between 0 and 100, so minimum: 0.8 is a threshold every run clears rather than "80%".

Both assertions fail when path enumeration stopped early at either ceiling, because a figure measured over part of a machine is not coverage. Both ceilings are parameters, so a machine that legitimately needs more room can say so:

php
FindeksMachine::assertAllPathsCovered(maxPaths: 5000, maxDepth: 400);
FindeksMachine::assertPathCoverage(minimum: 90.0, maxPaths: 5000);

The failure message names which ceiling fired. Inspect it with php artisan machine:paths <machine> --max-paths=<larger> --max-depth=<larger>.

Path Types

Console and JSON output print the enum's own value, in lower case — happy, region_exit — while this table names the cases.

TypeLevelMeaning
HAPPYmachine, regionReached a FINAL state without @fail or timer
FAILmachine, regionPath contains an @fail step
TIMEOUTmachine, regionPath contains a timer-triggered step or @timeout
LOOPmachine, regionCycle detected — path revisits a state
GUARD_BLOCKmachine, regionAll guards fail with no fallback — event swallowed
DEAD_ENDmachine, regionATOMIC state with no transitions and not FINAL
TRUNCATEDmachine, regionEnumeration hit a ceiling and the path was cut short
REGION_EXITregion onlyA transition declared inside the region targets a state outside it
REGION_DEFERREDregion onlyEvery continuation is declared at or above the parallel state, so machine level owns it

The last two appear only inside a PARALLEL: block: region paths are collected separately from machine-level paths, so they never show up in the per-type groups.

Child Machine Visibility

Path analysis treats child machines as opaque (compositional verification). Each machine's paths are analyzed independently. The output shows:

  • Child machine/job class names on invoke state steps (e.g., processing (PaymentMachine))
  • Async/sync mode and queue in the stats section
  • Unhandled child outcome warnings when a child has final states the parent doesn't route via @done.{state}

To see a child machine's internal paths, run machine:paths on the child separately.

CI Integration

yaml
- run: composer test
- run: php artisan machine:coverage FindeksMachine --min=100

See Artisan Commands for full command documentation.

Related

See TestMachine for the complete assertion API, Isolated Testing for unit-level guard testing, Fakeable Behaviors for guard faking, and Recipes for common real-world patterns.

Released under the MIT License.