Skip to content

Persistence Testing

Testing EventMachine's database persistence and event sourcing capabilities.

Test Setup

RefreshDatabase Trait

php
use Illuminate\Foundation\Testing\RefreshDatabase;

class OrderMachineTest extends TestCase
{
    use RefreshDatabase;

    // Tests with fresh database
}

In-Memory SQLite

xml
<!-- phpunit.xml -->
<php>
    <env name="DB_CONNECTION" value="sqlite"/>
    <env name="DB_DATABASE" value=":memory:"/>
</php>

Database Assertions

assertDatabaseHas

php
it('persists events to database', function () {
    $machine = OrderMachine::create();
    $machine->send(['type' => 'SUBMIT']);

    $this->assertDatabaseHas('machine_events', [
        'type' => 'SUBMIT',
        'source' => 'external',
        'machine_id' => 'order',
    ]);
});

assertDatabaseCount

php
it('creates expected number of events', function () {
    $machine = OrderMachine::create();
    $machine->send(['type' => 'SUBMIT']);

    // Count all events for this machine
    $count = MachineEvent::where(
        'root_event_id',
        $machine->state->history->first()->root_event_id
    )->count();

    expect($count)->toBeGreaterThan(1); // Includes internal events
});

assertDatabaseMissing

php
it('does not persist when disabled', function () {
    $machine = MachineDefinition::define(
        config: [
            'should_persist' => false,
            'initial' => 'idle',
            'states' => [
                'idle' => ['on' => ['GO' => 'done']],
                'done' => [],
            ],
        ],
    );

    $machine->transition(['type' => 'GO']);

    $this->assertDatabaseMissing('machine_events', [
        'type' => 'GO',
    ]);
});

Event History Testing

Check Event Order

php
it('records events in correct order', function () {
    $machine = OrderMachine::create();

    $machine->send(['type' => 'SUBMIT']);
    $machine->send(['type' => 'APPROVE']);

    $events = MachineEvent::where('root_event_id',
        $machine->state->history->first()->root_event_id
    )->orderBy('sequence_number')->get();

    $externalEvents = $events->where('source', 'external');

    expect($externalEvents->first()->type)->toBe('SUBMIT');
    expect($externalEvents->last()->type)->toBe('APPROVE');
});

Check Event Payload

php
it('stores event payload correctly', function () {
    $machine = OrderMachine::create();

    $machine->send([
        'type' => 'ADD_ITEM',
        'payload' => [
            'productId' => 123,
            'quantity' => 2,
        ],
    ]);

    $event = MachineEvent::where('type', 'ADD_ITEM')->first();

    expect($event->payload)->toBe([
        'productId' => 123,
        'quantity' => 2,
    ]);
});

Check Context Storage

php
it('stores context incrementally', function () {
    $machine = OrderMachine::create();

    // First event - full context
    $machine->send(['type' => 'SUBMIT']);

    // Second event - only changes
    $machine->send(['type' => 'SET_NOTE', 'payload' => ['note' => 'Test']]);

    $events = MachineEvent::where('root_event_id',
        $machine->state->history->first()->root_event_id
    )->where('source', 'external')->orderBy('sequence_number')->get();

    // First event has full context
    expect($events[0]->context)->toHaveKey('orderId');
    expect($events[0]->context)->toHaveKey('items');

    // Second event has only the change
    expect($events[1]->context)->toHaveKey('note');
});

State Restoration Testing

Basic Restoration

php
it('restores state from root event id', function () {
    $machine = OrderMachine::create();

    $machine->send(['type' => 'SUBMIT']);
    $machine->send(['type' => 'APPROVE']);

    $rootId = $machine->state->history->first()->root_event_id;
    $originalContext = $machine->state->context->toArray();
    $originalState = $machine->state->value;

    // Restore from root event ID
    $restored = OrderMachine::create(state: $rootId);

    expect($restored->state->value)->toEqual($originalState);
    expect($restored->state->context->toArray())->toEqual($originalContext);
});

Context Reconstruction

php
it('reconstructs context from incremental changes', function () {
    $machine = OrderMachine::create();

    // Make multiple changes
    $machine->send(['type' => 'SET_CUSTOMER', 'payload' => ['id' => 'cust-1']]);
    $machine->send(['type' => 'ADD_ITEM', 'payload' => ['item' => ['id' => 1]]]);
    $machine->send(['type' => 'SET_DISCOUNT', 'payload' => ['amount' => 10]]);

    $rootId = $machine->state->history->first()->root_event_id;

    // Restore
    $restored = OrderMachine::create(state: $rootId);

    // All context values should be present
    expect($restored->state->context->customerId)->toBe('cust-1');
    expect($restored->state->context->items)->toHaveCount(1);
    expect($restored->state->context->discount)->toBe(10);
});

