Scenarios as Tests
In EventFlow, every scenario with assertions is an executable test. Your documentation IS your test suite.
Scenarios ARE Tests
The test structure follows the familiar Given-When-Then pattern:
- Given: Initial state (
given:block) - When: Event trigger (
on :event) - Then: Assertions (
expect:block)
machine: @shopping_cart
scenario: add item to empty cart
given:
@customer is logged in
cart is empty
$total: number is 0
on :add_item from @customer (api)
$items adds $item
$total increases by $item.price
expect:
= $items contains $item
expect:
= $items_count equals 1
= $total equals 1200Run with:
eventflow test cart.flowAssertion Levels
Event-Level Assertions (Optional)
Verify after each event:
on :checkout from @customer (api)
order moves to #awaiting_payment
expect:
= order is in #awaiting_paymentScenario-Level Assertions
Verify after all events complete:
scenario: complete purchase
on :checkout from @customer (api)
order moves to #awaiting_payment
on :payment_success from @payment
order moves to #paid
on :stock_reserved from @inventory
order moves to #fulfilled
expect:
= order is in #fulfilled
= @customer received :order_confirmedRecommendation
- Use scenario-level
expect:for main test assertions - Use event-level
expect:for debugging or critical checkpoints
Test Granularity
| Level | What | EventFlow | Binding Code |
|---|---|---|---|
| Unit | Individual action/guard | N/A | Test each binding class |
| Integration | Event handler | Event-level expect | Test event flow |
| Feature | Full scenario | Scenario-level expect | Test complete feature |
Complete Test Example
machine: @order
scenario: successful checkout
given:
@customer is logged in as "[email protected]"
@customer has verified payment method
cart contains:
| product | price |
| Laptop | 1200 |
| Mouse | 25 |
$total: number is 1225
on :checkout from @customer (api)
? cart is not empty
order moves to #awaiting_payment
emit :payment_request to @payment
expect:
= order is in #awaiting_payment
= :payment_request was emitted to @payment
on :payment_success from @payment
order moves to #paid
$paid_at becomes now
emit :order_confirmed to @customer
expect:
= order is in #paid
= $paid_at is not empty
expect:
= order is in #paid
= @customer received :order_confirmed
= $total equals 1225Assertion Patterns
State Assertions
expect:
= order is in #paid
= order is not in #cancelled
= order has entered #processingContext Assertions
expect:
= $total equals 1200
= $items_count equals 3
= $discount is greater than 0
= $customer_email is not emptyEvent Assertions
expect:
= @customer received :order_confirmed
= :payment_request was emitted to @payment
= @warehouse received :prepare_shipmentCollection Assertions
expect:
= $items contains "Laptop"
= $items is not empty
= $tags includes "urgent"Response Assertions
Verify API responses from reply statements:
expect:
= reply status is 201
= reply.id is not empty
= reply.customer.name equals "John"
= reply does not contain trackingFor async responses:
expect:
= callback for :generate_report was sentSee Assertions for complete response assertion syntax.
Testing Multiple Scenarios
machine: @order
scenario: successful checkout
given:
cart has items
payment method is valid
on :checkout from @customer (api)
order moves to #paid
expect:
= order is in #paid
scenario: checkout with empty cart
given:
cart is empty
on :checkout from @customer (api)
emit :empty_cart_error to @customer
expect:
= order is not in #paid
= @customer received :empty_cart_error
scenario: checkout with payment failure
given:
cart has items
payment will fail
on :checkout from @customer (api)
order moves to #awaiting_payment
on :payment_failed from @payment
order moves to #payment_failed
expect:
= order is in #payment_failedTest Runner Commands
# Run all scenarios in a file
eventflow test order.flow
# Run specific scenario
eventflow test order.flow --scenario="successful checkout"
# Run all tests in directory
eventflow test ./flows/
# Run with verbose output
eventflow test order.flow --verbose
# Watch mode (re-run on file changes)
eventflow test order.flow --watchTesting Systems
Test entire multi-machine systems:
# Test system with all machines
eventflow test system.flow
# Test specific flow across machines
eventflow test system.flow --scenario="complete purchase"Test Output
eventflow test order.flow order.test.flow
@order / complete checkout
happy path
✓ checkout → awaiting_payment (18ms)
✓ payment_success → confirmed (21ms)
:checkout variations
✓ empty cart rejected (12ms)
✓ guest user redirected to login (10ms)
:payment_failed variations
✓ retry after payment failure (16ms)
✓ retry limit exceeded (13ms)
6 passing (90ms)With failures:
eventflow test order.flow
@order / complete checkout
✓ successful checkout (23ms)
✗ checkout with empty cart (15ms)
Expected: order is in #error
Actual: order is in #awaiting_payment
1 passing, 1 failing (38ms)Coverage Report
Use --coverage to see which events, guards, and states are tested:
eventflow test order.flow order.test.flow --coverageCoverage Report: order.flow
Events:
:checkout ✓ 7 tests
:payment_success ✓ 2 tests
:payment_failed ✓ 2 tests
:ship ✓ 2 tests
:cancel ✗ 0 tests (only in scenario)
Guards:
? customer is premium ✓ both branches
? payment_gateway available ✓ both branches
? payment successful ✓ both branches
? fraud detected ✓ both branches
? $total > 1000 ✓ both branches
? $total > 500 ✓ both branches
? $retry_count < 3 ✓ both branches
States:
#pending ✓ entry/exit tested
#confirmed ✓ entry tested
#paid ✓ entry tested
#shipped ✓ entry tested
#queued ✓ entry tested
#payment_failed ✓ entry tested
#fraud_rejected ✓ entry tested
#permanently_failed ✓ entry tested
#cancelled ✓ entry tested (scenario only)
#error ✗ not tested
Coverage: 94% (17/18 branches)The coverage report shows:
- Events: Which events have transition tests
- Guards: Whether both true/false branches are tested
- States: Which states are entered during tests
Best Practices
Test Happy Path and Edge Cases
scenario: happy path - successful order
// Normal flow
scenario: edge case - empty cart
// Edge case
scenario: error case - payment failure
// Error handlingUse Descriptive Scenario Names
// Good
scenario: customer can checkout with valid payment
scenario: checkout fails when cart is empty
scenario: order cancelled after 3 payment failures
// Avoid
scenario: test 1
scenario: checkout testKeep Scenarios Focused
// Good - focused on one thing
scenario: add item updates total
given:
$total: number is 100
on :add_item from @customer (api)
$total increases by $item.price
expect:
= $total equals 150
// Avoid - testing too many things
scenario: full order flow test
// 50 lines of setup and assertionsUnit Testing Bindings
While EventFlow scenarios test behavior at the integration level, you may also want unit tests for individual PHP bindings. This is especially useful when following a TDD approach.
Running Binding Tests
Use eventflow test:binding to run tests for a specific pattern:
$ eventflow test:binding "cart is not empty"
Testing binding: 'cart is not empty'
Binding: App\Order\Guards\CartNotEmptyGuard
✓ returns false when cart is null (2ms)
✓ returns false when cart is empty (1ms)
✓ returns true when cart has items (1ms)
3 passing (4ms)Or run all binding tests:
$ eventflow test:bindings
# Or filter by type
$ eventflow test:bindings --type=guardTesting Guards
Guards return boolean values based on context. Link tests with #[Tests]:
<?php
namespace Tests\Order\Guards;
use PHPUnit\Framework\TestCase;
use App\Order\Guards\CartNotEmptyGuard;
use EventFlow\Testing\Attributes\Tests;
#[Tests('cart is not empty')]
class CartNotEmptyGuardTest extends TestCase
{
private CartNotEmptyGuard $guard;
protected function setUp(): void
{
$this->guard = new CartNotEmptyGuard();
}
public function test_returns_false_when_cart_is_null(): void
{
$context = ['cart' => null];
$this->assertFalse(($this->guard)($context));
}
public function test_returns_false_when_cart_is_empty(): void
{
$cart = new \stdClass();
$cart->items = [];
$context = ['cart' => $cart];
$this->assertFalse(($this->guard)($context));
}
public function test_returns_true_when_cart_has_items(): void
{
$cart = new \stdClass();
$cart->items = [['id' => 1, 'name' => 'Laptop']];
$context = ['cart' => $cart];
$this->assertTrue(($this->guard)($context));
}
}Testing Actions
Actions return context updates:
<?php
namespace Tests\Order\Actions;
use PHPUnit\Framework\TestCase;
use App\Order\Actions\IncrementRetryCountAction;
use EventFlow\Testing\Attributes\Tests;
#[Tests('$retry_count increases by 1')]
class IncrementRetryCountActionTest extends TestCase
{
private IncrementRetryCountAction $action;
protected function setUp(): void
{
$this->action = new IncrementRetryCountAction();
}
public function test_increments_retry_count(): void
{
$context = ['retry_count' => 2];
$result = ($this->action)($context);
$this->assertEquals(['retry_count' => 3], $result);
}
public function test_defaults_to_one_when_not_set(): void
{
$context = [];
$result = ($this->action)($context);
$this->assertEquals(['retry_count' => 1], $result);
}
}Testing Side-Effect Actions
Actions with side effects use mocks:
<?php
namespace Tests\Order\Actions;
use PHPUnit\Framework\TestCase;
use App\Order\Actions\SendConfirmationEmailAction;
use App\Services\EmailService;
use EventFlow\Testing\Attributes\Tests;
use Mockery;
#[Tests('send confirmation email')]
class SendConfirmationEmailActionTest extends TestCase
{
public function test_sends_confirmation_email(): void
{
$emailService = Mockery::mock(EmailService::class);
$emailService->shouldReceive('sendOrderConfirmation')
->once()
->with(
customerId: 'customer-123',
orderId: 'order-456',
total: 1200.00
);
$action = new SendConfirmationEmailAction($emailService);
$customer = new \stdClass();
$customer->id = 'customer-123';
$context = [
'customer' => $customer,
'order_id' => 'order-456',
'total' => 1200.00,
];
$result = ($action)($context);
$this->assertEquals([], $result); // No context changes
}
protected function tearDown(): void
{
Mockery::close();
}
}The Double Loop
Unit tests for bindings form the inner loop of the BDD/TDD Double Loop:
| Loop | Level | Tool | What it Tests |
|---|---|---|---|
| Outer (BDD) | Feature | eventflow test | Business behavior from flow files |
| Inner (TDD) | Unit | eventflow test:binding | Individual binding implementation |
The workflow:
1. OUTER LOOP (FAIL)
$ eventflow test order.flow
→ Error: No binding for 'cart is not empty'
2. WRITE TEST (RED)
$ eventflow test:binding "cart is not empty"
→ Error: Class not found
3. IMPLEMENT (GREEN)
$ eventflow test:binding "cart is not empty"
→ 3 passing
4. OUTER LOOP CHECK
$ eventflow test order.flow
→ +1 scenario passingFor test linking (connecting bindings to their tests), see Test Linking Guide.
For a complete TDD workflow, see Session 4b: TDD Implementation.
Testing Validation
EventFlow provides built-in support for testing data validation. When validation fails, the :validation_failed event is automatically emitted.
Testing Validation Success
Verify that valid data passes validation:
scenario: valid checkout data accepted
on :checkout from @customer (api) with:
| field | value | validation |
| email | "user@example.com"| required, string, valid email |
| total | 1200 | required, number, greater than 0 |
expect:
= :validation_failed was not emitted
= order is in #awaiting_paymentTesting Validation Failure
Verify that invalid data is rejected:
scenario: invalid email rejected
on :checkout from @customer (api) with:
| field | value | validation |
| email | "not-an-email" | required, string, valid email |
expect:
= :validation_failed was emitted
= order is not in #awaiting_payment
= $errors contains "email"Testing Multiple Validation Rules
scenario: multiple validation errors
on :checkout from @customer (api) with:
| field | value | validation |
| email | "" | required, string, valid email |
| total | -50 | required, number, greater than 0 |
expect:
= :validation_failed was emitted
= validation error count is 2
= $errors contains "email"
= $errors contains "total"Testing Context Constraints
Context constraints validate data after actions. When violated, the action rolls back:
scenario: retry limit constraint
given:
$retry_count: number is 5
on :retry_checkout from @customer (api)
$retry_count increases by 1
| $retry_count | required, integer, between 0 and 5 |
expect:
= :constraint_violated was emitted
= $retry_count is still 5Testing Custom Validation Rules
Test custom validation rules the same way:
scenario: custom validation rule
on :register from @customer (api) with:
| field | value | validation |
| turkish_id | "12345678901" | required, string, valid turkish_id |
expect:
= :validation_failed was not emitted
= registration is in #pendingTesting Validation in Test Files
Use .test.flow files for validation edge cases:
// order.test.flow
test: @order
for scenario: checkout
for :checkout:
invalid email:
when:
@customer sends :checkout with:
| field | value | validation |
| email | "not-an-email" | required, string, valid email |
= :validation_failed was emitted
= order is not in #awaiting_payment
missing required field:
when:
@customer sends :checkout with:
| field | value | validation |
| email | | required |
= :validation_failed was emitted
= $errors contains "email.required"
boundary value (edge case):
when:
@customer sends :checkout with:
| field | value | validation |
| amount | 0 | required, number, greater than 0 |
= :validation_failed was emittedTesting Validation Rule Bindings
Use TDD to test custom validation rules:
<?php
namespace Tests\Order\Rules;
use PHPUnit\Framework\TestCase;
use App\Order\Rules\ValidEmailRule;
use EventFlow\Validation\Context;
use EventFlow\Testing\Attributes\Tests;
#[Tests('valid email')]
class ValidEmailRuleTest extends TestCase
{
private ValidEmailRule $rule;
protected function setUp(): void
{
$this->rule = new ValidEmailRule();
}
public function test_returns_false_for_invalid_email(): void
{
$this->assertFalse($this->rule->validate('not-an-email', new Context()));
}
public function test_returns_true_for_valid_email(): void
{
$this->assertTrue($this->rule->validate('user@example.com', new Context()));
}
}See Data Validation for complete validation documentation.