Skip to content

Isolated Behavior Testing

Unit-level testing — the bottom of the testing pyramid. Test individual behaviors without booting a machine or touching the database.

State::forTesting()

Create lightweight state objects for isolated tests:

php
// Simple — array context
$state = State::forTesting(['count' => 0, 'items' => []]);

// With ContextManager
$ctx = new ContextManager(['amount' => 100]);
$state = State::forTesting($ctx);

// With EventBehavior (for guards/actions that read event data)
$event = AddValueEvent::forTesting(['payload' => ['value' => 42]]);
$state = State::forTesting(['amount' => 100], currentEventBehavior: $event);

// With StateDefinition (for behaviors that inspect current state)
$state = State::forTesting(['count' => 5], currentStateDefinition: $stateDef);

runWithState()

Uses the exact same injectInvokableBehaviorParameters DI as the engine. What passes runWithState() is guaranteed to receive identical parameters during real execution.

Guards — returns bool

Guards return true to allow a transition or false to block it. Test them by creating a state with the context your guard depends on.

php
$state = State::forTesting(['count' => 5]);
expect(IsCountPositiveGuard::runWithState($state))->toBeTrue();

$state = State::forTesting(['count' => 0]);
expect(IsCountPositiveGuard::runWithState($state))->toBeFalse();

Actions — modifies context

Actions perform side effects, typically modifying context values. Since they return void, assert on the context changes after calling runWithState().

php
$state = State::forTesting(
    new TrafficLightsContext(count: 0, modelA: new \Spatie\LaravelData\Optional())
);
IncrementAction::runWithState($state);
expect($state->context->count)->toBe(1);

Actions — asserting raised events

Actions that call $this->raise() push events onto an internal queue. After runWithState(), use static assertions to verify which events were raised:

php
CheckProtocolAction::runWithState($state);

CheckProtocolAction::assertRaised(ProtocolUndecidedEvent::class);
CheckProtocolAction::assertNotRaised(ProtocolRejectedEvent::class);
CheckProtocolAction::assertRaisedCount(1);

Supports both FQCN and event type strings:

php
CheckProtocolAction::assertRaised('PROTOCOL_UNDECIDED');
CheckProtocolAction::assertRaised(ProtocolUndecidedEvent::class);

For actions that should NOT raise any events:

php
StoreDataAction::runWithState($state);
StoreDataAction::assertNothingRaised();

Multiple raised events — assert each individually:

php
MultiStepAction::runWithState($state);
MultiStepAction::assertRaised('STEP_ONE_DONE');
MultiStepAction::assertRaised('STEP_TWO_DONE');
MultiStepAction::assertRaisedCount(2);

Calculators — with arguments

Calculators run before guards to compute derived values. Unlike actions, they only modify context — no side effects. The third parameter passes colon-separated arguments from the machine definition (e.g., 'myCalculator:7' passes ['7']).

php
$state = State::forTesting(['count' => 10]);
DoubleCountCalculator::runWithState($state);
expect($state->context->get('total'))->toBe(20);

With EventBehavior

When an action reads event payload (e.g., values submitted by the user), pass an EventDefinition as the second parameter to simulate the event data.

php
$state = State::forTesting(
    new TrafficLightsContext(count: 10, modelA: new \Spatie\LaravelData\Optional())
);
$event = AddValueEvent::forTesting(['payload' => ['value' => 5]]);

AddValueAction::runWithState($state, eventBehavior: $event);
expect($state->context->count)->toBe(15);

EventBehavior::forTesting()

EventBehavior subclasses often have validation rules and required fields. forTesting() creates a valid instance with sensible defaults, so you don't have to manually construct the full event structure.

php
// Base — sensible defaults
$event = IncreaseEvent::forTesting();
expect($event->type)->toBe('INCREASE');
expect($event->payload)->toBe([]);

// Override specific fields
$event = AddValueEvent::forTesting(['payload' => ['value' => 42]]);
expect($event->payload)->toBe(['value' => 42]);

