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.
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.
// 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 bypassedGuard-Specific Assertions
Verify which guard blocked an event with assertGuardedBy():
// 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.
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.
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:
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 sharedcontext:(row keys win). - Unhandled events fail loudly — an event with no transition from
fromfails the row with a distinct "event not handled" message (guarded or not), catching event-name typos. - Guard-blocked rows fail unless
guarded: true— even whentoequalsfrom, so guard-blocked self-transitions can't pass vacuously. Guarded rows accept both regular guard blocks (TRANSITION_FAIL) andValidationGuardBehaviorrejections. - Row shape is validated up front — empty tables, missing
from/event/tokeys,guarded: truewith a non-nullto,to: nullwithoutguarded, and non-behaviorfaking:entries all throwInvalidArgumentExceptionnaming the row. - Path coverage — rows are tracked by
PathCoverageTrackerexactly like individualstartingAt()+send()tests; guarded rows record only thefromstate.
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 ownfrom. 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.
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:
SyncMachine::test(['is_ready' => false])
->assertState('waiting')
->send(['type' => 'UPDATE', 'payload' => ['is_ready' => true]])
->assertState('processing'); // @always transition firedVerify transient router states were visited using assertTransitionedThrough():
// @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:
// 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.
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
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 type | Meaning |
|---|---|
HAPPY, LOOP, GUARD_BLOCK, DEAD_END | the usual outcomes, reached without leaving the region |
REGION_EXIT | a 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_DEFERRED | every 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:
| Shape | What the runtime does | What the analysis records |
|---|---|---|
| A handler declared on an ancestor | The machine rests at the final child until that event arrives, then leaves | The terminal path and the continuation past the event — both are real outcomes |
A compound @done whose branches are all guarded | If every guard fails the @done does not fire and the machine rests at the final child | Same: the @done continuation, the rest-at-final path, and anything reachable from there |
A compound @done with an unguarded branch | It always fires; the machine never rests | Only the continuation — no terminal path, because resting never happens |
A final state that delegates (machine/job) | Its @done/@fail still route | The 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:
php artisan machine:paths "App\Machines\CarSales\CarSalesMachine" --max-paths=2000 --max-depth=400A 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:
// 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:
- Enable tracking — add
TracksPathCoverageto your app'stests/Pest.php(uses(TracksPathCoverage::class)->in('Machines')) or to the baseTestCaseyour machine tests extend. Scope it to the directories containing machine tests; it is a no-op elsewhere. - 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. - Assert coverage — add a dedicated coverage test per machine (
CarSalesMachine::assertPathCoverage(minimum: 90.0)orassertAllPathsCovered()), 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
// 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:
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.
| Type | Level | Meaning |
|---|---|---|
| HAPPY | machine, region | Reached a FINAL state without @fail or timer |
| FAIL | machine, region | Path contains an @fail step |
| TIMEOUT | machine, region | Path contains a timer-triggered step or @timeout |
| LOOP | machine, region | Cycle detected — path revisits a state |
| GUARD_BLOCK | machine, region | All guards fail with no fallback — event swallowed |
| DEAD_END | machine, region | ATOMIC state with no transitions and not FINAL |
| TRUNCATED | machine, region | Enumeration hit a ceiling and the path was cut short |
| REGION_EXIT | region only | A transition declared inside the region targets a state outside it |
| REGION_DEFERRED | region only | Every 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
- run: composer test
- run: php artisan machine:coverage FindeksMachine --min=100See 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.