Skip to content

Testing Guide

Jean-Marc Strauven edited this page Aug 6, 2025 · 2 revisions

πŸ§ͺ Testing Guide

Testing state machines is crucial for ensuring your workflows behave correctly. Laravel Statecraft provides comprehensive testing utilities and patterns to make testing easy and reliable.

πŸ—οΈ Test Setup

Basic Test Configuration

First, ensure your test environment is properly configured:

// tests/TestCase.php
<?php

namespace Tests;

use Illuminate\Foundation\Testing\TestCase as BaseTestCase;
use Illuminate\Foundation\Testing\RefreshDatabase;

abstract class TestCase extends BaseTestCase
{
    use CreatesApplication, RefreshDatabase;

    protected function setUp(): void
    {
        parent::setUp();
        
        // Disable state machine cache for tests
        config(['statecraft.cache.enabled' => false]);
        
        // Enable history tracking for tests
        config(['statecraft.enable_history' => true]);
    }
}

State Machine Factory

Create factories for testing different states:

// database/factories/OrderFactory.php
<?php

namespace Database\Factories;

use App\Models\Order;
use Illuminate\Database\Eloquent\Factories\Factory;

class OrderFactory extends Factory
{
    protected $model = Order::class;

    public function definition()
    {
        return [
            'amount' => $this->faker->randomFloat(2, 10, 1000),
            'customer_email' => $this->faker->email,
            'status' => 'pending',
        ];
    }

    public function pending()
    {
        return $this->state(['status' => 'pending']);
    }

    public function paid()
    {
        return $this->state(['status' => 'paid']);
    }

    public function processing()
    {
        return $this->state(['status' => 'processing']);
    }

    public function shipped()
    {
        return $this->state(['status' => 'shipped']);
    }

    public function delivered()
    {
        return $this->state(['status' => 'delivered']);
    }

    public function cancelled()
    {
        return $this->state(['status' => 'cancelled']);
    }
}

πŸ”„ Testing State Transitions

Basic Transition Tests

<?php

namespace Tests\Feature;

use App\Models\Order;
use Tests\TestCase;

class OrderStateMachineTest extends TestCase
{
    public function test_order_starts_in_pending_state()
    {
        $order = Order::factory()->create();
        
        $this->assertEquals('pending', $order->getCurrentState());
    }

    public function test_order_can_transition_from_pending_to_paid()
    {
        $order = Order::factory()->pending()->create();
        
        $this->assertTrue($order->canTransitionTo('pay'));
        
        $order->transitionTo('pay');
        
        $this->assertEquals('paid', $order->getCurrentState());
    }

    public function test_order_cannot_skip_states()
    {
        $order = Order::factory()->pending()->create();
        
        $this->assertFalse($order->canTransitionTo('ship'));
        
        $this->expectException(\Grazulex\LaravelStatecraft\Exceptions\TransitionNotAllowedException::class);
        $order->transitionTo('ship');
    }

    public function test_completed_workflow()
    {
        $order = Order::factory()->create();
        
        // Full workflow test
        $order->transitionTo('pay');
        $this->assertEquals('paid', $order->getCurrentState());
        
        $order->transitionTo('process');
        $this->assertEquals('processing', $order->getCurrentState());
        
        $order->transitionTo('ship');
        $this->assertEquals('shipped', $order->getCurrentState());
        
        $order->transitionTo('deliver');
        $this->assertEquals('delivered', $order->getCurrentState());
    }
}

Testing with Context

public function test_transition_with_context()
{
    $order = Order::factory()->pending()->create();
    
    $context = [
        'payment_method' => 'credit_card',
        'user_id' => 1
    ];
    
    $order->transitionTo('pay', $context);
    
    $this->assertEquals('paid', $order->getCurrentState());
    
    // Verify context was stored in history
    $history = $order->getLastStateChange();
    $this->assertEquals($context, $history->context);
}

πŸ›‘οΈ Testing Guards

Unit Testing Guards

<?php

