-
-
Notifications
You must be signed in to change notification settings - Fork 0
Testing Guide
Jean-Marc Strauven edited this page Aug 6, 2025
·
2 revisions
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.
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]);
}
}
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']);
}
}
<?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());
}
}
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);
}
<?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'));
}
}
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'));
}
<?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);
}
}
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);
}
<?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);
}
}
public function test_order_listener_sends_notification()
{
Mail::fake();
$order = Order::factory()->create();
$order->transitionTo('pay');
// Verify listener was triggered
Mail::assertSent(OrderConfirmationMail::class);
}
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);
}
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);
}
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());
}
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());
}
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'));
}
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');
}
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');
}
// 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}'");
}
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);
}
# Run tests with coverage
php artisan test --coverage
# Generate HTML coverage report
php artisan test --coverage-html coverage-report
Ensure you cover these areas:
- All State Transitions - Test every valid transition
- Guard Logic - Test all guard conditions
- Action Side Effects - Verify all action behaviors
- Error Conditions - Test invalid transitions
- Event Firing - Verify events are dispatched
- History Recording - Check audit trail
- Performance - Test with realistic data volumes
// 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
// 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()
// Use transactions for faster tests
use Illuminate\Foundation\Testing\DatabaseTransactions;
class OrderStateMachineTest extends TestCase
{
use DatabaseTransactions;
// Tests will automatically rollback
}
π― Laravel Statecraft - Advanced State Machine Implementation for Laravel
Navigate: π Home | π¦ Installation | π Basic Guide | π YAML Config | π‘ Examples
Resources: π‘οΈ Guards & Actions | π― Events | π State History | π§ͺ Testing | π¨ Commands
π Community Resources:
Made with β€οΈ for the Laravel community β’ Contribute β’ License