Skip to content

TestMachine

Fluent test wrapper inspired by Livewire::test(). Provides a chainable API for sending events and asserting state, context, transitions, and behavior execution.

Construction

For class-based machines, use Machine::test() and Machine::startingAt(). For inline throwaway definitions, use TestMachine::define().

php
// From a Machine subclass — THE entry point for class-based testing
TrafficLightsMachine::test()
TrafficLightsMachine::test(context: ['count' => 42])
TrafficLightsMachine::test(context: [...], guards: [...], faking: [...])

// Start at a specific state — skip path replay
TrafficLightsMachine::startingAt('active', context: ['count' => 42])

// Inline definition (no Machine class, no persistence)
TestMachine::define(
    config: ['id' => 'counter', 'initial' => 'active', 'context' => ['count' => 0], 'states' => [
        'active' => ['on' => ['GO' => ['target' => 'done']]],
        'done'   => [],
    ]],
)

Configuration

Chain configuration methods before sending events to customize machine behavior for the test.

php
->faking([SendEmailAction::class, ChargePaymentAction::class])  // class-based: spy mode
->faking(['broadcastAction'])                                      // inline: fake with no-op
->faking(['isValidGuard' => true])                                 // inline: fake with return value
->faking(['calcTax' => fn(ContextManager $ctx) => $ctx->set('tax', 0)])  // inline: custom replacement
->withoutPersistence()                                            // skip DB writes
->withoutParallelDispatch()                                       // run regions sequentially
// Scenarios are activated via HTTP endpoints, not TestMachine.
// See /advanced/scenarios for the QA workflow.

Sending Events

php
->send('SUBMIT')                                              // string shorthand
->send(['type' => 'PAY', 'payload' => ['amount' => 100]])    // full event
->send(PaymentEvent::forTesting()->toArray())                 // from factory
->sendMany(['SUBMIT', 'PAY', 'SHIP'])                        // sequence

State Assertions

php
->assertState('awaiting_payment')
->assertState('checkout.payment')          // hierarchical
->assertNotState('cancelled')
->assertFinished()                         // current state is type:final
->assertOutput($expected)                  // final state output value

Context Assertions

php
->assertContext('total', 100)
->assertContextHas('paid_at')
->assertContextMissing('error')
->assertContextMatches('amount', fn($v) => $v > 0 && $v < 10000)
->assertContextIncludes(['a' => 1, 'b' => 2])

Transition Assertions

php
->assertTransition('NEXT', 'yellow')       // send + assertState in one
->assertGuarded('SHIP')                    // event blocked, state unchanged
->assertGuardedBy('SHIP', IsStockAvailableGuard::class)  // blocked by specific guard
->assertValidationFailed('PAY', 'amount')  // MachineValidationException thrown

History Assertions

php
->assertHistoryContains('SUBMIT', 'PAY')
->assertHistoryOrder('SUBMIT', 'PAY', 'SHIP')  // events appear in this order
->assertTransitionedThrough(['idle', 'processing', 'done'])  // states visited (including @always)

Path Assertions

php
->assertPath([
    ['event' => 'START',   'state' => 'active'],
    ['event' => 'PROCESS', 'state' => 'done', 'context' => ['completed' => true]],
])

Parallel Assertions

php
->assertRegionState('payment', 'charged')
->assertAllRegionsCompleted()              // any parallel state's @done fired
->assertAllRegionsCompleted('processing')  // specific parallel state route

Behavior Assertions

php
->assertBehaviorRan(SendEmailAction::class)        // class-based
->assertBehaviorRan('broadcastAction')              // inline
->assertBehaviorNotRan(RefundAction::class)
->assertBehaviorRanTimes(SendEmailAction::class, 2)
->assertBehaviorRanWith(SendEmailAction::class, fn($ctx) => $ctx->get('email') !== null)
->assertBehaviorRanWith('myAction', fn(array $params) => $params[0]->get('done'))  // inline: array param

Available Events Assertions

Assert which events the machine's current state can accept. Useful for verifying the machine exposes the correct API at each step — especially with forward endpoints.

php
// Assert a specific event is available
->assertAvailableEvent('SUBMIT_ORDER')

// Assert an event is NOT available (e.g., after transitioning away)
->assertNotAvailableEvent('SUBMIT_ORDER')

// Assert exact set of available event types
->assertAvailableEvents(['SUBMIT_ORDER', 'CANCEL'])

// Assert a forwarded event is available (source: 'forward')
->assertForwardAvailable('PROVIDE_CARD')