namespace Tests\Unit\Guards;

use App\StateMachine\Guards\PaymentGuard;
use App\Models\Order;
use Tests\TestCase;

class PaymentGuardTest extends TestCase
{
    public function test_allows_transition_with_valid_amount()
    {
        $order = Order::factory()->make(['amount' => 100.00]);
        $guard = new PaymentGuard();
        
        $this->assertTrue($guard->check($order, 'pending', 'paid'));
    }

    public function test_blocks_transition_with_zero_amount()
    {
        $order = Order::factory()->make(['amount' => 0]);
        $guard = new PaymentGuard();
        
        $this->assertFalse($guard->check($order, 'pending', 'paid'));
    }

    public function test_blocks_transition_with_negative_amount()
    {
        $order = Order::factory()->make(['amount' => -50.00]);
        $guard = new PaymentGuard();
        
        $this->assertFalse($guard->check($order, 'pending', 'paid'));
    }
}

Integration Testing Guards

public function test_guard_prevents_invalid_transitions()
{
    $order = Order::factory()->create(['amount' => 0]);
    
    $this->assertFalse($order->canTransitionTo('pay'));
    
    $this->expectException(\Grazulex\LaravelStatecraft\Exceptions\TransitionNotAllowedException::class);
    $order->transitionTo('pay');
}

public function test_guard_with_dependency_injection()
{
    // Mock external service
    $this->mock(\App\Services\PaymentService::class, function ($mock) {
        $mock->shouldReceive('validatePayment')->andReturn(true);
    });
    
    $order = Order::factory()->pending()->create();
    
    $this->assertTrue($order->canTransitionTo('pay'));
}

βš™οΈ Testing Actions

Unit Testing Actions

<?php

namespace Tests\Unit\Actions;

use App\StateMachine\Actions\SendConfirmationEmail;
use App\Models\Order;
use App\Mail\OrderConfirmationMail;
use Illuminate\Support\Facades\Mail;
use Tests\TestCase;

class SendConfirmationEmailTest extends TestCase
{
    public function test_sends_confirmation_email()
    {
        Mail::fake();
        
        $order = Order::factory()->create([
            'customer_email' => '[email protected]'
        ]);
        
        $action = new SendConfirmationEmail();
        $action->execute($order, 'pending', 'paid');
        
        Mail::assertSent(OrderConfirmationMail::class, function ($mail) use ($order) {
            return $mail->hasTo('[email protected]');
        });
    }

    public function test_updates_model_after_sending_email()
    {
        Mail::fake();
        
        $order = Order::factory()->create();
        
        $action = new SendConfirmationEmail();
        $action->execute($order, 'pending', 'paid');
        
        $this->assertNotNull($order->fresh()->confirmation_email_sent_at);
    }
}

Testing Action Side Effects

public function test_inventory_action_updates_stock()
{
    $product = Product::factory()->create(['stock' => 100]);
    $order = Order::factory()->create();
    $order->items()->create([
        'product_id' => $product->id,
        'quantity' => 5
    ]);
    
    $action = new ReserveInventory();
    $action->execute($order, 'paid', 'processing');
    
    $this->assertEquals(95, $product->fresh()->available_stock);
    $this->assertEquals(5, $product->fresh()->reserved_stock);
}

🎯 Testing Events

Event Firing Tests

<?php

namespace Tests\Feature;

use App\Models\Order;
use Grazulex\LaravelStatecraft\Events\StateTransitioned;
use Illuminate\Support\Facades\Event;
use Tests\TestCase;

class StateEventsTest extends TestCase
{
    public function test_state_transition_fires_event()
    {
        Event::fake();
        
        $order = Order::factory()->create();
        $order->transitionTo('pay');
        
        Event::assertDispatched(StateTransitioned::class, function ($event) use ($order) {
            return $event->model->is($order) &&
                   $event->fromState === 'pending' &&
                   $event->toState === 'paid';
        });
    }

