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
| Layer | What to Test | Speed | Dependencies |
|---|---|---|---|
| Unit | Individual behaviors (guards, actions, calculators) | Milliseconds | None (in-memory) |
| Integration | State flow, transition paths, guard gating | Fast | SQLite in-memory |
| Path Coverage | All enumerated paths exercised by tests | Fast | None (static analysis + tracker) |
| E2E | Full pipeline: timers, scheduled events, persistence | Moderate | SQLite + artisan |
| LocalQA | Async delegation, parallel dispatch, locking | Slow | MySQL + Redis + Horizon |
Unit: Isolated Behavior Testing
Test individual guards, actions, and calculators without booting a machine. Use State::forTesting() and runWithState().
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.
// 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);// 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 idleTest 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.
// 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
| Concern | Layer | Why |
|---|---|---|
| Guard returns correct boolean | Unit | Fast, no machine needed |
| Action modifies context correctly | Unit | Isolated side effect |
| Calculator produces correct value | Unit | Pure computation |
| Event triggers correct transition | Integration | Needs machine flow |
| Guard blocks transition | Integration | Needs machine + guard wiring |
| Context propagates across states | Integration | Multi-step flow |
@always routing | Integration | Needs guard evaluation chain |
| Timer fires at deadline | E2E | Needs advanceTimers() |
| Scheduled event targets instances | E2E | Needs resolver + scheduler |
| Persist and restore state | E2E | Needs database |
| Async child completes and reports | LocalQA | Needs real queue |
| Parallel dispatch with locking | LocalQA | Needs MySQL locks |
| Available events correct per state | Integration | assertAvailableEvent, 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.
// 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 itMachine::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:
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 logicadvanceTimers() 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.
// 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'); // expiredAnti-Pattern: Testing Internal Events
// Anti-pattern: testing raised event details
->send('ORDER_SUBMITTED')
->assertEventRaised('VALIDATION_STARTED') // internal implementation detail
->assertEventRaised('VALIDATION_PASSED') // brittle -- rename breaks testInternal 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.
->send('ORDER_SUBMITTED')
->assertState('processing') // validates the outcome
->assertContext('is_validated', true) // checks observable dataAnti-Pattern: Testing Transition Order
// 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.
->send('ORDER_SUBMITTED')
->assertState('processing')
->assertBehaviorRan(CalculateOrderTotalAction::class)Example: Order Workflow at All Four Layers
// UNIT: guard logic
$state = State::forTesting(['orderTotal' => 500]);
assert(IsOrderTotalValidGuard::runWithState($state) === true);
$state = State::forTesting(['orderTotal' => 0]);
assert(IsOrderTotalValidGuard::runWithState($state) === false);// INTEGRATION: happy path
OrderWorkflowMachine::test(['orderId' => 'ORD-100', 'orderTotal' => 500])
->send('ORDER_SUBMITTED')
->assertState('processing')
->send('PAYMENT_RECEIVED')
->assertState('paid');// E2E: timer expiry
OrderWorkflowMachine::test(['orderId' => 'ORD-101', 'orderTotal' => 500])
->send('ORDER_SUBMITTED')
->assertState('awaiting_payment')
->advanceTimers(Timer::days(7))
->assertState('cancelled');// 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 advancedGuidelines
Unit tests for behavior logic. Fast, isolated, no machine booting. Use
runWithState().Integration tests for state flow. Use
TestMachinefluent API. Cover happy path, guard blocking, and error paths.E2E tests for infrastructure. Timers, scheduled events, persistence. Use
advanceTimers().LocalQA for async features. Real queue, real locks. Run separately from CI.
Test outcomes, not internals. Assert on final state and context, not on raised events or transition sequences.
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:
| Pattern | What It Actually Tests | Risk |
|---|---|---|
fakingAllActions() + fakingAllGuards() + startingAt() | Only transition config (@done → state_x) | Very high |
fakingAllActions(except: [X]) + startingAt() | Action X with real logic + routing | Low |
fakingAllActions() + real guards | Guard logic + routing | Medium |
startingAt() + no faking | All behaviors from that state forward | Good |
| Full path (no shortcuts) | Everything | Best 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.
Related
- Testing Overview -- testing layers reference
- Isolated Testing --
State::forTesting()andrunWithState() - TestMachine -- fluent API reference
- Delegation Testing --
Machine::fake() - Time-Based Testing --
advanceTimers() - Recipes -- common real-world patterns