// Assert no events available (final state)
->assertNoAvailableEvents()
MethodDescription
assertAvailableEvent(string $eventType)Event type is available in current state
assertNotAvailableEvent(string $eventType)Event type is NOT available
assertAvailableEvents(array $expectedTypes)Exact set of available events matches
assertForwardAvailable(string $eventType)Forwarded event is available (source: 'forward')
assertNoAvailableEvents()No events available — current state is final or has no transitions

Parent vs Forward Events

assertAvailableEvent() matches events from any source. assertForwardAvailable() specifically matches events with source: 'forward' — those auto-generated from the forward config on delegation states. See Available Events for details.

Timer Testing

Methods for testing time-based events (after/every on transitions).

php
// Advance time and run timer sweep
->advanceTimers(Timer::days(7))

// Run sweep without advancing time
->processTimers()

// Assert timer exists on current state
->assertHasTimer('ORDER_EXPIRED')

// Assert timer fired / not fired
->assertTimerFired('ORDER_EXPIRED')
->assertTimerNotFired('ORDER_EXPIRED')

See Time-Based Testing for complete examples.

Accessors

When you need direct access to the underlying machine, state, or context — for example, to perform custom assertions not covered by the built-in methods.

php
->machine()     // underlying Machine instance
->state()       // current State object
->context()     // ContextManager instance

Utilities

Helper methods for debugging and mid-chain side effects.

php
// Execute a callback mid-chain for side-effect assertions
->tap(fn($test) => Notification::assertSentTo($user, ApprovalNotification::class))

// Debug guard evaluation results (WARNING: sends the event, mutates state)
->debugGuards('SUBMIT')  // returns ['IsValidGuard' => true, 'HasStockGuard' => false]

Cleanup

Call resetFakes() when you need to clear fakes mid-test. In most cases, the global afterEach hook handles this automatically.

php
->resetFakes()  // cleanup faked behaviors registered via faking()

Note: As of v7.4, resetFakes() also clears Machine::fake() registrations and CommunicationRecorder state.

Note: With the InteractsWithMachines trait, manual resetMachineFakes() calls are no longer needed. The trait resets all Machine fakes, CommunicationRecorder, and InlineBehaviorFake state automatically after each test.

Complete Example

php
TrafficLightsMachine::test()
    ->assertState('active')
    ->assertContext('count', 0)
    ->send('INCREASE')
    ->send('INCREASE')
    ->assertContext('count', 2)
    ->assertHistoryContains('INCREASE')
    ->assertPath([
        // assertPath sends each event, then checks state + context
        ['event' => 'INCREASE', 'state' => 'active', 'context' => ['count' => 3]],
    ]);

Direct State Access

Use direct state access when you need raw values for complex assertions, or when integrating with external assertion libraries that don't work with TestMachine's fluent API.

For advanced cases where TestMachine doesn't fit, you can access the underlying state directly:

php
// Direct state matching
expect($machine->state->matches('processing'))->toBeTrue();
expect($machine->state->value)->toBe(['order.processing']);

// Context access
expect($machine->state->context->get('total'))->toBe(100);
expect($machine->state->context->has('paid_at'))->toBeTrue();

// History inspection
expect($machine->state->history->pluck('type'))->toContain('SUBMIT');

Timer Helpers

For testing time-based events (after/every on transitions):

php
// Advance time and trigger timer sweep inline
$machine->test()
    ->advanceTimers(Timer::days(8))
    ->assertState('expired');

// Run timer sweep without advancing time
$machine->test()
    ->processTimers()
    ->assertState('active');  // no timers due yet

// Assert timer exists / fired / not fired
$machine->test()
    ->assertHasTimer('ORDER_EXPIRED')
    ->advanceTimers(Timer::days(8))
    ->assertTimerFired('ORDER_EXPIRED');

$machine->test()
    ->assertTimerNotFired('ORDER_EXPIRED');

See Time-Based Testing for full details.

Schedule Helpers

For testing scheduled events (schedules key on definition):

php
// Send scheduled event inline (bypasses queue)
$machine->test()
    ->runSchedule('CHECK_EXPIRY')
    ->assertState('expired');

// Assert schedule exists
$machine->test()
    ->assertHasSchedule('CHECK_EXPIRY')
    ->assertHasSchedule('DAILY_REPORT');

See Scheduled Testing for full details.

Child Delegation Assertions

When a state delegates to a child machine, use fakingChild() to short-circuit the delegation and control the outcome. Then assert what happened using the child assertion methods.

