Skip to content

Inter-Machine Testing ​

Test child machine delegation and cross-machine communication without running actual child machines.

Machine Faking ​

Use Machine::fake() to short-circuit child machine execution in tests:

php
use Tarfinlabs\EventMachine\Actor\Machine;

// Fake a successful child output
PaymentMachine::fake(output: ['paymentId' => 'pay_123']);

// Fake a failure
PaymentMachine::fake(fail: true, error: 'Insufficient funds');

// Fake with specific final state name
PaymentMachine::fake(output: ['status' => 'ok'], finalState: 'approved');

When a faked machine is invoked via the machine key, it short-circuits: no child machine is actually created. Instead, it records the invocation and immediately routes @done (or @fail) on the parent.

Machine::fake() options:

OptionTypeDescription
outputarrayThe output the child "returns" via @done
failboolWhen true, triggers @fail instead of @done
errorstringError message passed to ChildMachineFailEvent
finalStatestringOverride the final state name reported to the parent

Assertion Methods ​

After faking, verify invocations:

php
use Tarfinlabs\EventMachine\Actor\Machine;

PaymentMachine::fake(output: ['paymentId' => 'pay_123']);

// ... run the parent machine ...

// Was the child invoked?
PaymentMachine::assertInvoked();

// How many times?
PaymentMachine::assertInvokedTimes(1);

// With specific context values?
PaymentMachine::assertInvokedWith(['orderId' => 'ORD-1']);

// Was it NOT invoked?
PaymentMachine::assertNotInvoked();

assertInvokedWith() checks that at least one invocation contains the expected key-value pairs (subset matching).

TestMachine Fluent API for Delegation ​

The TestMachine fluent API integrates child delegation testing directly into the chain — no separate static calls needed:

php
OrderMachine::test()
    ->fakingChild(PaymentMachine::class, output: ['id' => 'pay_1'], finalState: 'approved')
    ->send('PLACE_ORDER')
    ->assertState('completed')
    ->assertChildInvoked(PaymentMachine::class)
    ->assertChildInvokedWith(PaymentMachine::class, ['orderId' => 'ORD-1'])
    ->assertRoutedViaDoneState('approved');

This is the recommended approach for delegation testing. It handles cleanup automatically via resetFakes().

Choosing the Right Test Level ​

Child delegation can be tested at three levels. Pick the one that matches what you're verifying:

LevelToolWhat It TestsDB Needed
UnitforTesting() + runWithState()Single guard/action logic in isolationNo
FocusedstartingAt() + fakingAllActions(except:)Specific state behavior without path replayNo
IntegrationsimulateChildDone/FailMachine & job delegation routing, guard chains, state flowNo
E2EMachine::fake() + create()Full delegation pipeline with persistenceYes

simulateChild* is DB-free

simulateChildDone(), simulateChildFail(), and simulateChildTimeout() do not touch the database. They route events entirely through definition-level logic (routeChildDoneEvent/routeChildFailEvent), so they work with TestMachine::define(), Machine::test(), and Machine::startingAt() equally well.

For async simulation (parent already waiting for child):

php
OrderMachine::test()
    ->send('START_ASYNC_PAYMENT')
    ->assertState('awaiting_payment')
    ->simulateChildDone(PaymentMachine::class, output: ['id' => 'pay_1'], finalState: 'approved')
    ->assertState('completed');

For failure simulation with structured error data:

php
VerificationMachine::test()
    ->send('PROCESS_PAYMENT')
    ->assertState('processing_payment')
    ->simulateChildFail(
        ProcessPaymentJob::class,
        errorMessage: 'Payment declined',
        errorCode: 311,
        output: ['errorCode' => 'E311', 'retryable' => true],
    )
    ->assertState('awaiting_pin'); // guard routed to retry branch

See TestMachine — Child Delegation Assertions and TestMachine — Async Simulation for the full API reference.

Faking Standalone Machines ​

When testing controllers or services that use Machine::create(), you can fake the machine to isolate your test from the machine pipeline:

php
// Without fake: controller triggers full machine restore + transition + persist
// With fake: Machine::create() returns a stub — send/persist are no-ops

OrderMachine::fake();

$response = $this->postJson("/consent/{$hash}/approve");

$response->assertOk();
OrderMachine::assertCreated();
OrderMachine::assertSent('ORDER_SUBMITTED');
// No resetMachineFakes() needed — InteractsWithMachines handles it

Instance-Level No-Ops

Only instances created via the fake intercept are no-ops. Real instances of the same class (e.g., child delegation via withDefinition()) work normally.

Separate Tracking