// Use with runWithState
$state = State::forTesting(['count' => 10]);
AddValueAction::runWithState($state, eventBehavior: $event);

EventBuilder

When events have complex payloads — many fields, faker-generated values, database-seeded relationships — forTesting() becomes verbose. EventBuilder provides composable, reusable test data builders with the same fluent API as Laravel's model factories.

When to Use Which

ScenarioToolExample
Simple event, payload doesn't matterforTesting()MyEvent::forTesting()
Simple event, a few field overridesforTesting()MyEvent::forTesting(['payload' => ['key' => 'val']])
Complex payload, faker, DB seedingEventBuilderMyEvent::builder()->withX()->make()
Validation testing with raw arrayEventBuilderMyEvent::builder()->raw()validateAndCreate()

Naming

Builder class names derive from the event class: {EventClassName}Builder.

Event ClassBuilder Class
OrderSubmittedEventOrderSubmittedEventBuilder
ApplicationStartedEventApplicationStartedEventBuilder

Builder methods that add state follow with{Description} and return static:

MethodDescription
withOrderItems(int $count)Add order items to payload
withFarmerPaymentDate(?CarbonImmutable $date)Set farmer payment date
withInvalidAttribute()Set deliberately invalid data for validation testing

Creating a Builder

Extend EventBuilder and implement eventClass(). Override definition() only when you need faker-generated defaults — omit it for the base defaults (type from getType(), empty payload, version 1).

php
use Tarfinlabs\EventMachine\Testing\EventBuilder;

class OrderSubmittedEventBuilder extends EventBuilder
{
    protected function eventClass(): string
    {
        return OrderSubmittedEvent::class;
    }

    protected function definition(): array
    {
        return [
            'type'    => OrderSubmittedEvent::getType(),
            'payload' => [
                'customerId' => $this->faker->uuid(),
                'amount'      => $this->faker->numberBetween(100, 10000),
                'currency'    => 'TRY',
            ],
            'version' => 1,
        ];
    }

    public function withAmount(int $amount): static
    {
        return $this->state(['payload' => ['amount' => $amount]]);
    }

    public function withItems(int $count): static
    {
        return $this->state(function (array $attrs) use ($count) {
            $items = [];
            foreach (range(1, $count) as $i) {
                $items[] = [
                    'productId' => Product::factory()->create()->id,
                    'quantity'   => random_int(1, 10),
                ];
            }
            $attrs['payload']['items'] = $items;
            return $attrs;
        });
    }
}

Connecting Event to Builder — HasBuilder

Add the HasBuilder trait to your event class so you can call Event::builder() directly. This follows the same pattern as Laravel's HasFactory.

php
use Tarfinlabs\EventMachine\Behavior\EventBehavior;
use Tarfinlabs\EventMachine\Testing\HasBuilder;

/**
 * @use HasBuilder<OrderSubmittedEventBuilder>
 */
class OrderSubmittedEvent extends EventBehavior
{
    use HasBuilder;

    public static function getType(): string
    {
        return 'ORDER_SUBMITTED';
    }
}

The @use HasBuilder<OrderSubmittedEventBuilder> annotation gives your IDE full autocomplete on the builder methods.

Convention: HasBuilder looks for {EventClass}Builder in the same namespace. If your builder lives elsewhere, override resolveBuilderClass():

php
class OrderSubmittedEvent extends EventBehavior
{
    use HasBuilder;

    protected static function resolveBuilderClass(): string
    {
        return \Database\Factories\OrderSubmittedEventBuilder::class;
    }
}

Usage

php
// Via event class (recommended — IDE autocomplete)
$event = OrderSubmittedEvent::builder()
    ->withAmount(5000)
    ->withItems(3)
    ->make();

// Via builder directly (also works)
$event = OrderSubmittedEventBuilder::new()
    ->withAmount(5000)
    ->withItems(3)
    ->make();