Continue After Restoration

php
it('can continue from restored state', function () {
    $machine = OrderMachine::create();
    $machine->send(['type' => 'SUBMIT']);

    $rootId = $machine->state->history->first()->root_event_id;

    // Restore and continue
    $restored = OrderMachine::create(state: $rootId);
    $restored->send(['type' => 'APPROVE']);

    expect($restored->state->matches('approved'))->toBeTrue();

    // New event should be persisted
    $this->assertDatabaseHas('machine_events', [
        'root_event_id' => $rootId,
        'type' => 'APPROVE',
    ]);
});

Transactional Testing

Rollback on Failure

php
it('rolls back on exception with transactional event', function () {
    $machine = OrderMachine::create();

    // Action that will fail
    FailingAction::fake();
    FailingAction::shouldRun()->andThrow(new Exception('Test error'));

    try {
        $machine->send(['type' => 'FAILING_EVENT']);
    } catch (Exception $e) {
        // Expected
    }

    // No events should be persisted
    $this->assertDatabaseMissing('machine_events', [
        'type' => 'FAILING_EVENT',
    ]);
});

Non-Transactional Events

php
it('persists non-transactional events on failure', function () {
    // Define non-transactional event
    $machine = MachineDefinition::define(
        config: [...],
        behavior: [
            'events' => [
                'FAST_UPDATE' => NonTransactionalEvent::class,
            ],
        ],
    );

    // Even if later actions fail, event is persisted
});

Eloquent Integration Testing

Model Machine Testing

php
it('initializes machine on model creation', function () {
    $order = Order::create(['name' => 'Test Order']);

    expect($order->status)->not->toBeNull();

    // Access machine
    expect($order->status->state->matches('pending'))->toBeTrue();
});

Model State Persistence

php
it('persists machine state with model', function () {
    $order = Order::create(['name' => 'Test Order']);

    $order->status->send(['type' => 'SUBMIT']);

    // Reload from database
    $order = Order::find($order->id);

    expect($order->status->state->matches('submitted'))->toBeTrue();
});

Multiple Machine Models

php
it('handles multiple machines on model', function () {
    $order = Order::create(['name' => 'Test Order']);

    $order->order_status->send(['type' => 'CONFIRM']);
    $order->payment_status->send(['type' => 'CHARGE']);

    $order = Order::find($order->id);

    expect($order->order_status->state->matches('confirmed'))->toBeTrue();
    expect($order->payment_status->state->matches('charged'))->toBeTrue();
});

Concurrent Access Testing

php
it('handles concurrent access with locking', function () {
    $machine = OrderMachine::create();
    $rootId = $machine->state->history->first()->root_event_id;

    // Simulate concurrent access
    $results = collect([1, 2, 3])->map(function ($i) use ($rootId) {
        try {
            $m = OrderMachine::create(state: $rootId);
            $m->send(['type' => 'INCREMENT']);
            return 'success';
        } catch (MachineAlreadyRunningException $e) {
            return 'locked';
        }
    });

    // At least one should succeed, others may be locked
    expect($results->contains('success'))->toBeTrue();
});

Archive Testing

php
it('archives and restores events', function () {
    $machine = OrderMachine::create();
    $machine->send(['type' => 'SUBMIT']);

    $rootId = $machine->state->history->first()->root_event_id;

    // Archive
    $service = new ArchiveService();
    $archive = $service->archiveMachine($rootId);

    expect($archive)->not->toBeNull();
    expect($archive->event_count)->toBeGreaterThan(0);

    // Restore
    $restored = OrderMachine::create(state: $rootId);
    expect($restored->state->matches('submitted'))->toBeTrue();
});

Best Practices

1. Use RefreshDatabase

php
use Illuminate\Foundation\Testing\RefreshDatabase;

class MyTest extends TestCase
{
    use RefreshDatabase;
}

2. Test State Restoration

php
it('restores correctly', function () {
    // Create, modify, save root ID
    // Restore and verify
});

3. Test Incremental Context

php
it('handles context changes', function () {
    // Make multiple changes
    // Verify all changes persist and restore
});

4. Test Edge Cases

php
it('handles empty context', function () { ... });
it('handles large payloads', function () { ... });
it('handles special characters', function () { ... });

Released under the MIT License.