assertCreated() and assertSent() use separate tracking from assertInvoked(). Child delegation and standalone usage don't interfere with each other.

Testing Per-Final-State Routing ​

When a child machine has multiple final states, use Machine::fake(finalState: ...) to test which @done.{state} route fires:

php
use Tarfinlabs\EventMachine\Actor\Machine;

// Child finished in 'approved' → parent follows @done.approved route
PaymentMachine::fake(finalState: 'approved');
$machine = OrderWorkflowMachine::create();
$machine->send(['type' => 'START']);
expect($machine->state->currentStateDefinition->id)->toContain('completed');

// Child finished in 'declined' → parent follows @done.declined route
Machine::resetMachineFakes();
PaymentMachine::fake(finalState: 'declined');
$machine = OrderWorkflowMachine::create();
$machine->send(['type' => 'START']);
expect($machine->state->currentStateDefinition->id)->toContain('payment_failed');

// No finalState → falls through to @done catch-all
Machine::resetMachineFakes();
PaymentMachine::fake();
$machine = OrderWorkflowMachine::create();
$machine->send(['type' => 'START']);
expect($machine->state->currentStateDefinition->id)->toContain('fallback');

finalState is routing-relevant

The finalState parameter on Machine::fake() determines which @done.{state} route fires on the parent. When omitted (or null), the event has no final state info and falls through to the @done catch-all.

Typed Contract Testing ​

When child machines use typed contracts (MachineInput, MachineOutput, MachineFailure), the same faking and simulation methods work with typed instances:

Faking with MachineOutput ​

php
use Tarfinlabs\EventMachine\Actor\Machine;

// Fake with a MachineOutput instance
PaymentMachine::fake(output: new PaymentOutput(
    paymentId: 'pay_123',
    amount: 5000,
    status: 'captured',
));

OrderMachine::test()
    ->send('START')
    ->assertState('completed');

PaymentMachine::assertInvoked();

Simulating Typed Child Done ​

php
OrderMachine::test()
    ->send('START_PAYMENT')
    ->assertState('awaiting_payment')
    ->simulateChildDone(
        PaymentMachine::class,
        output: new PaymentOutput(
            paymentId: 'pay_456',
            amount: 5000,
            status: 'captured',
        ),
    )
    ->assertState('completed');

Simulating Typed Child Fail ​

php
OrderMachine::test()
    ->send('START_PAYMENT')
    ->assertState('awaiting_payment')
    ->simulateChildFail(
        PaymentMachine::class,
        output: new PaymentFailure(
            errorCode: 'INSUFFICIENT_FUNDS',
            retryable: true,
        ),
    )
    ->assertState('retrying_payment');

The MachineOutput and MachineFailure instances are serialized via toArray() before being passed to the parent's routing logic, matching the production behavior.

Full Test Example ​

php
use Tarfinlabs\EventMachine\Actor\Machine;

it('processes order through payment', function (): void {
    // Arrange: fake the child machine
    PaymentMachine::fake(output: ['paymentId' => 'pay_456']);

    // Act: run the orchestrator
    $machine = OrderWorkflowMachine::create();
    $machine->send(['type' => 'START_ORDER', 'payload' => ['orderId' => 'ORD-1']]);

    // Assert: child was invoked with correct context
    PaymentMachine::assertInvoked();
    PaymentMachine::assertInvokedWith(['orderId' => 'ORD-1']);

    // Assert: parent received child output and transitioned
    expect($machine->state->context->get('paymentId'))->toBe('pay_456');
    // No cleanup needed — InteractsWithMachines handles it
});

Testing dispatchTo / dispatchToParent ​

For async messaging via dispatchTo() and dispatchToParent(), fake the queue:

php
use Illuminate\Support\Facades\Queue;
use Tarfinlabs\EventMachine\Jobs\SendToMachineJob;

it('dispatches progress to parent', function (): void {
    Queue::fake();

    // ... invoke action that calls dispatchToParent() ...

    Queue::assertPushed(SendToMachineJob::class, function (SendToMachineJob $job): bool {
        return $job->event['type'] === 'CHILD_PROGRESS'
            && $job->event['payload']['percent'] === 50;
    });
});

Testing sendTo / sendToParent ​

For sync messaging, verify the target machine's state directly after the call:

php
it('sends event synchronously to target', function (): void {
    $target = TargetMachine::create();
    $target->persist();

    // ... invoke action that calls sendTo() ...

    $restored = TargetMachine::create(state: $rootEventId);
    expect($restored->state->currentStateDefinition->id)->toBe('target.completed');
});

Testing Job Actors ​