// Raw array for validation testing
$raw = OrderSubmittedEvent::builder()->withAmount(-1)->raw();
expect(fn () => OrderSubmittedEvent::validateAndCreate($raw))
    ->toThrow(ValidationException::class);

// Immutable — reuse a base builder
$base   = OrderSubmittedEvent::builder()->withItems(3);
$eventA = $base->withAmount(1000)->make();
$eventB = $base->withAmount(5000)->make();

Minimal Builder

If your event has a simple payload and you only need builder methods (not faker defaults), skip definition() entirely:

php
class MyEventBuilder extends EventBuilder
{
    protected function eventClass(): string { return MyEvent::class; }

    public function withAmount(int $amount): static
    {
        return $this->state(['payload' => ['amount' => $amount]]);
    }
}

API Reference

EventBuilder:

MethodReturnsDescription
::new()staticStatic constructor — creates fresh builder instance
state(Closure|array)staticAdd state mutation (returns immutable clone)
make(array $overrides)EventBehaviorBuild event instance — overrides take final precedence
raw(array $overrides)arrayRaw attribute array — for validateAndCreate() testing

HasBuilder (trait on EventBehavior):

MethodReturnsDescription
::builder()EventBuilder (concrete via @template)Resolve and return builder instance
::resolveBuilderClass()stringOverride for custom builder location

make() skips validation

make() calls EventBehavior::from() directly — same as forTesting(). If you need to test validation rules, use raw()validateAndCreate(). Note that validateAndCreate() throws Illuminate\Validation\ValidationException, not MachineEventValidationException.

Closure vs array state behavior

Array states are additivearray_replace_recursive preserves sibling keys. Closure states replace the entire array — you must return all keys you want to keep. Use array states for simple overrides and closures only when you need computed values or cross-key logic.

Child Machine Event Factories

When testing guards or actions that handle @done/@fail events, use the child event factories to avoid boilerplate:

php
use Tarfinlabs\EventMachine\Behavior\ChildMachineDoneEvent;
use Tarfinlabs\EventMachine\Behavior\ChildMachineFailEvent;

// Only provide the data you care about — identity fields are defaulted
$event = ChildMachineDoneEvent::forTesting(['output' => ['statusCode' => 3]]);
$state = State::forTesting(['attemptCount' => 2], currentEventBehavior: $event);
expect(IsStatusSuccessGuard::runWithState($state))->toBeTrue();

// Fail event for error-handling guards
$event = ChildMachineFailEvent::forTesting(['errorMessage' => 'Gateway timeout']);
$state = State::forTesting([], currentEventBehavior: $event);
expect(IsRetryableErrorGuard::runWithState($state))->toBeTrue();

// With final state (for @done.{state} routing guards)
$event = ChildMachineDoneEvent::forTesting([
    'output'      => ['status' => 'ok'],
    'finalState' => 'approved',
]);

// Zero config — all defaults (machine_id: 'test', machine_class: 'TestMachine')
$event = ChildMachineDoneEvent::forTesting();
$event = ChildMachineFailEvent::forTesting();

Use concrete event type-hints

When a guard handles @done events, type-hint ChildMachineDoneEvent $event — not EventBehavior $event. The injection system (injectInvokableBehaviorParameters) resolves the correct subclass automatically. Concrete type-hints give you IDE autocompletion, PHPStan safety, and clear method availability (result(), output(), finalState()).

When to Use Which

Test TypeMethodBest For
UnitrunWithState()Single behavior logic, fast, no DB
Raised eventsassertRaised() / assertNothingRaised()Unit-level raise testing, no machine needed
IntegrationMachine::test()Transition flow, guard interaction
E2EMachine::create() + send()Full persistence, real DB

Related

See Fakeable Behaviors for mocking during execution, Constructor DI for service injection testing, TestMachine for the fluent machine-level wrapper, and Migration Patterns for upgrading from legacy test patterns.

Released under the MIT License.