Testing Factories for EventFlow
Laravel-Style Factories for EventFlow Component Testing
Version: Draft 0.1 Date: December 2024 Status: To Be Evaluated
Executive Summary
EventFlow mechanisms (guards, actions, resolvers) require context to execute, but setting up that context for tests is manual and error-prone. This proposal introduces a Laravel-style factory system for EventFlow testing infrastructure, enabling:
- Event Factories - Generate valid event payloads with states and overrides
- Action Factories - Provide context for action execution with state methods
- Resolver Factories - Supply resolution context for resolvers
- Domain Factories - Create Customer, Cart, Order and other domain objects
- Path Definitions - Describe complete state machine paths with factory bindings
- Path Simulation - Execute paths to reach any intermediate state
Core Philosophy
Factories are testing infrastructure, NOT DSL features.
The machine runs normally - factories just provide the data it needs.
Key Insight: Guards Don't Need Factories
Guards evaluate existing context - they don't produce it. Instead of guard factories, path definitions declare expected outcomes (PASS/FAIL), and the simulation either mocks the result or validates against actual context.
Motivation & Problem Statement
Current Situation
Testing EventFlow mechanisms requires manual context setup:
class ExpressCheckoutEligibilityGuardTest extends TestCase
{
public function test_eligible_customer(): void
{
$guard = new ExpressCheckoutEligibilityGuard();
// Manual context creation - error prone!
$context = [
'customer' => $this->createMockCustomer(), // How did we know this?
'customer_tier' => 'gold', // Trial and error...
'total' => 600, // Reading implementation...
'shipping_address' => $this->createMockAddress('US'),
'inventory_status' => 'in_stock',
];
$this->assertTrue(($guard)($context));
}
}Problems:
- Manual context discovery - Read implementation to find required context
- Duplicate setup code - Same context built across many tests
- No state variations - Hard to test different scenarios systematically
- Integration testing gaps - No way to "start from state B"
- Brittle tests - Context structure changes break many tests
Goals
- Laravel-style factories -
Factory::new()->state()->create(['overrides']) - Path definitions - Describe complete paths through state machines
- Factory-path binding - Connect factories to path steps explicitly
- Simulation engine - Execute paths to reach any state
- Side effect handling - Skip external calls during simulation
Proposed Solution
Two-Level Factory System
┌─────────────────────────────────────────────────────────────┐
│ LEVEL 1: UNIT TESTING │
│ │
│ ┌─────────────┐ ┌─────────────┐ ┌─────────────────────┐ │
│ │ Event │ │ Action │ │ Domain │ │
│ │ Factories │ │ Factories │ │ Factories │ │
│ └─────────────┘ └─────────────┘ └─────────────────────┘ │
│ │
│ Test individual mechanisms in isolation │
└─────────────────────────────────────────────────────────────┘
┌─────────────────────────────────────────────────────────────┐
│ LEVEL 2: PATH SIMULATION │
│ │
│ ┌─────────────┐ ┌─────────────┐ ┌─────────────────────┐ │
│ │ Path │ │ Simulation │ │ Machine │ │
│ │ Definitions │──│ Engine │──│ State │ │
│ └─────────────┘ └─────────────┘ └─────────────────────┘ │
│ │
│ "Give me a machine in state B" │
└─────────────────────────────────────────────────────────────┘Level 1: Component Factories
Event Factories
Generate valid event payloads with states and overrides:
namespace Tests\EventFlow\Factories\Events;
use EventFlow\Testing\EventFactory;
#[EventFactory(':checkout')]
class CheckoutEventFactory extends EventFactory
{
/**
* Default definition - valid payload
*/
public function definition(): array
{
return [
'customer_id' => $this->faker->uuid(),
'email' => $this->faker->safeEmail(),
'items' => [
['product_id' => 'prod-1', 'quantity' => 1, 'price' => 100],
],
'shipping_address' => [
'street' => $this->faker->streetAddress(),
'city' => $this->faker->city(),
'country' => 'US',
],
];
}
/**
* State: Premium customer checkout
*/
public function premium(): static
{
return $this->state([
'customer_tier' => 'premium',
'discount_eligible' => true,
]);
}
/**
* State: Express shipping
*/
public function express(): static
{
return $this->state([
'shipping_method' => 'express',
'delivery_days' => 1,
]);
}
/**
* State: International shipping
*/
public function international(): static
{
return $this->state([
'shipping_address' => [
'street' => $this->faker->streetAddress(),
'city' => 'Berlin',
'country' => 'DE',
],
'customs_required' => true,
]);
}
}Usage:
// Default payload
$payload = CheckoutEventFactory::new()->create();
// With state
$payload = CheckoutEventFactory::new()->premium()->create();
// With override
$payload = CheckoutEventFactory::new()->create([
'email' => 'test@example.com',
]);
// Chained states + override
$payload = CheckoutEventFactory::new()
->premium()
->express()
->create(['items' => $customItems]);Action Factories
Provide context for action execution:
namespace Tests\EventFlow\Factories\Actions;
use EventFlow\Testing\ActionFactory;
#[ActionFactory('calculate discount')]
class DiscountActionFactory extends ActionFactory
{
/**
* Default context for action
*/
public function definition(): array
{
return [
'subtotal' => 100.00,
'customer_tier' => 'standard',
'coupon_code' => null,
];
}
/**
* State: Premium customer discount
*/
public function premium(): static
{
return $this->state([
'customer_tier' => 'premium',
'base_discount' => 0.10, // 10% base discount
]);
}
/**
* State: With coupon
*/
public function withCoupon(string $code = 'SAVE20'): static
{
return $this->state([
'coupon_code' => $code,
'coupon_discount' => 0.20,
]);
}
/**
* State: High-value order
*/
public function highValue(): static
{
return $this->state([
'subtotal' => 1500.00,
'volume_discount_eligible' => true,
]);
}
}Usage in unit tests:
class CalculateDiscountActionTest extends EventFlowTestCase
{
public function test_premium_customer_gets_base_discount(): void
{
$action = new CalculateDiscountAction();
// Factory provides context
$context = DiscountActionFactory::new()
->premium()
->create(['subtotal' => 200.00]);
$result = $action($context);
$this->assertEquals(20.00, $result['discount_amount']); // 10% of 200
}
public function test_coupon_stacks_with_premium(): void
{
$action = new CalculateDiscountAction();
$context = DiscountActionFactory::new()
->premium()
->withCoupon('SAVE20')
->create(['subtotal' => 100.00]);
$result = $action($context);
$this->assertEquals(30.00, $result['discount_amount']); // 10% + 20%
}
}Domain Factories
Create domain objects for context:
namespace Tests\EventFlow\Factories\Domain;
use EventFlow\Testing\DomainFactory;
class CustomerFactory extends DomainFactory
{
public function definition(): array
{
return [
'id' => $this->faker->uuid(),
'email' => $this->faker->safeEmail(),
'name' => $this->faker->name(),
'tier' => 'standard',
'verified' => true,
];
}
public function premium(): static
{
return $this->state([
'tier' => 'premium',
'loyalty_points' => 5000,
]);
}
public function gold(): static
{
return $this->state([
'tier' => 'gold',
'loyalty_points' => 15000,
'personal_shopper' => true,
]);
}
public function unverified(): static
{
return $this->state([
'verified' => false,
'verification_pending' => true,
]);
}
}
class CartFactory extends DomainFactory
{
public function definition(): array
{
return [
'items' => [
['product_id' => 'prod-1', 'quantity' => 1, 'price' => 50.00],
],
'subtotal' => 50.00,
];
}
public function empty(): static
{
return $this->state([
'items' => [],
'subtotal' => 0,
]);
}
public function withItems(int $count): static
{
$items = [];
$subtotal = 0;
for ($i = 1; $i <= $count; $i++) {
$price = $this->faker->numberBetween(10, 100);
$items[] = [
'product_id' => "prod-{$i}",
'quantity' => 1,
'price' => $price,
];
$subtotal += $price;
}
return $this->state([
'items' => $items,
'subtotal' => $subtotal,
]);
}
public function highValue(): static
{
return $this->state([
'items' => [
['product_id' => 'prod-luxury', 'quantity' => 1, 'price' => 1500.00],
],
'subtotal' => 1500.00,
]);
}
}Level 2: Path Definitions & Simulation
What Are Guards? (No Factories Needed)
Guards evaluate existing context to make decisions. They don't produce context.
#[Guard('cart is not empty')]
class CartNotEmptyGuard extends GuardBehavior
{
public function __invoke(array $context): bool
{
// EVALUATES context - doesn't create it
return !empty($context['cart']?->items);
}
}Key insight: Instead of guard factories, path definitions declare expected outcomes:
Step::define()
->event(':checkout')
->guard('cart is not empty', GuardExpectation::PASS) // Expect it to pass
->guard('customer is banned', GuardExpectation::FAIL) // Expect it to failThe simulation engine either:
- Mocks the guard result (trusts the expectation)
- Validates the guard against actual context (asserts expectation matches reality)
Path Definition Structure - Attribute-Based Discovery
Key Design Decision: Factory discovery via PHP attributes (not naming convention).
Attribute Matching:
// Action binding - has pattern attribute
#[Action('calculate discount')]
class CalculateDiscountAction extends ActionBehavior { }
// Action factory - SAME pattern attribute (this is how they match!)
#[ActionFactory('calculate discount')]
class CalculateDiscountActionFactory extends ActionFactory { }
// Event factory - pattern attribute
#[EventFactory(':checkout')]
class CheckoutEventFactory extends EventFactory { }Path definition uses the pattern to find factory:
->action('calculate discount', 'premium')
// System: find #[ActionFactory('calculate discount')] → call ::premium()->create()Symmetric API Pattern: (pattern, optionalFactoryState)
Important: Path definitions only list items that HAVE factories. DSL expressions (like $total becomes calculated) are NOT listed in path definitions - they're defined in the .flow file and auto-executed by simulation.
Path definitions describe complete journeys through state machines:
namespace Tests\EventFlow\Paths\Order;
use EventFlow\Testing\PathDefinition;
use EventFlow\Testing\Step;
use EventFlow\Testing\GuardExpectation;
class PremiumExpressHappyPath extends PathDefinition
{
public string $name = 'premium_express_happy';
public string $machine = '@order';
public string $description = 'Premium customer express checkout to shipped';
public function steps(): array
{
return [
// Step 1: Checkout with premium customer
Step::define()
->event(':checkout', 'premium') // #[EventFactory(':checkout')]::premium()
->guard('cart is not empty', GuardExpectation::PASS)
->guard('customer is premium', GuardExpectation::PASS)
->action('calculate discount', 'premium') // #[ActionFactory('calculate discount')]::premium()
// DSL expressions like '$total becomes calculated' NOT listed
// - they're in .flow file, auto-executed by simulation
->transitionsTo('#premium_processing'),
// Step 2: Payment successful
Step::define()
->event(':payment_received', 'successful') // #[EventFactory(':payment_received')]::successful()
->guard('payment is valid', GuardExpectation::PASS)
->guard('fraud check passed', GuardExpectation::PASS)
->action('capture payment') // #[ActionFactory('capture payment')]::default
->action('send confirmation') // #[ActionFactory('send confirmation')]::default
->transitionsTo('#paid'),
// Step 3: Express shipping
Step::define()
->event(':ship', 'express') // #[EventFactory(':ship')]::express()
->action('generate tracking', 'express') // #[ActionFactory('generate tracking')]::express()
->transitionsTo('#shipped_express'),
];
}
}API Benefits:
| Aspect | Before | After |
|---|---|---|
| Factory imports | Required | Not needed |
| Factory discovery | Naming convention | Attribute matching |
| API pattern | .using(Class, 'state') | ('pattern', 'state') |
| DSL expressions | Listed in path | NOT listed (auto from .flow) |
When no factory state is specified:
->event(':checkout') // Uses #[EventFactory(':checkout')]::definition() (default)
->action('calculate discount') // Uses #[ActionFactory('calculate discount')]::definition() (default)What gets listed in path definitions:
| Item | Listed? | Why |
|---|---|---|
| Events | ✅ YES | Need factory for payload |
| Guards | ✅ YES | Need expectation (PASS/FAIL) |
| Bound Actions | ✅ YES | Need factory for context |
| DSL expressions | ❌ NO | Auto from .flow file |
Alternative Path: Failure Scenario
class PaymentFailedPath extends PathDefinition
{
public string $name = 'payment_failed';
public string $machine = '@order';
public string $description = 'Order where payment fails';
public function steps(): array
{
return [
Step::define()
->event(':checkout') // #[EventFactory(':checkout')]::default
->guard('cart is not empty', GuardExpectation::PASS)
->action('calculate totals') // #[ActionFactory('calculate totals')]::default
->transitionsTo('#awaiting_payment'),
Step::define()
->event(':payment_received', 'declined') // #[EventFactory(':payment_received')]::declined()
->guard('payment is valid', GuardExpectation::FAIL) // Payment fails!
->transitionsTo('#payment_failed'),
Step::define()
->event(':retry_payment', 'successful') // #[EventFactory(':retry_payment')]::successful()
->guard('retry allowed', GuardExpectation::PASS)
->guard('payment is valid', GuardExpectation::PASS)
->action('capture payment') // #[ActionFactory('capture payment')]::default
->transitionsTo('#paid'),
];
}
}Step Builder API
class Step
{
/**
* Start building a step definition
*/
public static function define(): self;
/**
* Set event with optional factory state
* Factory discovered via #[EventFactory(':event_name')] attribute
*/
public function event(string $event, ?string $factoryState = null): self;
/**
* Declare expected guard outcome
*/
public function guard(string $pattern, GuardExpectation $expectation): self;
/**
* Add action with optional factory state
* Factory discovered via #[ActionFactory('pattern')] attribute
*/
public function action(string $pattern, ?string $factoryState = null): self;
/**
* Target state after this step
*/
public function transitionsTo(string $state): self;
}
enum GuardExpectation
{
case PASS;
case FAIL;
}Simulation Engine
How Simulation Works
The machine runs its normal logic - simulation just provides the data:
class PathSimulator
{
private StateGraph $graph;
private bool $mockGuards;
private bool $skipSideEffects;
public function simulate(PathDefinition $path, array $initialContext = []): SimulationResult
{
$context = $initialContext;
$state = $this->graph->getInitialState();
$trace = [];
foreach ($path->steps() as $step) {
// 1. Generate event payload from factory
$payload = $this->createPayload($step);
// 2. Evaluate guards
foreach ($step->guards as $guardPattern => $expectation) {
$result = $this->evaluateGuard($guardPattern, $context, $payload);
if ($this->mockGuards) {
// Trust the path definition
$result = ($expectation === GuardExpectation::PASS);
} else {
// Validate expectation matches reality
$expected = ($expectation === GuardExpectation::PASS);
if ($result !== $expected) {
throw new GuardExpectationMismatch($guardPattern, $expected, $result);
}
}
if (!$result) {
// Guard failed - stop here (for failure paths)
break;
}
}
// 3. Execute actions (accumulate context)
foreach ($step->actions as $action) {
$actionContext = $this->getActionContext($action, $context);
$result = $this->executeAction($action, $actionContext);
$context = array_merge($context, $result);
}
// 4. Transition state
$state = $step->targetState;
$trace[] = new TraceEntry($step, $state, $context);
}
return new SimulationResult(
finalState: $state,
finalContext: $context,
trace: $trace,
);
}
private function createPayload(Step $step): array
{
// Discover factory via attribute matching
// e.g., ':checkout' → find class with #[EventFactory(':checkout')]
$factoryClass = $this->factoryResolver->findEventFactory($step->event);
$factory = new $factoryClass();
if ($step->eventFactoryState) {
$factory = $factory->{$step->eventFactoryState}();
}
return $factory->create();
}
private function getActionContext(string $actionPattern, ?string $factoryState, array $currentContext): array
{
// Discover factory via attribute matching
// e.g., 'calculate discount' → find class with #[ActionFactory('calculate discount')]
$factoryClass = $this->factoryResolver->findActionFactory($actionPattern);
if (!$factoryClass) {
return $currentContext; // No factory for this action
}
$factory = new $factoryClass();
if ($factoryState) {
$factory = $factory->{$factoryState}();
}
return array_merge($currentContext, $factory->create());
}
}Side Effect Handling
Actions with side effects use #[SideEffect] attribute:
#[Action('send confirmation email')]
#[RequiresContext('customer', 'order_id')]
class SendConfirmationAction extends ActionBehavior
{
public function __construct(
private EmailService $emailService
) {}
public function __invoke(array $context): array
{
$this->sendNotification($context);
return [
'confirmation_sent' => true,
'sent_at' => now(),
];
}
#[SideEffect] // Skipped during simulation
protected function sendNotification(array $context): void
{
$this->emailService->sendOrderConfirmation(
$context['customer']->email,
$context['order_id']
);
}
}During simulation, methods marked #[SideEffect] are skipped. The action still returns its context modifications.
Testing Side Effects (Laravel Fake Pattern)
For actual integration tests, use Laravel's Fake pattern:
class SendConfirmationActionTest extends EventFlowTestCase
{
public function test_sends_email_to_customer(): void
{
// Arrange - use Laravel fake
Mail::fake();
$action = app(SendConfirmationAction::class);
$context = $this->contextFor('send confirmation email', [
'customer' => CustomerFactory::new()->create(),
'order_id' => 'order-123',
]);
// Act
$result = $action($context);
// Assert - verify in test, not in action
Mail::assertSent(OrderConfirmationMail::class, function ($mail) {
return $mail->hasTo('customer@example.com');
});
$this->assertTrue($result['confirmation_sent']);
}
}CLI Workflow
Path Discovery
$ eventflow paths:discover @order
╔══════════════════════════════════════════════════════════════════╗
║ Discovered Paths for @order ║
╠═══════╦══════════════════════════╦══════════════╦════════════════╣
║ ID ║ Path ║ Target ║ Steps ║
╠═══════╬══════════════════════════╬══════════════╬════════════════╣
║ P1 ║ #created → #shipped ║ #shipped ║ 4 transitions ║
║ P2 ║ #created → #shipped_exp ║ #shipped ║ 3 transitions ║
║ P3 ║ #created → #cancelled ║ #cancelled ║ 2 transitions ║
║ P4 ║ #created → #refunded ║ #refunded ║ 5 transitions ║
║ P5 ║ #paid → #shipped ║ #shipped ║ 2 transitions ║
╚═══════╩══════════════════════════╩══════════════╩════════════════╝
Found 5 unique paths. Use 'eventflow path:generate P1' to create.Path Generation
$ eventflow path:generate P1 --name=StandardHappyPath
Generated files:
✓ tests/EventFlow/Paths/Order/StandardHappyPath.php
✓ tests/EventFlow/Factories/Events/CheckoutEventFactory.php (if not exists)
✓ tests/EventFlow/Factories/Events/PaymentEventFactory.php (if not exists)
✓ tests/EventFlow/Factories/Events/ShippingEventFactory.php (if not exists)
✓ tests/EventFlow/Factories/Actions/DiscountActionFactory.php (stub)
✓ tests/EventFlow/Factories/Actions/PaymentActionFactory.php (stub)
Next steps:
1. Fill in factory definitions
2. Run: eventflow path:simulate StandardHappyPathPath Simulation
$ eventflow path:simulate StandardHappyPath
Simulating: StandardHappyPath (@order)
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
Step 1: :checkout
Event: CheckoutEventFactory (default)
Guards:
✓ cart is not empty → PASS
✓ customer is valid → PASS
Actions:
✓ calculate totals
✓ reserve inventory
State: #created → #awaiting_payment
Context: +$subtotal, +$reserved_items
Step 2: :payment_received
Event: PaymentEventFactory (successful)
Guards:
✓ payment is valid → PASS
Actions:
✓ capture payment
✓ send confirmation [SIDE_EFFECT SKIPPED]
State: #awaiting_payment → #paid
Context: +$payment_id, +$paid_at
Step 3: :ship
Event: ShippingEventFactory (standard)
Actions:
✓ generate tracking
✓ notify customer [SIDE_EFFECT SKIPPED]
State: #paid → #shipped
Context: +$tracking_number, +$shipped_at
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
✓ Simulation complete
Final state: #shipped
Context keys: 12
Side effects skipped: 2Usage in Tests
Unit Testing with Factories
class CalculateDiscountActionTest extends EventFlowTestCase
{
public function test_premium_discount_applied(): void
{
$action = new CalculateDiscountAction();
$context = DiscountActionFactory::new()
->premium()
->create(['subtotal' => 200.00]);
$result = $action($context);
$this->assertEquals(20.00, $result['discount_amount']);
}
}Integration Testing with Path Simulation
class OrderFlowTest extends EventFlowTestCase
{
public function test_premium_express_happy_path(): void
{
$path = new PremiumExpressHappyPath();
$result = $this->simulate($path);
$this->assertEquals('#shipped_express', $result->finalState);
$this->assertArrayHasKey('tracking_number', $result->finalContext);
}
public function test_can_reach_paid_state(): void
{
$path = new PremiumExpressHappyPath();
// Simulate up to specific state
$result = $this->simulate($path, untilState: '#paid');
$this->assertEquals('#paid', $result->finalState);
$this->assertArrayHasKey('payment_id', $result->finalContext);
}
public function test_from_paid_state(): void
{
// Get machine in #paid state
$machine = $this->machineAt('@order', '#paid', using: PremiumExpressHappyPath::class);
// Now test what happens from #paid
$result = $machine->receive(':ship', ShippingEventFactory::new()->express()->create());
$this->assertEquals('#shipped_express', $machine->currentState());
}
}Testing Guard Failures
class PaymentFailureTest extends EventFlowTestCase
{
public function test_payment_declined_transitions_to_failed(): void
{
$path = new PaymentFailedPath();
// Simulate with validation (not mocking)
$result = $this->simulate($path, validateGuards: true);
// Path stops at payment failure
$this->assertEquals('#payment_failed', $result->finalState);
}
}Directory Structure
tests/
└── EventFlow/
├── Factories/
│ ├── Events/
│ │ ├── CheckoutEventFactory.php
│ │ ├── PaymentEventFactory.php
│ │ └── ShippingEventFactory.php
│ ├── Actions/
│ │ ├── DiscountActionFactory.php
│ │ ├── PaymentActionFactory.php
│ │ └── TrackingActionFactory.php
│ └── Domain/
│ ├── CustomerFactory.php
│ ├── CartFactory.php
│ └── AddressFactory.php
├── Paths/
│ └── Order/
│ ├── StandardHappyPath.php
│ ├── PremiumExpressHappyPath.php
│ ├── PaymentFailedPath.php
│ └── RefundPath.php
└── Unit/
├── Guards/
│ └── CartNotEmptyGuardTest.php
└── Actions/
└── CalculateDiscountActionTest.phpIntegration with Existing Proposals
Context Dependencies Proposal
Factories complement #[RequiresContext]:
#[Action('calculate discount')]
#[RequiresContext('subtotal', 'customer_tier', '?coupon_code')]
class CalculateDiscountAction extends ActionBehavior
{
// ...
}
// Factory provides required context automatically
#[ActionFactory('calculate discount')]
class DiscountActionFactory extends ActionFactory
{
public function definition(): array
{
return [
'subtotal' => 100.00, // Required
'customer_tier' => 'standard', // Required
// coupon_code is optional - not included by default
];
}
}The contextFor() helper can use factories internally:
// This still works
$context = $this->contextFor('calculate discount', ['subtotal' => 500]);
// Behind the scenes: uses DiscountActionFactory if availableData Validation Proposal
Event factories generate valid data by default:
#[EventFactory(':checkout')]
class CheckoutEventFactory extends EventFactory
{
public function definition(): array
{
return [
// Respects validation: "email must be valid email"
'email' => $this->faker->safeEmail(),
// Respects validation: "total must be greater than 0"
'total' => $this->faker->numberBetween(1, 1000),
// Respects validation: "items must have at least 1 item"
'items' => [['id' => 1, 'quantity' => 1]],
];
}
/**
* For negative testing - generate invalid data
*/
public function invalidEmail(): static
{
return $this->state(['email' => 'not-an-email']);
}
}Implementation Components
Core Classes
| Class | Purpose |
|---|---|
EventFactory | Base class for event payload factories |
ActionFactory | Base class for action context factories |
DomainFactory | Base class for domain object factories |
PathDefinition | Abstract class for path definitions |
Step | Builder for individual path steps |
PathSimulator | Executes paths and tracks state |
SimulationResult | Contains final state, context, and trace |
Attributes
| Attribute | Purpose |
|---|---|
#[EventFactory(':event')] | Mark class as event factory |
#[ActionFactory('pattern')] | Mark class as action factory |
#[SideEffect] | Mark method to skip during simulation |
PHPUnit Traits
| Trait | Purpose |
|---|---|
InteractsWithFactories | Factory access in tests |
InteractsWithPaths | Path simulation in tests |
Benefits Summary
| Benefit | Description |
|---|---|
| Laravel Familiar | Same factory pattern developers know |
| Explicit Bindings | Path definitions show which factories provide data |
| State Variations | Factory states enable systematic scenario testing |
| Path Simulation | "Give me a machine in state B" |
| Side Effect Safety | External calls skipped during simulation |
| Integration with Proposals | Complements RequiresContext and Data Validation |
| CLI Tooling | Discover paths, generate factories, run simulations |
| No DSL Changes | Pure PHP testing infrastructure |
Summary
This proposal introduces a comprehensive testing factory system for EventFlow:
- Event Factories - Generate valid payloads with states
- Action Factories - Provide action context with states
- Domain Factories - Create Customer, Cart, etc.
- Path Definitions - Describe state machine journeys with factory bindings
- Guard Expectations - Declare PASS/FAIL instead of guard factories
- Simulation Engine - Execute paths to reach any state
- Side Effect Handling -
#[SideEffect]attribute for simulation safety
The key insight is that guards don't need factories - they evaluate existing context. Path definitions declare expected outcomes, and the simulation either mocks or validates against reality.
This approach maintains EventFlow's philosophy: the machine runs normally, factories just provide the data it needs.