TDD implementation of Finite State Machine (FSM) with Laravel

This article describes a TDD (Test Driven Development) approach to implement a Finite State Machine (FSM) using PHP+Laravel. However, such method is general and could be used in the programming language of your choice.

Category: Development
04/07/2018
20305 times
TDD implementation of Finite State Machine (FSM) with Laravel

Introduction

This article describes a TDD (Test Driven Development) approach to implement a Finite State Machine (FSM) using PHP+Laravel. However, such method is general and could be used in the programming language of your choice. A Finite State Machine (FSM) is a model where an object (or entity) can be exactly in one of a finite number of states at any given time. The state is defined by a set of the entity properties. The FSM is represented by a set of states and a set of transitions that bring the model from a state to another Often, developers are required to implement such a model to track the evolution of one of its entities. For example, assume you need to follow the process of a model describing a Request which can be in one of the following states:

  • Draft - a Request which is being filled with its details and has not been submitted yet
  • Submitted - a Request which has been filled and has been submitted for evaluation
  • Rejected - after submission, if the Request is considered unfit, it is rejected and ends its life.
  • Processing - a Request which has been evaluated and considered ready for processing
  • Closed – After being processed, the request is marked as complete and is closed

Allowed transitions could be represented in the following table:

From/toDraftSubmittedRejectedProcessingClosed
Draft-submit()---
Submitted--reject()approve()-
Rejected-----
Processing----close()
Closed-----

Resulting in the following diagram:

State diagram

TDD approach

We will proceed using a TDD (Test Driven Development) approach. We write some tests for testing the consistence of states and attributes, and also an unfeasible transition throwing an exception:

Firstly, we will create a new unit test with the command

php artisan make:test FSMTest --unit

and define a new test

class FSMTest extends TestCase
{
public function testDraftOncreate()
{
$r = new Request();
$this->assertEquals($r->state,'DRAFT');
}
}

run and fail the test, because the Request class does not exist along with its state attribute.

So, create a Request model using artisan:

php artisan make:model Request

and add a state accessor with mock implementation, returning 'DRAFT' string

class Request extends Model
{
public function getStateAttribute()
{
return 'DRAFT';
}
}

Run test and success. Then, refactor .

Move state string in constant, return the constant

class Request extends Model
{
const DRAFT = 'DRAFT';

public function getStateAttribute()
{
return Request::DRAFT;
}
}

so we can now compare the actual state with the value of the constant

class FSMTest extends TestCase
{
public function testDraftOncreate()
{
$r = new Request();
$this->assertEquals($r->state,Request::DRAFT);
}
}

Run again, success

add test testSubmittedOnSubmit()

public function testSubmittedOnSubmit()
{
$r = new Request();
$r->submit();
$this->assertEquals($r->state,Request::SUBMITTED);
$this->assertNotNull($r->submitted_at);
}

run and fail

create method submit() setting submitted_at to current time

public function submit()
{
$this->submitted_at = Carbon::now();
}

run and fail (state is not consistent with the mock state returned by getStateAttribute() string). 

Now I'll go a bit quicker, doing four steps (that should be done one at a time):

refactor class Request, 

  • declare a state attribute, 
  • set state to DRAFT on constructor 
  • set state to SUBMITTED in submit() method 
  • Return $state value in state accessor
class Request extends Model
{
const DRAFT = 'DRAFT';
const SUBMITTED = 'SUBMITTED';

public function __construct(array $attributes = [])
{
parent::__construct($attributes);
$this->state = Request::DRAFT;
}

public function getStateAttribute()
{
return $this->state;
}

public function submit()
{
$this->submitted_at = Carbon::now();
$this->state = Request::SUBMITTED;
}
}

run and success

Repeat similarly for the other states, the result is:

FSMTest.php

class FSMTest extends TestCase
{
public function testDraftOncreate()
{
$r = new Request();
$this->assertEquals($r->state,Request::DRAFT);
}

public function testSubmittedOnSubmit()
{
$r = new Request();
$r->submit();
$this->assertEquals($r->state,Request::SUBMITTED);
$this->assertNotNull($r->submitted_at);
}

public function testProcessingOnApprove()
{
$r = new Request();
$r->submit();
$r->approve();
$this->assertEquals($r->state,Request::PROCESSING);
$this->assertNotNull($r->approved_at);
}

public function testClosedOnClose()
{
$r = new Request();
$r->submit();
$r->approve();
$r->close();
$this->assertEquals($r->state,Request::CLOSED);
$this->assertNotNull($r->closed_at);
}

public function testRejectedOnReject()
{
$r = new Request();
$r->submit();
$r->reject();
$this->assertEquals($r->state,Request::REJECTED);
$this->assertNotNull($r->rejected_at);
}
}

Request.php

class Request extends Model
{
const DRAFT = 'DRAFT';
const SUBMITTED = 'SUBMITTED';
const PROCESSING = 'PROCESSING';
const CLOSED = 'CLOSED';
const REJECTED = 'REJECTED';

protected $state;

public function __construct(array $attributes = [])
{
parent::__construct($attributes);
$this->state = Request::DRAFT;
}

public function getStateAttribute()
{
return $this->state;
}

public function submit()
{
$this->submitted_at = Carbon::now();
$this->state = Request::SUBMITTED;
}

public function approve()
{
$this->approved_at = Carbon::now();
$this->state = Request::PROCESSING;
}

public function close()
{
$this->closed_at = Carbon::now();
$this->state = Request::CLOSED;
}

public function reject()
{
$this->rejected_at = Carbon::now();
$this->state = Request::REJECTED;
}
}

Lastly, test for wrong transition. For example, try to close a submitted but not yet approved request:

public function testExceptionOnInvalidTransition()
{
$r = new Request();
$r->submit();
$this->expectException(\Exception::class);
$r->close();
}

Run and fail

check transitions, you can close a request only if it is in a PROCESSING state

public function close()
{
if ($this->state === Request::PROCESSING) {
$this->closed_at = Carbon::now();
$this->state = Request::CLOSED;
} else {
throw new \Exception('Not feasible transition');
}
}

Run and success

Note: you must check if requested transition is allowed given the current state, so in every transition method, you have to define a series of if-then-else blocks to embed this logic. This could become very confusing if many states and/or transitions exist.

Conclusion

The described approach is easy to implement, and is suitable for few-states and few-transitions FSMs, but quickly becomes complex to manage when either states or transitions number (or both) increases. For these cases a smarter approach using the State design pattern is preferred. I will describe it in the next post, so stay tuned!

Related posts
Design patterns in PHP: Strategy

This tutorial describes a TDD approach to refactor code to Strategy pattern. Starting from working code and some test, refactoring steps are applied to obtain cleaner code

Laravel: Rendering view as file donwload

More than once I needed to render a laravel view as a downlodable file. This task could be easily accomplished

Create a custom validation rule with string alias

In this tutorial I will show how to define a Laravel validation rule providing a custom alias string to use in custom validation classes.

Design patterns in PHP: Replace Constructors with Creation Methods

This tutorial describes a TDD approach to refactor code to Replace Constructors with Creation Methods pattern. Starting from working code and some test, refactoring steps are applied to obtain cleaner code