Mock Objects in PHP – Testing
Introduction
In unit tests, mock objects simulate the behaviour of real objects. They are commonly utilised to offer test isolation, to stand in for objects which do not yet exist, or to allow for the exploratory design of class APIs without requiring actual implementation up front. A good example, where mock objects would be required, is any point where communication is made to the database. Fine, we want to test those methods that depend on communication to the database, we wouldn’t want test data to be stored along with our real data or, as a worst case scenario, risk the loss of our precious data. During the course of this post, I will provide a setup guideline for mockery, and walk you through a pragmatic example of PHPUnit testing using Mockery’s mock objects. Without further ado, let’s get to it.
Mockery and PHPUnit Setup with Composer
Mockery is a simple yet flexible PHP testing framework for use in unit testing with PHPUnit, PHPSpec or any other testing framework.
To install, run this on the terminal
1 2 |
composer require --dev phpunit/phpunit composer require --dev mockery/mockery |
OR
Open your composer.json and add this:
1 2 3 4 |
"require-dev": { "phpunit/phpunit": "4.8.*" "Mockery/Mockery": ">=0.7.2" } |
then run composer update
.
Example Class
Model class houses the method to be tested.
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 |
class Model { public static function destroy($id) { $table = Backbone::getTable(get_called_class()); $dbConn = Backbone::makeDbConn(); try { $query = $dbConn->prepare('DELETE FROM ' . $table . ' WHERE id= ' . $id); $query->execute(); } catch (PDOException $e) { return $e->getMessage(); } finally { $dbConn = NULL; } $check = $query->rowCount(); if ($check) { return $check; } else { throw new RecordNotFoundException(); } } } |
Let’s test the method destroy($id)
which deletes a record with supplied $id
in the database. Apparently, we wouldn’t want it deleted while testing for the functionality of the method. On the first line, a static method called
of class Backbone is called on to get the name of the database table mapped to the class Model. Though you might have thought a mock object of Backbone is required here, mock objects don’t support static methods. So we have to dive into class Backbone to see what method getTable()
getTable()
entails, probably we could find something to be mocked.
Class Backbone is a helper class that contains some of the functions used in the Model 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 |
class Backbone { public static function makeDbConn() { return new DbConn(); } public static function checkForTable($table) { $dbConn = self::makeDbConn(); try { $result = $dbConn->query('SELECT 1 FROM ' . $table . ' LIMIT 1'); } catch (PDOException $e) { return false; } finally { $dbConn = null; } return $result ? true : false; } public static function mapClassToTable($className) { $demarcation = strrpos($className, '\', -1); if (! $demarcation) { $table = strtolower(substr($className, $demarcation + 1)); } else { $table = strtolower($className); } if (! self::checkForTable($table)) { throw new TableDoesNotExistException; } return $table; } public static function getTable($className) { try { $table = self::mapClassToTable($className); } catch (TableDoesNotExistException $e) { return $e->message(); } return $table; } } |
Apparently
depends on getTable()
in the same class and so does mapClassToTable()
depend on mapClassToTable()
. So let’s focus on method checkForTable()
which requires a database connection to perform queries on. Finally we found what could and must be mocked: the class checkForTable()
DbConn
instantiated and returned method
.makeConn()
It is important to know that any mock object expected to be used in a method should be passed in as an argument to the method. In anticipation for that, a parameter should be provided to take care of that in method
. So the refactored version of checkForTable()
looks like this:checkForTable()
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 |
public static function checkForTable($table, $dbConn = NULL) { if (is_null($dbConn)) { $dbConn = self::makeDbConn(); } try { $result = $dbConn->query('SELECT 1 FROM ' . $table . ' LIMIT 1'); } catch (PDOException $e) { return false; } finally { $dbConn = null; } return $result ? true : false; } |
Note: parameter
is set to have a default value of $dbConn
NULL
for the method to work as expected when not being tested since it would be called normally with argument only for
parameter. So in that case, the real database connection could be used rather than the mocked one.$table
Next is to make provision for
parameter everywhere $dbConn
has been called. The refactored version of all other methods are:checkForTable()
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 |
public static function mapClassToTable($className, $dbConn = NULL) { $demarcation = strrpos($className, '\', -1); if (! $demarcation) { $table = strtolower(substr($className, $demarcation + 1)); } else { $table = strtolower($className); } if (is_null($dbConn)) { $dbConn = self::makeDbConn(); } if (! self::checkForTable($table, $dbConn)) { throw new TableDoesNotExistException; } return $table; } public static function getTable($className, $dbConn = NULL) { if (is_null($dbConn)) { $dbConn = self::makeDbConn(); } try { $table = self::mapClassToTable($className, $dbConn); } catch (TableDoesNotExistException $e) { return $e->message(); } return $table; } |
Same goes for this method in Model 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 |
public static function destroy($record, $dbConn = NULL) { $table = Backbone::getTable(get_called_class(), $dbConn); if (is_null($dbConn)) { $dbConn = Backbone::makeDbConn(); } try { $query = $dbConn->prepare('DELETE FROM ' . $table . ' WHERE id= ' . $record); $query->execute(); } catch (PDOException $e) { return $e->getMessage(); } finally { $dbConn = null; } $check = $query->rowCount(); if ($check) { return $check; } else { throw new RecordNotFoundException; } } |
Testing
For effective explanation, I have chosen to use a top-down approach in writing the test. Let’s start by calling the method to be tested;
1 2 3 4 |
public function testDestroy() { $this->assertEquals(1, Model::destroy(7, $dbConnMock)); } |
This requires instance of PDO class for database connection to be stored in $dbConnMock
, so we have;
1 2 3 |
$dbConnMock = Mockery::mock('PDO'); $this->assertEquals(1, Model::destroy(7, $dbConnMock)); |
Tracing the link of method calls starting from
to Backbone::getTable()
to mapClassToTable()
, you would notice that only checkForTable()
connects to the database and that occurs upon call to method checkForTable()
on the PDO connection object. So the query()
mock object we have created needs a method $dbConnMock
to possess that functionality. Let’s make provision for that:query()
1 2 3 4 5 |
$dbConnMock = Mockery::mock('PDO'); $dbConnMock->shouldReceive('query')->with('SELECT 1 FROM model LIMIT 1')->andReturn($statement); $this->assertEquals(1, Model::destroy(7, $dbConnMock)); |
Don’t get freaked out, I will explain.
adds method shouldReceive()
to query()
mock object. $dbConnMock
takes care of argument expected to be passed into query parameter, and with()
specifies what should be the outcome.andReturn()
It’s good to know what a method does to set the accurate expectations for testing purposes. Normally, the query method connects to an SQL database to run the SQL statement argument provided. In this case, the supplied statement checks for the existence of a table; a PDO statement is returned on existence of the table, while false is returned on the contrary. This leads us to creating a mock object of PDO statement and so comes a new line added to the test.
1 2 3 4 5 6 |
$dbConnMock = Mockery::mock('PDO'); $statement = Mockery::mock('PDOStatement'); $dbConnMock->shouldReceive('query')->with('SELECT 1 FROM model LIMIT 1')->andReturn($statement); $this->assertEquals(1, Model::destroy(7, $dbConnMock)); |
Since we have made provision for the database connection used deep inside the long chain of method calls, let’s come back to the actual method we are testing:
.Model::destroy()
Next method
prepare should be added to the prepare()
to handle its call right in method $dbConnMock
. So we have a new line to the test:destroy()
1 2 3 4 5 6 7 |
$dbConnMock = Mockery::mock('PDO'); $statement = Mockery::mock('PDOStatement'); $dbConnMock->shouldReceive('query')->with('SELECT 1 FROM model LIMIT 1')->andReturn($statement); $dbConnMock->shouldReceive('prepare')->with('DELETE FROM model WHERE id= 7')->andReturn($statement); $this->assertEquals(1, Model::destroy(7, $dbConnMock)); |
Next, two methods are called on the returned PDO statement from the database query right inside method destroy()
; appropriate provision needs to be made for the two. And so we have this:
1 2 3 4 5 6 7 8 9 |
$dbConnMock = Mockery::mock('PDO'); $statement = Mockery::mock('PDOStatement'); $dbConnMock->shouldReceive('query')->with('SELECT 1 FROM model LIMIT 1')->andReturn($statement); $dbConnMock->shouldReceive('prepare')->with('DELETE FROM model WHERE id= 7')->andReturn($statement); $statement->shouldReceive('execute'); $statement->shouldReceive('rowCount')->andReturn(1); $this->assertEquals(1, Model::destroy(7, $dbConnMock)); |
And that’s it; a complete test for method
. Notice that destroy()
returns the number of records affected by the SQL statement prepared and executed. So 1 is expected on successful deletion of a record. In view of that, a custom exception rowCount()
RecordNotFoundException
should be thrown upon failure to delete a record because of its nonexistence. So the complete code for that case is:
1 2 3 4 5 6 7 8 9 10 |
$dbConnMock = Mockery::mock('PDO'); $statement = Mockery::mock('PDOStatement’); $dbConnMock->shouldReceive('query')->with('SELECT 1 FROM model LIMIT 1')->andReturn($statement); $dbConnMock->shouldReceive('prepare')->with('DELETE FROM model WHERE id= 7')->andReturn($statement); $statement->shouldReceive('execute'); $statement->shouldReceive('rowCount'); $setExpectedException('RecordNotFoundException'); Model::destroy(7, $dbConnMock); |
These two tests completely test the method destroy()
.
Conclusion
Mock objects are simulated copies of real objects with the capability of possessing the public properties and methods of the real objects. However, mock objects of Mockery can not possess static methods. Amidst several cases of using mock objects, any point where communication has to be made to a database is a practical pointer for mock object usage. Start mocking up your database connection in all your tests to save your precious data, today! For more examples, checkout the tests directory on my github repo.
- Mock Objects in PHP – Testing - November 16, 2015