Skip to content

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)
flow
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 1200

Run with:

bash
eventflow test cart.flow

Assertion Levels ​

Event-Level Assertions (Optional) ​

Verify after each event:

flow
on :checkout from @customer (api)
  order moves to #awaiting_payment

  expect:
    = order is in #awaiting_payment

Scenario-Level Assertions ​

Verify after all events complete:

flow
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_confirmed

Recommendation ​

  • Use scenario-level expect: for main test assertions
  • Use event-level expect: for debugging or critical checkpoints

Test Granularity ​

LevelWhatEventFlowBinding Code
UnitIndividual action/guardN/ATest each binding class
IntegrationEvent handlerEvent-level expectTest event flow
FeatureFull scenarioScenario-level expectTest complete feature

Complete Test Example ​

flow
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 1225

Assertion Patterns ​

State Assertions ​

flow
expect:
  = order is in #paid
  = order is not in #cancelled
  = order has entered #processing

Context Assertions ​

flow
expect:
  = $total equals 1200
  = $items_count equals 3
  = $discount is greater than 0
  = $customer_email is not empty

Event Assertions ​

flow
expect:
  = @customer received :order_confirmed
  = :payment_request was emitted to @payment
  = @warehouse received :prepare_shipment

Collection Assertions ​

flow
expect:
  = $items contains "Laptop"
  = $items is not empty
  = $tags includes "urgent"

Response Assertions ​

Verify API responses from reply statements:

flow
expect:
  = reply status is 201
  = reply.id is not empty
  = reply.customer.name equals "John"
  = reply does not contain tracking

For async responses:

flow
expect:
  = callback for :generate_report was sent

See Assertions for complete response assertion syntax.

Testing Multiple Scenarios ​

flow
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_failed

Test Runner Commands ​

bash
# 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 --watch

Testing Systems ​

Test entire multi-machine systems:

bash
# 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:

bash
eventflow test order.flow order.test.flow --coverage
Coverage 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 ​

flow
scenario: happy path - successful order
  // Normal flow

scenario: edge case - empty cart
  // Edge case

scenario: error case - payment failure
  // Error handling

Use Descriptive Scenario Names ​

flow
// 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 test

Keep Scenarios Focused ​

flow
// 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 assertions

Unit 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:

bash
$ 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:

bash
$ eventflow test:bindings

# Or filter by type
$ eventflow test:bindings --type=guard

Testing Guards ​

Guards return boolean values based on context. Link tests with #[Tests]:

php
<?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
<?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
<?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:

LoopLevelToolWhat it Tests
Outer (BDD)Featureeventflow testBusiness behavior from flow files
Inner (TDD)Uniteventflow test:bindingIndividual 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 passing

For 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:

flow
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_payment

Testing Validation Failure ​

Verify that invalid data is rejected:

flow
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 ​

flow
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:

flow
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 5

Testing Custom Validation Rules ​

Test custom validation rules the same way:

flow
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 #pending

Testing Validation in Test Files ​

Use .test.flow files for validation edge cases:

flow
// 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 emitted

Testing Validation Rule Bindings ​

Use TDD to test custom validation rules:

php
<?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.

Released under the MIT License.