Job actors dispatch ChildJobJob internally. Use Queue::fake() to verify:

php
use Illuminate\Support\Facades\Queue;
use Tarfinlabs\EventMachine\Jobs\ChildJobJob;

it('dispatches job actor', function (): void {
    Queue::fake();

    // ... transition to a state with job key ...

    Queue::assertPushed(ChildJobJob::class, function (ChildJobJob $job): bool {
        return $job->jobClass === SendEmailJob::class
            && $job->jobData === ['email' => 'test@example.com'];
    });
});

For fire-and-forget jobs, verify the parent transitions immediately:

php
it('fire-and-forget job transitions parent immediately', function (): void {
    Queue::fake();

    // ... transition to a state with job + target ...

    // Parent should be at target state, not waiting
    expect($state->value)->toBe(['parent.next_state']);

    // Job was dispatched
    Queue::assertPushed(ChildJobJob::class);
});

Simulating Managed Job Completion ​

Managed jobs (with @done/@fail) support the same simulateChild* methods as machine children — the routing infrastructure is identical. Use Queue::fake() to capture the dispatch, then simulate completion:

php
Queue::fake();

VerificationMachine::test()
    ->withoutPersistence()
    ->faking([StoreItemsAction::class, ValidateOrderAction::class])
    ->assertState('processing_items')
    ->simulateChildDone(ProcessItemsJob::class, output: [
        'phones' => [['itemId' => 'ITEM-1', 'quantity' => 2]],
    ])
    ->assertState('awaiting_confirmation');

This tests the @done routing logic without running the actual job. For dispatch verification, use Queue::assertPushed(ChildJobJob::class).

Testing Fire-and-Forget Machine Delegation ​

For fire-and-forget machine delegation (machine + queue, no @done), use Machine::fake() to verify the child was invoked and the parent stayed in state (or transitioned):

php
use Tarfinlabs\EventMachine\Actor\Machine;

it('fire-and-forget machine delegation stays in state', function (): void {
    AuditMachine::fake(output: []);

    $machine = AccountMachine::create();
    $machine->send(['type' => 'SUSPEND']);

    // Parent stays in the delegating state
    expect($machine->state->currentStateDefinition->id)->toContain('suspended');

    // Child was invoked
    AuditMachine::assertInvoked();
    AuditMachine::assertInvokedWith(['userId' => 'usr_123']);
    // No cleanup needed — InteractsWithMachines handles it
});

Without faking, use Queue::fake() to verify the ChildMachineJob dispatch:

php
use Illuminate\Support\Facades\Queue;
use Tarfinlabs\EventMachine\Jobs\ChildMachineJob;

it('dispatches fire-and-forget ChildMachineJob', function (): void {
    Queue::fake();

    $machine = AccountMachine::create();
    $machine->send(['type' => 'SUSPEND']);

    Queue::assertPushed(ChildMachineJob::class, function (ChildMachineJob $job): bool {
        return $job->childMachineClass === AuditMachine::class
            && $job->fireAndForget === true;
    });
});

Testing Forward Endpoints ​

Forward endpoints let a parent machine proxy HTTP events to a running async child. In feature tests, you POST to the parent's forwarded URI and assert the child's state changed.

Basic Forward Test ​

php
use Illuminate\Foundation\Testing\RefreshDatabase;
use Tarfinlabs\EventMachine\Routing\MachineRouter;

uses(RefreshDatabase::class);

it('forwards PROVIDE_CARD to child via parent endpoint', function (): void {
    // 1. Register routes for the parent machine (includes forwarded endpoints)
    MachineRouter::register(OrderMachine::class, 'orders', 'order_mre');

    // 2. Create parent and transition to the delegating state
    $order   = Order::create(['status' => 'pending']);
    $machine = $order->order_mre;
    $machine->send(['type' => 'START']);

    // 3. POST to the parent's forwarded endpoint
    $response = $this->postJson("/orders/{$order->id}/provide-card", [
        'card_number' => '4111111111111111',
    ]);

    // 4. Assert the response includes child state
    $response->assertOk()
        ->assertJsonPath('data.child.value.0', 'payment_child.card_provided');

    // 5. Assert parent is still in delegating state
    $response->assertJsonPath('data.value.0', 'order.processing');
});

Advanced Forward Patterns ​

For detailed forward endpoint testing patterns including response formats, error cases, endpoint customization, childOutput injection, availableEvents introspection, and FQCN forward keys, see the Endpoints reference.

Related

See Cross-Machine Messaging for the API reference, Job Actors for configuration, Machine Delegation for delegation configuration, and Recipes — Child Machine Faking for more examples.

Released under the MIT License.