Skip to content

Testing Overview

EventMachine is designed for testability at every level — from isolated unit tests of individual behaviors to full machine-level acceptance tests.

Philosophy

  • Layered testing pyramid: behavior → transition → path
  • Real by default, opt-in faking: behaviors run with real logic unless you explicitly fake them
  • Container-first architecture: all behaviors are resolved via App::make(), enabling constructor DI and mockability

Quick Start

Three test levels, one behavior:

php
// 1. Isolated — unit test a single guard
$state = State::forTesting(['count' => 4]);
expect(IsEvenGuard::runWithState($state))->toBeTrue();

// 2. Faked — mock a behavior during machine execution
SendEmailAction::shouldRun()->once();
OrderMachine::test()->send('SUBMIT')->assertBehaviorRan(SendEmailAction::class);

// 3. Fluent — full path test with TestMachine
TrafficLightsMachine::test()
    ->assertState('active')
    ->send('INCREASE')
    ->assertContext('count', 1);

// 4. Inline faking — fake inline closures during machine execution
OrderMachine::test()
    ->faking(['broadcastAction', 'isValidGuard' => true])
    ->send('SUBMIT')
    ->assertBehaviorRan('broadcastAction');

Test Setup

Pest / PHPUnit Configuration

Use the InteractsWithMachines trait to automatically reset all fakes between tests. This prevents state leaking across test cases — no manual resetMachineFakes() or resetAllFakes() needed.

php
// tests/Pest.php
uses(
    Tests\TestCase::class,
    Illuminate\Foundation\Testing\RefreshDatabase::class,
    Tarfinlabs\EventMachine\Testing\InteractsWithMachines::class,
)->in('Feature');

Automatic Cleanup

InteractsWithMachines automatically resets all Machine::fake(), CommunicationRecorder, and InlineBehaviorFake state after each test. No manual resetMachineFakes() needed.

Prerequisite

InteractsWithMachines requires your TestCase to extend Laravel's or Orchestra Testbench's TestCase. Pure PHPUnit TestCase without InteractsWithTestCaseLifecycle won't auto-call the teardown.

In-Memory Database

For fast tests, use SQLite in-memory. This eliminates migration overhead and disk I/O — each test gets a fresh database without touching the filesystem. Combined with RefreshDatabase, tests run in complete isolation.

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

Testing Layers

LayerWhat to TestGuide
Event BuildingComplex test event payloads with faker, DB seedingIsolated Testing — EventBuilder
Behavior (Unit)Individual guards, actions, calculatorsIsolated Testing
FakingMock behaviors during executionFakeable Behaviors
Inline FakingFake inline closures (actions, guards, calculators)Fakeable Behaviors — Inline
Constructor DIService injection + mockingConstructor DI
Transition (Integration)Guard pass/fail, state changes, pathsTransitions & Paths
Machine (Acceptance)Full fluent test wrapperTestMachine
ParallelDispatch verification, region isolationParallel Testing
Inter-MachineChild machine faking, sendTo/dispatchTo assertionsInter-Machine Testing
Job ActorsJob dispatch verification, fire-and-forgetInter-Machine Testing — Job Actors
Fire-and-ForgetMachine + queue (no @done) delegation testingInter-Machine Testing — Fire-and-Forget
Available EventsWhich events current state accepts, forward event availabilityTestMachine — Available Events
Scheduled EventsCron-based batch operations via runSchedule(), assertHasSchedule()Scheduled Testing
Time-BasedadvanceTimers(), assertHasTimer(), timer lifecycle testingTime-Based Testing
PersistenceDB, restoration, archivalPersistence Testing
Path CoverageEnumerate all paths, track coverage, assert thresholdsTransitions & Paths — Coverage
RecipesCommon real-world patternsRecipes
MigrationUpgrading from legacy test patternsMigration Patterns

How to Choose Your Testing Approach

What are you testing?

GoalToolGuide
Single guard/action in isolationState::forTesting() + runWithState()Isolated Testing
Raised event from action?assertRaised() after runWithState()Isolated Testing — Raised Events
Complex event payloadEvent::builder()->withX()->make()Isolated Testing — EventBuilder
Full machine flowMachine::test()TestMachine
Deep state without path replayMachine::startingAt()TestMachine — Starting At
Child machine delegationfakingChild() / simulateChildDone()Delegation Testing
Job actor completionsimulateChildDone(MyJob::class)Delegation Testing — Job Actors
Bulk fake all behaviorsfakingAllActions(except: [...])TestMachine — Bulk Faking
Timer behavioradvanceTimers()Time-Based Testing
Parallel statesassertRegionState()Parallel Testing
Forward endpointswithRunningChild()Delegation Testing — Forward
Cross-machine messagingrecordingCommunication()Delegation Testing — sendTo
Real async pipelineLocalQA with HorizonReal Infrastructure
Something not working?Troubleshooting

Testing Strategy

For guidance on choosing the right test level and avoiding common traps, see Testing Strategy.

When Fakes Aren't Enough

Most EventMachine testing works fine with fakes. But some scenarios can only be verified with real infrastructure:

What fakes verifyWhat fakes DON'T verify
Job dispatch (Queue::assertPushed)Job execution → child runs → completion routes back
Timer registration (assertHasTimer)machine:process-timers command fires correctly
Schedule definition (assertHasSchedule)machine:process-scheduled runs via scheduler
Child invocation (assertChildInvoked)Real child starts, persists, reaches final state
Lock exception thrownConcurrent requests actually block each other

Rule of thumb: If you check "was the right thing dispatched/registered?" → fakes are sufficient. If you need "does the full pipeline complete end-to-end?" → real infrastructure.

When to invest:

  • Async delegation (queue) where parent behavior after @done is business-critical
  • Timers (after/every) that must be verified against the sweep command
  • Concurrent access with real database locks
  • Forward endpoints with full HTTP → child → response chain

See Recipes for real infrastructure testing patterns.

Released under the MIT License.