    public function test_failed_transition_fires_failed_event()
    {
        Event::fake();
        
        $order = Order::factory()->create(['amount' => 0]);
        
        try {
            $order->transitionTo('pay');
        } catch (\Exception $e) {
            // Expected to fail
        }
        
        Event::assertDispatched(\Grazulex\LaravelStatecraft\Events\StateTransitionFailed::class);
    }
}

Testing Event Listeners

public function test_order_listener_sends_notification()
{
    Mail::fake();
    
    $order = Order::factory()->create();
    $order->transitionTo('pay');
    
    // Verify listener was triggered
    Mail::assertSent(OrderConfirmationMail::class);
}

πŸ“Š Testing State History

History Recording Tests

public function test_state_changes_are_recorded()
{
    $order = Order::factory()->create();
    
    $this->assertEquals(0, $order->getStateHistory()->count());
    
    $order->transitionTo('pay');
    
    $this->assertEquals(1, $order->getStateHistory()->count());
    
    $history = $order->getLastStateChange();
    $this->assertEquals('pending', $history->from_state);
    $this->assertEquals('paid', $history->to_state);
}

public function test_user_is_recorded_in_history()
{
    $user = User::factory()->create();
    $this->actingAs($user);
    
    $order = Order::factory()->create();
    $order->transitionTo('pay');
    
    $history = $order->getLastStateChange();
    $this->assertEquals($user->id, $history->user_id);
}

πŸ”„ Testing Complex Workflows

Multi-Step Workflow Tests

public function test_complete_order_workflow()
{
    $order = Order::factory()->create();
    $user = User::factory()->create();
    $this->actingAs($user);
    
    // Track each step
    $steps = [
        'pay' => 'paid',
        'process' => 'processing',
        'ship' => 'shipped',
        'deliver' => 'delivered'
    ];
    
    foreach ($steps as $transition => $expectedState) {
        $this->assertTrue($order->canTransitionTo($transition));
        $order->transitionTo($transition);
        $this->assertEquals($expectedState, $order->getCurrentState());
    }
    
    // Verify complete history
    $history = $order->getStateHistory();
    $this->assertCount(4, $history);
}

Error Recovery Tests

public function test_failed_transition_does_not_change_state()
{
    $order = Order::factory()->create(['amount' => 0]);
    $originalState = $order->getCurrentState();
    
    try {
        $order->transitionTo('pay');
    } catch (\Exception $e) {
        // Expected to fail
    }
    
    $this->assertEquals($originalState, $order->getCurrentState());
}

🎭 Mocking and Stubbing

Mocking External Services

public function test_payment_processing_with_mock()
{
    // Mock payment service
    $this->mock(\App\Services\PaymentGateway::class, function ($mock) {
        $mock->shouldReceive('processPayment')
             ->once()
             ->with(100.00, 'credit_card')
             ->andReturn(['status' => 'success', 'id' => 'txn_123']);
    });
    
    $order = Order::factory()->create(['amount' => 100.00]);
    
    $order->transitionTo('pay', ['payment_method' => 'credit_card']);
    
    $this->assertEquals('paid', $order->getCurrentState());
}

Stubbing Guards and Actions

public function test_with_stubbed_guard()
{
    // Create a test-specific guard that always passes
    $this->app->bind(\App\StateMachine\Guards\PaymentGuard::class, function () {
        return new class implements \Grazulex\LaravelStatecraft\Contracts\Guard {
            public function check($model, $from, $to): bool {
                return true;
            }
        };
    });
    
    $order = Order::factory()->create(['amount' => 0]);
    
    // Should now pass even with zero amount
    $this->assertTrue($order->canTransitionTo('pay'));
}

πŸ“ˆ Performance Testing

Load Testing State Machines

public function test_bulk_transitions_performance()
{
    $orders = Order::factory()->count(1000)->create();
    
    $startTime = microtime(true);
    
    foreach ($orders as $order) {
        $order->transitionTo('pay');
    }
    
    $endTime = microtime(true);
    $duration = $endTime - $startTime;
    
    // Should complete within reasonable time
    $this->assertLessThan(10, $duration, 'Bulk transitions took too long');
}

