Skip to content

Testing Strategy

EventMachine is testable at every level. A well-designed test suite uses the right level for each concern -- fast isolated tests for logic, integration tests for flow, and end-to-end tests for the full pipeline.

Five Layers

LayerWhat to TestSpeedDependencies
UnitIndividual behaviors (guards, actions, calculators)MillisecondsNone (in-memory)
IntegrationState flow, transition paths, guard gatingFastSQLite in-memory
Path CoverageAll enumerated paths exercised by testsFastNone (static analysis + tracker)
E2EFull pipeline: timers, scheduled events, persistenceModerateSQLite + artisan
LocalQAAsync delegation, parallel dispatch, lockingSlowMySQL + Redis + Horizon

Unit: Isolated Behavior Testing

Test individual guards, actions, and calculators without booting a machine. Use State::forTesting() and runWithState().

php
use Tarfinlabs\EventMachine\Actor\State;
use Tarfinlabs\EventMachine\Behavior\GuardBehavior;
use Tarfinlabs\EventMachine\ContextManager;

class IsRetryAllowedGuard extends GuardBehavior
{
    public function __invoke(ContextManager $context): bool
    {
        return $context->get('retryCount') < 3;
    }
}

// Test: guard returns true when retries remain
$state = State::forTesting(['retryCount' => 2]);
assert(IsRetryAllowedGuard::runWithState($state) === true);

// Test: guard returns false when retries exhausted
$state = State::forTesting(['retryCount' => 3]);
assert(IsRetryAllowedGuard::runWithState($state) === false);

Test here: Guard boolean logic, action side effects on context, calculator math.

Integration: State Flow Testing

Test transition paths and guard gating using the TestMachine fluent API. This boots the machine and runs real transitions.

php
// Test: order follows happy path

OrderWorkflowMachine::test(['orderId' => 'ORD-001', 'orderTotal' => 500])
    ->assertState('idle')
    ->send('ORDER_SUBMITTED')
    ->assertState('submitted')
    ->send('PAYMENT_RECEIVED')
    ->assertState('processing')
    ->assertContext('orderTotal', 500);
php
// Test: guard blocks transition when total is zero

OrderWorkflowMachine::test(['orderId' => 'ORD-002', 'orderTotal' => 0])
    ->assertState('idle')
    ->send('ORDER_SUBMITTED')
    ->assertState('idle');   // guard blocked, stayed in idle

Test here: Transition paths, context changes across transitions, guard pass/fail scenarios, multi-branch routing.

E2E: Full Pipeline

Test the complete lifecycle including persistence, timer processing, and scheduled events. These tests use artisan commands and real database writes.

php
// Test: timer fires after advancing time

OrderWorkflowMachine::test(['orderId' => 'ORD-003', 'orderTotal' => 100])
    ->send('ORDER_SUBMITTED')
    ->assertState('awaiting_payment')
    ->assertHasTimer('ORDER_EXPIRED')
    ->advanceTimers(Timer::days(7))
    ->assertState('cancelled');

Test here: Timer sweep, scheduled event processing, persist/restore cycles, artisan command output.

LocalQA: Real Infrastructure

Tests requiring real MySQL, Redis, and Laravel Horizon for async features. These live in tests/LocalQA/ and are excluded from composer test.

Test here: Async child delegation, queue job dispatch and completion, parallel dispatch with database locking, timeout handling, concurrent state mutation.

What to Test Where

ConcernLayerWhy
Guard returns correct booleanUnitFast, no machine needed
Action modifies context correctlyUnitIsolated side effect
Calculator produces correct valueUnitPure computation
Event triggers correct transitionIntegrationNeeds machine flow
Guard blocks transitionIntegrationNeeds machine + guard wiring
Context propagates across statesIntegrationMulti-step flow
@always routingIntegrationNeeds guard evaluation chain
Timer fires at deadlineE2ENeeds advanceTimers()
Scheduled event targets instancesE2ENeeds resolver + scheduler
Persist and restore stateE2ENeeds database
Async child completes and reportsLocalQANeeds real queue
Parallel dispatch with lockingLocalQANeeds MySQL locks
Available events correct per stateIntegrationassertAvailableEvent, assertForwardAvailable

Machine::fake() for Isolation

When testing a parent machine, you do not want child machines to actually run. Machine::fake() short-circuits delegation, returning a configurable result.

php
// Arrange: fake the child machine before creating the parent
PaymentMachine::fake(result: ['paymentId' => 'pay_123'], finalState: 'settled');

// Act + Assert: test parent orchestration without running children
OrderWorkflowMachine::test(['orderId' => 'ORD-004'])
    ->send('ORDER_SUBMITTED')
    ->assertState('shipping');   // child faked, @done fired, parent moved to next
// No cleanup needed — InteractsWithMachines handles it

Machine::fake() is a static call on the child machine class — call it before creating the parent. The finalState parameter determines which @done.{state} route fires on the parent. See Inter-Machine Testing for the full API.

