As you probably know, writing tests is very important part of developing applications. If you don’t have them you don’t have this confidence when making changes and each time you make complex change you might break a lot of parts of application without knowing this. In my opinion in most cases feature/integration tests are the way to go, but in some cases it’s really good to have unit tests. Of course a lot of depends on application. In some cases it’s fine to have no or a few unit tests but in others you will have plenty of them.
In this article we will focus on unit tests using Mockery library. When writing unig tests we have often a lot of class that work together and in most cases we want to focus only on testing single class. Let’s see how this sample class look like below:
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 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 |
<?php namespace App; class Service { /** * @var \App\Entry */ protected $entry; /** * Service constructor. * * @param \App\Entry $entry */ public function __construct(Entry $entry) { $this->entry = $entry; } /** * Create new entry with given name and key. * * @param string $name * @param string $key * * @return bool */ public function create($name, $key) { return $this->entry->create($name, $key); } /** * Create new entry with given name. * * @param string $name * * @return bool */ public function createWithRandomKey($name) { return $this->entry->create($name, $this->randomKey()); } /** * Create new entry with given name using alternative method. * * @param string $name * * @return bool */ public function createWithObject($name) { return $this->entry->createFromObject((object) ['name' => $name, 'key' => $this->randomKey()]); } /** * Generate random key. * * @return string */ protected function randomKey() { return 'ABC' . implode('', array_rand(range(0, 9), 5)); } } |
As you see implementation is really simple. We have 3 public methods (not counting constructor) and we would like if this class does what is expected.
Let’s start with our test class. It will look like this:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 |
<?php namespace Tests\Unit; use App\Service; use PHPUnit\Framework\TestCase; use Mockery; use stdClass; class ServiceTest extends TestCase { // here we will put test methods public function tearDown() { Mockery::close(); } } |
we will now work on writing tests for each method.
Simple arguments matching
Method create is pretty simple. We just pass 2 arguments, and it passes those argument to other service. We don’t have this service implementation here at all (we could of course use interface). Example test could look like this:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 |
/** @test */ public function it_creates_entry_with_with_valid_parameters() { $mock = Mockery::mock('\App\Entry'); $mock->shouldReceive('create')->once()->with('test', 'key') ->andReturn('anything'); $service = new Service($mock); $result = $service->create('test', 'key'); $this->assertSame('anything', $result); } |
So as you see at the beginning we create mock and define what method and what arguments it should receive and finally we create service we want to test passing this mock and run method from service we want to test. Pretty simple.
But how we could like createWithRandomKey method ? It’s not that simple. It creates some random key so we won’t know what will be final key. We don’t know what to put in place of key in below code:
1 2 |
$mock->shouldReceive('create')->once()->with('test', 'key') ->andReturn('anything'); |
Complex argument matching – use Mockery::on
The solution for this is using Mockery::on matcher – where we will pass closure that will test whether argument is really expected
If we take a look at Mockery documentation about this we will find the following example:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 |
<?php $postId = 42; $modelMock = \Mockery::mock('Model'); $modelMock->shouldReceive('save') ->once() ->with(\Mockery::on(function ($argument) use ($postId) { $postIdIsSet = isset($argument['post_id']) && $argument['post_id'] === $postId; $publishedFlagIsSet = isset($argument['published']) && $argument['published'] === 1; $publishedAtIsSet = isset($argument['published_at']); return $postIdIsSet && $publishedFlagIsSet && $publishedAtIsSet; })); $service = new \Service\Post($modelMock); $service->publishPost($postId); \Mockery::close(); |
Let’s try to write something similar for our case.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 |
/** @test */ public function it_creates_entry_with_random_key() { $mock = Mockery::mock('\App\Entry'); $mock->shouldReceive('create')->once()->with('test', Mockery::on(function($key) { return substr($key,0, 3) == 'ABC' && strlen($key) == 8; }))->andReturn('anything'); $service = new Service($mock); $result = $service->createWithRandomKey('test'); $this->assertSame('anything', $result); } |
As you see in with method we have now 2 arguments – test (this is the name we pass later in test) and Mockery::on matcher. We pass into matcher closure where we return true when key starts with ABC and it has 8 characters. Of course we could test here something more – whether it contains 6 digits after ABC but for understanding this article it’s quite enough.
When you will run test, it should pass. No problem, everything similar to example from Mockery documentation so we are good to go with testing the last method, also using of course Mockery::on matcher.
The implementation could look like this:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 |
/** @test */ public function it_creates_entry_with_valid_object() { $mock = Mockery::mock('\App\Entry'); $mock->shouldReceive('createFromObject')->once()->with(Mockery::on(function($object) { return $object instanceof stdClass && $object->name == 'test' && substr($object->key,0, 3) == 'ABC' && strlen($object->key) == 8; }))->andReturn('anything'); $service = new Service($mock); $result = $service->createWithObject('test'); $this->assertSame('anything', $result); } |
Again when you will run this test, everything should be pretty fine.
Mockery::on when failing
Let’s assume the following scenario. Some other developer got access to code and he implemented some additional methods. However by mistake he changed prefix ABC into ABCD in randomKey method (so he added additional letter D by mistake). You can of course make the same change in service code and run tests. Now when tests will be run, result for running ServiceTest::it_creates_entry_with_random_key test will be:
1) Tests\Unit\ServiceTest::it_creates_entry_with_random_key
Mockery\Exception\NoMatchingExpectationException: No matching handler found for Mockery_0__App_Entry::create(‘test’, ‘ABCD34678’). Either the method was unexpected or its arguments matched no expected argument list for this method/usr/share/nginx/html/vendor/mockery/mockery/library/Mockery/ExpectationDirector.php:92
/usr/share/nginx/html/app/Service.php:44
/usr/share/nginx/html/tests/Unit/ServiceTest.php:37
and result for running ServiceTest::it_creates_entry_with_valid_object test will be:
2) Tests\Unit\ServiceTest::it_creates_entry_with_valid_object
Mockery\Exception\NoMatchingExpectationException: No matching handler found for Mockery_0__App_Entry::createFromObject(object(stdClass)). Either the method was unexpected or its arguments matched no expected argument list for this methodObjects: ( array (
‘stdClass’ =>
array (
‘class’ => ‘stdClass’,
‘properties’ =>
array (
),
),
))/usr/share/nginx/html/vendor/mockery/mockery/library/Mockery/ExpectationDirector.php:92
/usr/share/nginx/html/app/Service.php:56
/usr/share/nginx/html/tests/Unit/ServiceTest.php:56
So yes, we have bug in code, our tests failed (that’s good) but how those tests can help us understand what really happened. In fact you don’t have any line of error here. We have lines 37 and 56 but they are lines where we called service in tests, so we really don’t know what happens with our tests that they fail
Using Mockery::on – the right way
As you see Mockery::on is pretty good way when everything is fine. However in case tests start failing, it’s pretty hard to say what’s the problem. You are getting information that test failed but you are not getting any hint what went wrong.
The alternative way is to combine using Mockery::on with PHPUnit assertions. In the closure we pass to Mockery::on we can run PHPUnit assertions and we can just return true – in case all assertions pass then it means everything is fine (so we return true) and in case any assertion fails, then we won’t return true because assertion cause that test fails before. So the better way of testing createWithRandomKey method would be:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 |
/** @test */ public function it_creates_entry_with_random_key_better_method() { $mock = Mockery::mock('\App\Entry'); $mock->shouldReceive('create')->once()->with('test', Mockery::on(function($key) { $this->assertSame('ABC', substr($key,0, 3)); $this->assertSame(8, strlen($key)); return true; }))->andReturn('anything'); $service = new Service($mock); $result = $service->createWithRandomKey('test'); $this->assertSame('anything', $result); } |
So here we make the similar assertions where we had before in our return statement.
In similar way we can now write test for createWithObject method:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 |
/** @test */ public function it_creates_entry_with_valid_object_better_method() { $mock = Mockery::mock('\App\Entry'); $mock->shouldReceive('createFromObject')->once()->with(Mockery::on(function($object) { $this->assertInstanceOf(stdClass::class, $object); $this->assertSame('test', $object->name); $this->assertSame('ABC', substr($object->key,0, 3)); $this->assertSame(8, strlen($object->key)); return true; }))->andReturn('anything'); $service = new Service($mock); $result = $service->createWithObject('test'); $this->assertSame('anything', $result); } |
What those changes gave us? Well, first of all they won’t change anything in case tests pass. We will get everything green. But in case we have any bug in code, when we take a look at tests result it will be much clearer what really happenned:
1) Tests\Unit\ServiceTest::it_creates_entry_with_random_key_better_method
Failed asserting that 9 is identical to 8./usr/share/nginx/html/tests/Unit/ServiceTest.php:68
…
and
2) Tests\Unit\ServiceTest::it_creates_entry_with_valid_object_better_method
Failed asserting that 9 is identical to 8./usr/share/nginx/html/tests/Unit/ServiceTest.php:89
…
So this time we get information that 9 is not the same as 8 (this is assertion for testing length of the whole key) and in both cases we get line (68/89) where we have this particular assertion so we can easily see that the error is with key length and start verifying source doe.
Summary
As you see Mockery::on is great way for testing objects or arguments that you cannot just compare 1 to 1. However it’s much better to use in it PHPUnit assertions and return true than running complex return statement that won’t help you detect error in case you will have bug in your code.