php
OrderMachine::test()
    ->fakingChild(PaymentMachine::class, output: ['id' => 'pay_1'], finalState: 'approved')
    ->send('PLACE_ORDER')
    ->assertChildInvoked(PaymentMachine::class)
    ->assertChildNotInvoked(AuditMachine::class)
    ->assertChildInvokedTimes(PaymentMachine::class, 1)
    ->assertChildInvokedWith(PaymentMachine::class, ['orderId' => 'ORD-1'])
    ->assertRoutedViaDoneState('approved')
MethodDescription
fakingChild(class, output, finalState)Short-circuit child delegation with a given outcome
assertChildInvoked(class)Assert the child machine was invoked at least once
assertChildNotInvoked(class)Assert the child machine was never invoked
assertChildInvokedTimes(class, int)Assert invocation count
assertChildInvokedWith(class, array)Assert initial context passed to the child
assertRoutedViaDoneState(string)Assert the @done routing used a specific child final state

TIP

Chain multiple fakingChild() calls for multiple children. This keeps the API simple and each call explicit.

php
// Complete fluent delegation testing pattern
OrderMachine::test()
    ->fakingChild(PaymentMachine::class, output: ['id' => 'pay_1'], finalState: 'approved')
    ->fakingChild(FraudCheckMachine::class, output: ['score' => 0.1], finalState: 'clear')
    ->send('PLACE_ORDER')
    ->assertChildInvoked(PaymentMachine::class)
    ->assertChildInvoked(FraudCheckMachine::class)
    ->assertRoutedViaDoneState('approved')
    ->assertState('order_confirmed')
    ->assertContext('paymentId', 'pay_1')

Async Simulation

Use simulateChild* methods to trigger completion on a parent that is already waiting for a child — as opposed to fakingChild() which short-circuits at the delegation entry point. Works for both machine delegation (machine: key) and job actors (job: key) — the routing infrastructure is identical.

php
->simulateChildDone(PaymentMachine::class, output: ['id' => 'pay_1'], finalState: 'approved')
->simulateChildFail(PaymentMachine::class, errorMessage: 'Insufficient funds')
->simulateChildTimeout(PaymentMachine::class)
MethodDescription
simulateChildDone(class, output, finalState)Trigger @done transition as if the child completed successfully
simulateChildFail(class, errorMessage)Trigger @fail transition as if the child threw an error
simulateChildTimeout(class)Trigger @timeout transition as if the child exceeded its deadline

INFO

The output parameter populates the output() accessor on the event, matching Machine::fake() behavior.

fakingChild() vs simulateChildDone():

  • fakingChild() — short-circuits at delegation entry. The child is never dispatched. Use this for most unit tests where you control the happy/failure path.
  • simulateChild*() — the parent has already entered the waiting state (child was dispatched). Use this when testing a parent that was constructed around an in-progress child, or when you need to test the parent's response to a completion event independently.

Job actor example (use Queue::fake() to capture the dispatch):

php
Queue::fake();

MyMachine::test()
    ->withoutPersistence()
    ->send('START')
    ->assertState('processing')
    ->simulateChildDone(ProcessDataJob::class, output: ['status' => 'done'])
    ->assertState('completed');

Bulk Faking

Fake all class-based behaviors in one call instead of listing each one:

php
->fakingAllActions()                                   // all actions → spy
->fakingAllActions(except: [StorePaymentAction::class])    // all except this one
->fakingAllActions(except: ['storePinAction'])          // by behavior key

->fakingAllGuards()                                    // all guards → spy
->fakingAllBehaviors()                                 // actions + guards + calculators

The except: parameter accepts both class FQCNs and behavior key strings. Excluded behaviors run their real logic.

Testing Nothing Trap

fakingAllActions() without except: means NO action logic runs. Combined with fakingAllGuards(), your test only verifies transition wiring (@done → state_x) — which is already visible in the machine config.

Ask yourself: "What behavior am I actually testing?"

php
// ✅ Good — tests StorePaymentAction logic:
->fakingAllActions(except: [StorePaymentAction::class])

// ⚠️ Questionable — tests nothing but config:
->fakingAllActions()
->fakingAllGuards()

fakingAllActions() vs fakingAllBehaviors()

fakingAllActions() only fakes actions. Calculators are NOT affected. If your test passes through @always transitions with calculators, use fakingAllBehaviors() which includes actions + guards + calculators.

Pre-Init Behavior Faking

The guards: and faking: parameters on Machine::test() and Machine::startingAt() set behavior fakes before getInitialState() runs — solving the @always timing problem where guards and actions run before the fluent chain reaches fakingAllActions():

