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.