Memory Usage Tests

public function test_state_machine_memory_usage()
{
    $startMemory = memory_get_usage();
    
    $orders = Order::factory()->count(100)->create();
    
    foreach ($orders as $order) {
        $order->transitionTo('pay');
        $order->transitionTo('process');
        $order->transitionTo('ship');
    }
    
    $endMemory = memory_get_usage();
    $memoryUsed = $endMemory - $startMemory;
    
    // Memory usage should be reasonable
    $this->assertLessThan(50 * 1024 * 1024, $memoryUsed, 'Memory usage too high');
}

πŸ”§ Test Utilities

Custom Test Assertions

// tests/TestCase.php
protected function assertStateEquals($expected, $model)
{
    $this->assertEquals($expected, $model->getCurrentState(), 
        "Expected state '{$expected}' but got '{$model->getCurrentState()}'");
}

protected function assertCanTransition($model, $transition)
{
    $this->assertTrue($model->canTransitionTo($transition),
        "Model should be able to transition via '{$transition}'");
}

protected function assertCannotTransition($model, $transition)
{
    $this->assertFalse($model->canTransitionTo($transition),
        "Model should not be able to transition via '{$transition}'");
}

Test Data Builders

class OrderBuilder
{
    private $attributes = [];

    public static function anOrder(): self
    {
        return new self();
    }

    public function withAmount(float $amount): self
    {
        $this->attributes['amount'] = $amount;
        return $this;
    }

    public function inState(string $state): self
    {
        $this->attributes['status'] = $state;
        return $this;
    }

    public function withCustomer(string $email): self
    {
        $this->attributes['customer_email'] = $email;
        return $this;
    }

    public function build(): Order
    {
        return Order::factory()->create($this->attributes);
    }
}

// Usage in tests
public function test_with_builder()
{
    $order = OrderBuilder::anOrder()
        ->withAmount(100.00)
        ->inState('pending')
        ->withCustomer('[email protected]')
        ->build();

    $this->assertStateEquals('pending', $order);
}

πŸ“Š Test Coverage

Measuring Coverage

# Run tests with coverage
php artisan test --coverage

# Generate HTML coverage report
php artisan test --coverage-html coverage-report

Critical Test Areas

Ensure you cover these areas:

  1. All State Transitions - Test every valid transition
  2. Guard Logic - Test all guard conditions
  3. Action Side Effects - Verify all action behaviors
  4. Error Conditions - Test invalid transitions
  5. Event Firing - Verify events are dispatched
  6. History Recording - Check audit trail
  7. Performance - Test with realistic data volumes

πŸ§ͺ Testing Best Practices

Test Organization

// Organize tests by concern
tests/
β”œβ”€β”€ Feature/
β”‚   β”œβ”€β”€ OrderWorkflowTest.php      # End-to-end workflows
β”‚   β”œβ”€β”€ StateEventsTest.php        # Event integration
β”‚   └── StateHistoryTest.php       # History functionality
β”œβ”€β”€ Unit/
β”‚   β”œβ”€β”€ Guards/
β”‚   β”‚   β”œβ”€β”€ PaymentGuardTest.php
β”‚   β”‚   └── InventoryGuardTest.php
β”‚   └── Actions/
β”‚       β”œβ”€β”€ ProcessPaymentTest.php
β”‚       └── SendEmailTest.php
└── Integration/
    └── StateMachineIntegrationTest.php

Test Naming Conventions

// Use descriptive test names
public function test_order_can_be_paid_when_amount_is_positive()
public function test_order_cannot_be_shipped_before_payment()
public function test_cancelled_orders_trigger_refund_process()

Database Considerations

// Use transactions for faster tests
use Illuminate\Foundation\Testing\DatabaseTransactions;

class OrderStateMachineTest extends TestCase
{
    use DatabaseTransactions;
    
    // Tests will automatically rollback
}
Clone this wiki locally