This lets you test the parent's orchestration logic (routing, error handling, context passing) without coupling to child machine internals.

Standalone Machine Isolation

Use Machine::fake() to isolate controller or service tests from the machine pipeline:

php
CarSalesMachine::fake();
// Controller calls Machine::create() → gets stub
// Controller calls send() → no-op
// Controller calls persist() → no-op
// Your test only verifies the controller's own logic

advanceTimers() for Time-Based Testing

advanceTimers() simulates time passage without sleep(). It updates the machine's internal clock and processes any timers that would have fired.

php
// Test: payment reminder sent after 1 day, order expires after 7

OrderWorkflowMachine::test(['orderId' => 'ORD-005'])
    ->send('ORDER_SUBMITTED')
    ->assertState('awaiting_payment')
    ->advanceTimers(Timer::days(1))
    ->assertBehaviorRan(SendPaymentReminderAction::class)
    ->assertState('awaiting_payment')       // still waiting
    ->advanceTimers(Timer::days(6))         // 7 days total
    ->assertState('cancelled');              // expired

Anti-Pattern: Testing Internal Events

php
// Anti-pattern: testing raised event details

->send('ORDER_SUBMITTED')
->assertEventRaised('VALIDATION_STARTED')    // internal implementation detail
->assertEventRaised('VALIDATION_PASSED')     // brittle -- rename breaks test

Internal events (raised events, @always mechanics) are implementation details. Tests should assert on observable outcomes: final state, context values, behavior execution.

Fix: Test the result, not the mechanism.

php
->send('ORDER_SUBMITTED')
->assertState('processing')                  // validates the outcome
->assertContext('is_validated', true)         // checks observable data

Anti-Pattern: Testing Transition Order

php
// Anti-pattern: asserting exact transition sequence

->send('ORDER_SUBMITTED')
->assertTransitionSequence([
    'idle -> validating',
    'validating -> calculating',
    'calculating -> processing',
])

The internal routing through @always states may change during refactoring. Tests should verify the final state, not the path taken.

Fix: Assert on the end state and any observable side effects.

php
->send('ORDER_SUBMITTED')
->assertState('processing')
->assertBehaviorRan(CalculateOrderTotalAction::class)

Example: Order Workflow at All Four Layers

php
// UNIT: guard logic
$state = State::forTesting(['orderTotal' => 500]);
assert(IsOrderTotalValidGuard::runWithState($state) === true);

$state = State::forTesting(['orderTotal' => 0]);
assert(IsOrderTotalValidGuard::runWithState($state) === false);
php
// INTEGRATION: happy path
OrderWorkflowMachine::test(['orderId' => 'ORD-100', 'orderTotal' => 500])
    ->send('ORDER_SUBMITTED')
    ->assertState('processing')
    ->send('PAYMENT_RECEIVED')
    ->assertState('paid');
php
// E2E: timer expiry
OrderWorkflowMachine::test(['orderId' => 'ORD-101', 'orderTotal' => 500])
    ->send('ORDER_SUBMITTED')
    ->assertState('awaiting_payment')
    ->advanceTimers(Timer::days(7))
    ->assertState('cancelled');
php
// LOCAL QA: async child delegation (tests/LocalQA/)
// Requires MySQL + Redis + Horizon running
OrderWorkflowMachine::create(['orderId' => 'ORD-102', 'orderTotal' => 500])
    ->send(['type' => 'ORDER_SUBMITTED']);
// Assert child job dispatched, await completion, verify parent advanced

Guidelines

  1. Unit tests for behavior logic. Fast, isolated, no machine booting. Use runWithState().

  2. Integration tests for state flow. Use TestMachine fluent API. Cover happy path, guard blocking, and error paths.

  3. E2E tests for infrastructure. Timers, scheduled events, persistence. Use advanceTimers().

  4. LocalQA for async features. Real queue, real locks. Run separately from CI.

  5. Test outcomes, not internals. Assert on final state and context, not on raised events or transition sequences.

  6. Fake children in parent tests. Machine::fake() isolates parent orchestration from child implementation.

Avoiding the Testing Nothing Trap

fakingAllActions(), fakingAllGuards(), and startingAt() are powerful convenience methods — but combining them aggressively can produce tests that verify almost nothing:

PatternWhat It Actually TestsRisk
fakingAllActions() + fakingAllGuards() + startingAt()Only transition config (@done → state_x)Very high
fakingAllActions(except: [X]) + startingAt()Action X with real logic + routingLow
fakingAllActions() + real guardsGuard logic + routingMedium
startingAt() + no fakingAll behaviors from that state forwardGood
Full path (no shortcuts)EverythingBest coverage

Rule of Thumb

If your test has fakingAllActions() without except:, ask yourself: "What behavior am I actually testing?" If the answer is "just the transition config," consider whether that's worth a test — or whether your effort is better spent on a test that exercises real logic.

Released under the MIT License.