php
VerificationMachine::test(
    context: ['orderId' => 'ORD-1'],
    guards: [
        HasExistingOrderGuard::class  => false,
        IsPaymentValidGuard::class       => true,
    ],
    faking: [InitializeOrderAction::class],
)
->fakingAllActions()
->assertState('processing');
  • guards: — sets guard return values ($class::shouldReturn($value))
  • faking: — spies behavior classes ($class::spy()) — prevents real side effects during init

Starting at a Specific State

Skip path replay and start the machine at any state:

php
VerificationMachine::startingAt(
    stateId: 'processing_payment',
    context: ['orderId' => 'ORD-1', 'amount' => 5000],
    guards: [IsRetryableGuard::class => true],
)
->fakingAllActions(except: [StorePaymentAction::class])
->send(PaymentConfirmedEvent::make(['confirmationCode' => 'ABC123']))
->assertState('verifying');

startingAt() creates the machine at the given state without running any lifecycle (no entry actions, no @always, no job dispatch). The machine uses the real definition — all transitions, guards, and actions are available.

When to use startingAt() vs withContext()

  • withContext() — tests the full path from initial state. Entry actions and @always run. Use for integration tests that validate the complete flow.
  • startingAt() — tests behavior FROM a specific state. No path validation. Use for focused unit tests of a single transition or state behavior.

Cross-Machine Communication Assertions

Assert that the machine sent events to other machines, dispatched async messages, or raised internal events.

php
OrderMachine::test()
    ->recordingCommunication()
    ->send('SHIP')
    ->assertSentTo(WarehouseMachine::class)
    ->assertSentTo(WarehouseMachine::class, 'PICK_ORDER')
    ->assertNotSentTo(RefundMachine::class)
    ->assertDispatchedTo(AuditMachine::class)
    ->assertRaisedEvent('RETRY')
MethodDescription
recordingCommunication()Enable communication recording (skips actual sendTo calls)
assertSentTo(class)Assert at least one synchronous sendTo was made to the target
assertSentTo(class, string)Assert a specific event type was sent to the target
assertNotSentTo(class)Assert no sendTo calls were made to the target
assertDispatchedTo(class)Assert a dispatchTo (queued) call was made to the target
assertRaisedEvent(string)Assert an internal raise() was called and the event was processed

INFO

assertRaisedEvent() asserts the event was raised AND processed (appears in history). This is a stronger assertion than checking the raise call alone. For unit-level raise testing (without a full machine), use assertRaised() after runWithState() — see Isolated Testing — Raised Events.

Note: assertDispatchedTo requires Queue::fake() to intercept queued jobs. recordingCommunication() skips actual sendTo calls — the target machine does not need to exist in the database.

Forward Endpoint Helpers

When testing delegation states that expose a forward endpoint, use withRunningChild() to simulate an already-running child so the parent can accept forwarded events.

php
OrderMachine::test()
    ->withRunningChild(PaymentMachine::class)
    ->send('PROVIDE_CARD', ['number' => '4111...'])
    ->assertForwardAvailable('PROVIDE_CARD')
MethodDescription
withRunningChild(class)Simulate a running child machine so the parent can receive forwarded events

TIP

The child starts in its initial state. If you need the child in a specific state for forward testing, create it separately with TestMachine::for() and send events to reach the desired state.

withRunningChild() requires persistence

withRunningChild() creates a child record in the database. It does NOT work with withoutPersistence() or TestMachine::define(). Use Machine::test() (persistence enabled by default).

When to Use What

ScenarioUseWhy
Full machine flow with assertionsMachine::test()Fluent chain, automatic cleanup, pre-init context
Skip to deep stateMachine::startingAt()No path replay, focused testing
Quick inline definitionTestMachine::define()No Machine class needed
Wrapping existing instanceTestMachine::for($machine)Access fluent API on pre-built machine
Quick transition unit testMachineDefinition::define() + transition()Lightweight, no TestMachine overhead

Rule of thumb: Use Machine::test() for class-based machines, TestMachine::define() for inline throwaway machines, MachineDefinition::define() when you only need getInitialState() + transition().

Method Compatibility

MethodwithoutPersistence()startingAt()fakingAllActions()
assertRegionState()
assertAllRegionsCompleted()
simulateChildDone/Fail()
fakingChild()
recordingCommunication()
assertSentTo/DispatchedTo()
withRunningChild()❌ Requires DB❌ Requires DB
advanceTimers()

Related

See Isolated Testing for unit-level runWithState(), Fakeable Behaviors for the faking API, Transitions & Paths for guard and path testing, Parallel Testing for parallel state testing, Persistence Testing for DB-level testing, Time-Based Testing for timer testing, and Scheduled Testing for schedule testing.

Released under the MIT License.