This weekend I was poking into my first PHPSpec tests and I found a real scenario that, I bet, you faced at least once if you do BDD/TDD and so on.
I’m talking about testing an object that changes state of another object.
DISCLAIMER
I will not post any real code here, just an easy example that could be understood by anyone that have experience in BDD and PHPSpec (and hopefully even from PHPSpec newcomers)
Let’s say we are building a bank application and we want to implement a Manager
that will transfer money from one account to other.
Start from Spec (in a wrong way … )
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 |
<?php namespace spec; use PhpSpec\ObjectBehavior; use Prophecy\Argument; use Prophecy\Prophet; class AccountManagerSpec extends ObjectBehavior { function it_is_initializable() { $this->shouldHaveType('AccountManager'); } public function it_transfer_money_between_accounts() { $from = new Account(120); $to = new Account(0); $this->moneyTransfer($from, $to, 60); // @todo: check for state changes } } |
It’s pretty clear that we need to create, as first step, an Account
class
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 |
<?php class Account { private $balance; public function __construct($initialBalance) { if (!is_integer($initialBalance)) { throw new \Exception("..."); } if ($initialBalance < 0) { throw new \Exception("..."); } $this->balance = $initialBalance; } public function increaseBalance($increaseAmount) { if ($increaseAmount < 0) { throw new \Exception("..."); } $this->balance += $increaseAmount; return $this; } public function decreaseBalance($decreaseAmount) { if ($decreaseAmount < 0) { throw new \Exception("..."); } if ($decreaseAmount > $this->balance) { throw new \Exception("..."); } $this->balance -= $decreaseAmount; return $this; } public function getBalance() { return $this->balance; } } |
First smell: PHPSpec helps us to create classes (and collaborators!!!) in automated fashion when we perform phpspec run
. If we run that command without creating the Account
class we receive an error pointing out that Account
class does not exists. It’s pretty clear why: here we are instantiating a class and we are not taking advantage of doubles: if we had used doubles, PHPSpec would have proposed an automated creation process and no errors would have been shown. Keep that in mind: PHPSpec tries, since first steps, to warn us!
If we now perform phpspec run
our BDD tool will propose to generate AccountManager
class. We let it go and let also create the transfer method. After these steps and after putting some code into this brand new class we obtain
1 2 3 4 5 6 7 8 9 10 11 12 13 14 |
<?php use PathToAccountClass\Account; class AccountManager { public function moneyTransfer(Account $from, Account $to, $amount) { $from->decreaseBalance($amount); $to->increaseBalance($amount); return true; } } |
Returning to spec file
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 |
<?php namespace spec; use PhpSpec\Exception\Example\FailureException; use PhpSpec\ObjectBehavior; use Prophecy\Argument; use Prophecy\Prophet; class AccountManagerSpec extends ObjectBehavior { function it_is_initializable() { $this->shouldHaveType('AccountManager'); } public function it_transfer_money_between_accounts() { $from = new Account(120); $to = new Account(0); $this->transfer($from, $to, 60); if ($from->getBalance() != 60) { throw new FailureException("..."); } if ($to->getBalance() != 60) { throw new FailureException("..."); } } } |
Given that this PHPSpec file is not good, even if the goal is reached, let’s highlight some “critical issues”
- Without using doubles, we are tightly coupled to
Account
implementation. So, a line of code changed intoAccount
could lead to test failure. If does not sound like a smell to you, keep reading. - The responsibility (and the actual implementation) of decreasing/increasing balance of account, is canned inside
Account
, and there should be. Thus the “changing state” (increasing/decreasing balance) is up toAccount
class and we should not worry about internal state ofAccount
itself in this spec. - If you are convinced of the previous point, it is easy to rethink about responsibility (behavior) of
AccountManager
: it will not be seen anymore like an “account state changer” but more like a coordinator; this class only knows how to perform operations (decrease from oneAccount
and increase the other of the same amount of money).
Third point will lead us to AccountManagerSpec
changes
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 |
<?php namespace spec; use PhpSpec\ObjectBehavior; use Prophecy\Argument; use Prophecy\Prophet; class AccountManagerSpec extends ObjectBehavior { function it_is_initializable() { $this->shouldHaveType('AccountManager'); } public function it_transfer_money_between_accounts() { $prophet = new Prophet(); $from = $prophet->prophesize('Account'); $from->willBeConstructedWith([120]); $to = $prophet->prophesize('Account'); $to->willBeConstructedWith([0]); $from->decreaseBalance(60)->shouldBeCalled(1); $to->increaseBalance(60)->shouldBeCalled(1); $this->moneyTransfer($from, $to, 60); } } |
What have we tested here? If you look at code snippet, you can easily find out that no Account
state is checked but we are “trusting” somehow (keep an “icon” open) that decreaseBalance
and increaseBalance
works as expected. So the only thing we tested here is the main behavior of AccountManager
: the interaction between those two accounts. Moreover we are making expectations about how many times a particular method should be called (if method is called more than once, this example will fail) and what arguments are passed to this method invocation.
Is the behavior granted? Absolutely!
Now we need to test Account
operations that change status of the object to fulfill our goals and to do this we need another spec.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 |
<?php namespace spec; use PhpSpec\ObjectBehavior; use Prophecy\Argument; class AccountSpec extends ObjectBehavior { function let() { $this->beConstructedWith(120); } function it_is_initializable() { $this->shouldHaveType('Account'); } function it_returns_balance() { $this->getBalance()->shouldBeEqualTo(120); } function it_raise_execption_if_decrease_more_than_current_balance() { $this->shouldThrow(\Exception::class)->duringDecreaseBalance(121); } function it_raise_exception_if_decrase_amount_is_negative() { $this->shouldThrow(\Exception::class)->duringDecreaseBalance(-1); } function it_decrease_balance() { $this->decreaseBalance(10); $this->getBalance()->shouldBeEqualTo(110); } function it_raise_exception_if_increase_amount_is_negative() { $this->shouldThrow(\Exception::class)->duringIncreaseBalance(-1); } function it_increase_balance() { $this->increaseBalance(10); $this->getBalance()->shouldBeEqualTo(130); } } |
Our initial requirements are now fulfilled: we have tested the correct interaction between accounts in AccountManagerSpec
and that internal state of Account
(s) is always correct.
If you end up with the following question
Could I, however, test the final balance into AccountManagerSpec file?
The answer is NO, you cannot
- As described above, that’s not make sense: a change into
Account
could lead toAccountManager
fail: remember that we are not testing but we are describing a behavior through examples. Ask yourself if a change inAccount
should influence the behavior ofAccountManager
. If yes, probably, you have not much encapsulation. If not, well, you just don’t have to make expectation onAccount
class insideAccountManagerSpec
- PHPSpec simply does not provide you an automatic way for using matchers onto collaborators (doubles): you can only match methods of the class you are spec-ing and returned objects/values. Change your code in order to return an
Account
sounds like a smell. Use external tricks (like performingif
andthrowing exceptions
or using an external assertion library) also sounds like a smell. And even if your code, naturally, returns anAccount
, my suggestion is to create anyway a spec file forAccount
and keep examples (behavior you are describing) separate.
If you want to dig your toes into this example, you can find working code onto my github personal account.
See you next time!
[…] About testing entity state changes (in PHPSPec) por Samuelle Lilli, resulta muy interesante para entender el modo en que PHPSpec nos impone restricciones de diseño que nos llevan a código menos acoplado y de mayor calidad. […]