Unit Testing a Zend Framework 2 application¶
A solid unit test suite is essential for ongoing development in large projects, especially those with many people involved. Going back and manually testing every individual component of an application after every change is impractical. Your unit tests will help alleviate that by automatically testing your application’s components and alerting you when something is not working the same way it was when you wrote your tests.
This tutorial is written in the hopes of showing how to test different parts of a Zend Framework 2 MVC application. As such, this tutorial will use the application written in the getting started user guide. It is in no way a guide to unit testing in general, but is here only to help overcome the initial hurdles in writing unit tests for ZF2 applications.
It is recommended to have at least a basic understanding of unit tests, assertions and mocks.
As the Zend Framework 2 API uses PHPUnit, so will this tutorial. This tutorial assumes that you already have PHPUnit installed. The version of PHPUnit used should be 3.7.*
Setting up phpunit to use composer’s autoload.php¶
If you used composer to generate an autoload.php
file for you,
as seen in the note on using composer to autoload module files,
then you need to use a phpunit binary installed by composer. You can add
this as a development dependency using composer itself:
$ php composer.phar require --dev phpunit/phpunit
The above command will update your composer.json
file and perform an update
for you, which will also setup autoloading rules.
Setting up the tests directory¶
As Zend Framework 2 applications are built from modules that should be standalone blocks of an application, we don’t test the application in it’s entirety, but module by module.
We will show how to set up the minimum requirements to test a module,
the Album
module we wrote in the user guide, and which then can be
used as a base for testing any other module.
Start by creating a directory called test
in zf2-tutorial\module\Album
with
the following subdirectories:
zf2-tutorial/
/module
/Album
/test
/AlbumTest
/Controller
The structure of the test
directory matches exactly with that of the
module’s source files, and it will allow you to keep your tests
well-organized and easy to find.
Bootstrapping your tests¶
Next, create a file called phpunit.xml
under zf2-tutorial/module/Album/test
:
1 2 3 4 5 6 7 8 9 | <?xml version="1.0" encoding="UTF-8"?>
<phpunit bootstrap="Bootstrap.php" colors="true">
<testsuites>
<testsuite name="zf2tutorial">
<directory>./AlbumTest</directory>
</testsuite>
</testsuites>
</phpunit>
|
And a file called Bootstrap.php
, also under zf2-tutorial/module/Album/test
:
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 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 | <?php
namespace AlbumTest;
use Zend\Loader\AutoloaderFactory;
use Zend\Mvc\Service\ServiceManagerConfig;
use Zend\ServiceManager\ServiceManager;
use RuntimeException;
error_reporting(E_ALL | E_STRICT);
chdir(__DIR__);
/**
* Test bootstrap, for setting up autoloading
*/
class Bootstrap
{
protected static $serviceManager;
public static function init()
{
$zf2ModulePaths = array(dirname(dirname(__DIR__)));
if (($path = static::findParentPath('vendor'))) {
$zf2ModulePaths[] = $path;
}
if (($path = static::findParentPath('module')) !== $zf2ModulePaths[0]) {
$zf2ModulePaths[] = $path;
}
static::initAutoloader();
// use ModuleManager to load this module and it's dependencies
$config = array(
'module_listener_options' => array(
'module_paths' => $zf2ModulePaths,
),
'modules' => array(
'Album'
)
);
$serviceManager = new ServiceManager(new ServiceManagerConfig());
$serviceManager->setService('ApplicationConfig', $config);
$serviceManager->get('ModuleManager')->loadModules();
static::$serviceManager = $serviceManager;
}
public static function chroot()
{
$rootPath = dirname(static::findParentPath('module'));
chdir($rootPath);
}
public static function getServiceManager()
{
return static::$serviceManager;
}
protected static function initAutoloader()
{
$vendorPath = static::findParentPath('vendor');
$zf2Path = getenv('ZF2_PATH');
if (!$zf2Path) {
if (defined('ZF2_PATH')) {
$zf2Path = ZF2_PATH;
} elseif (is_dir($vendorPath . '/ZF2/library')) {
$zf2Path = $vendorPath . '/ZF2/library';
} elseif (is_dir($vendorPath . '/zendframework/zendframework/library')) {
$zf2Path = $vendorPath . '/zendframework/zendframework/library';
}
}
if (!$zf2Path) {
throw new RuntimeException(
'Unable to load ZF2. Run `php composer.phar install` or'
. ' define a ZF2_PATH environment variable.'
);
}
if (file_exists($vendorPath . '/autoload.php')) {
include $vendorPath . '/autoload.php';
}
include $zf2Path . '/Zend/Loader/AutoloaderFactory.php';
AutoloaderFactory::factory(array(
'Zend\Loader\StandardAutoloader' => array(
'autoregister_zf' => true,
'namespaces' => array(
__NAMESPACE__ => __DIR__ . '/' . __NAMESPACE__,
),
),
));
}
protected static function findParentPath($path)
{
$dir = __DIR__;
$previousDir = '.';
while (!is_dir($dir . '/' . $path)) {
$dir = dirname($dir);
if ($previousDir === $dir) {
return false;
}
$previousDir = $dir;
}
return $dir . '/' . $path;
}
}
Bootstrap::init();
Bootstrap::chroot();
|
The contents of this bootstrap file can be daunting at first sight, but all it
really does is ensuring that all the necessary files are autoloadable for our
tests. The most important lines is line 38 on which we say what
modules we want to load for our test. In this case we are only loading the
Album
module as it has no dependencies against other modules.
Now, if you navigate to the zf2-tutorial/module/Album/test/
directory,
and run phpunit
, you should get a similar output to this:
PHPUnit 3.7.13 by Sebastian Bergmann.
Configuration read from /var/www/zf2-tutorial/module/Album/test/phpunit.xml
Time: 0 seconds, Memory: 1.75Mb
No tests executed!
Even though no tests were executed, we at least know that the autoloader found the
ZF2 files, otherwise it would throw a RuntimeException
, defined on line 69 of
our bootstrap file.
Your first controller test¶
Testing controllers is never an easy task, but Zend Framework 2 comes
with the Zend\Test
component which should make testing much less
cumbersome.
First, create AlbumControllerTest.php
under
zf2-tutorial/module/Album/test/AlbumTest/Controller
with
the following contents:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 | <?php
namespace AlbumTest\Controller;
use Zend\Test\PHPUnit\Controller\AbstractHttpControllerTestCase;
class AlbumControllerTest extends AbstractHttpControllerTestCase
{
public function setUp()
{
$this->setApplicationConfig(
include '/var/www/zf2-tutorial/config/application.config.php'
);
parent::setUp();
}
}
|
The AbstractHttpControllerTestCase
class we extend here helps us setting up the
application itself, helps with dispatching and other tasks that happen during a request,
as well offers methods for asserting request params, response headers, redirects and more.
See Zend\Test documentation for more.
One thing that is needed is to set the application config with the setApplicationConfig
method.
Now, add the following function to the AlbumControllerTest
class:
1 2 3 4 5 6 7 8 9 10 | public function testIndexActionCanBeAccessed()
{
$this->dispatch('/album');
$this->assertResponseStatusCode(200);
$this->assertModuleName('Album');
$this->assertControllerName('Album\Controller\Album');
$this->assertControllerClass('AlbumController');
$this->assertMatchedRouteName('album');
}
|
This test case dispatches the /album
URL, asserts that the response code is 200,
and that we ended up in the desired module and controller.
Note
For asserting the controller name we are using the controller name we defined in our
routing configuration for the Album module. In our example this should be defined on line
19 of the module.config.php
file in the Album module.
A failing test case¶
Finally, cd
to zf2-tutorial/module/Album/test/
and run phpunit
. Uh-oh! The test
failed!
PHPUnit 3.7.13 by Sebastian Bergmann.
Configuration read from /var/www/zf2-tutorial/module/Album/test/phpunit.xml
F
Time: 0 seconds, Memory: 8.50Mb
There was 1 failure:
1) AlbumTest\Controller\AlbumControllerTest::testIndexActionCanBeAccessed
Failed asserting response code "200", actual status code is "500"
/var/www/zf2-tutorial/vendor/ZF2/library/Zend/Test/PHPUnit/Controller/AbstractControllerTestCase.php:373
/var/www/zf2-tutorial/module/Album/test/AlbumTest/Controller/AlbumControllerTest.php:22
FAILURES!
Tests: 1, Assertions: 0, Failures: 1.
The failure message doesn’t tell us much, apart from that the expected status code
is not 200, but 500. To get a bit more information when something goes wrong in a
test case, we set the protected $traceError
member to true
. Add the following
just above the setUp
method in our AlbumControllerTest
class:
1 | protected $traceError = true;
|
Running the phpunit
command again and we should see some more information about
what went wrong in our test. The main error message we are interested in should read
something like:
Zend\ServiceManager\Exception\ServiceNotFoundException: Zend\ServiceManager\ServiceManager::get
was unable to fetch or create an instance for Zend\Db\Adapter\Adapter
From this error message it is clear that not all our dependencies are available in the service manager. Let us take a look how can we fix this.
Configuring the service manager for the tests¶
The error says that the service manager can not create an instance of a database adapter
for us. The database adapter is indirectly used by our Album\Model\AlbumTable
to
fetch the list of albums from the database.
The first thought would be to create an instance of an adapter, pass it to the service manager and let the code run from there as is. The problem with this approach is that we would end up with our test cases actually doing queries against the database. To keep our tests fast, and to reduce the number of possible failure points in our tests, this should be avoided.
The second thought would be then to create a mock of the database adapter, and prevent the actual database calls by mocking them out. This is a much better approach, but creating the adapter mock is tedious (but no doubt we will have to create it at one point).
The best thing to do would be to mock out our Album\Model\AlbumTable
class which
retrieves the list of albums from the database. Remember, we are now testing our controller,
so we can mock out the actual call to fetchAll
and replace the return values with
dummy values. At this point, we are not interested in how fetchAll
retrieves the
albums, but only that it gets called and that it returns an array of albums, so that is
why we can get away with this mocking. When we will test AlbumTable
itself,
then we will write the actual tests for the fetchAll
method.
Here is how we can accomplish this, by modifying the testIndexActionCanBeAccessed
test method as follows:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 | public function testIndexActionCanBeAccessed()
{
$albumTableMock = $this->getMockBuilder('Album\Model\AlbumTable')
->disableOriginalConstructor()
->getMock();
$albumTableMock->expects($this->once())
->method('fetchAll')
->will($this->returnValue(array()));
$serviceManager = $this->getApplicationServiceLocator();
$serviceManager->setAllowOverride(true);
$serviceManager->setService('Album\Model\AlbumTable', $albumTableMock);
$this->dispatch('/album');
$this->assertResponseStatusCode(200);
$this->assertModuleName('Album');
$this->assertControllerName('Album\Controller\Album');
$this->assertControllerClass('AlbumController');
$this->assertMatchedRouteName('album');
}
|
By default, the Service Manager does not allow us to replace existing services. As the
Album\Model\AlbumTable
was already set, we are allowing for overrides (line 12), and then
replacing the real instance of the AlbumTable with a mock. The mock is created so that it
will return just an empty array when the fetchAll
method is called. This allows us to
test for what we care about in this test, and that is that by dispatching to the /album
URL we get to the Album module’s AlbumController.
Running the phpunit
command at this point, we will get the following output as the
tests now pass:
PHPUnit 3.7.13 by Sebastian Bergmann.
Configuration read from /var/www/zf2-tutorial/module/Album/test/phpunit.xml
.
Time: 0 seconds, Memory: 9.00Mb
OK (1 test, 6 assertions)
Testing actions with POST¶
One of the most common actions happening in controllers is submitting a form with some POST data. Testing this is surprisingly easy:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 | public function testAddActionRedirectsAfterValidPost()
{
$albumTableMock = $this->getMockBuilder('Album\Model\AlbumTable')
->disableOriginalConstructor()
->getMock();
$albumTableMock->expects($this->once())
->method('saveAlbum')
->will($this->returnValue(null));
$serviceManager = $this->getApplicationServiceLocator();
$serviceManager->setAllowOverride(true);
$serviceManager->setService('Album\Model\AlbumTable', $albumTableMock);
$postData = array(
'title' => 'Led Zeppelin III',
'artist' => 'Led Zeppelin',
'id' => '',
);
$this->dispatch('/album/add', 'POST', $postData);
$this->assertResponseStatusCode(302);
$this->assertRedirectTo('/album/');
}
|
Here we test that when we make a POST request against the /album/add
URL, the
Album\Model\AlbumTable
‘s saveAlbum
will be called and after that we will
be redirected back to the /album
URL.
Running phpunit
gives us the following output:
PHPUnit 3.7.13 by Sebastian Bergmann.
Configuration read from /home/robert/www/zf2-tutorial/module/Album/test/phpunit.xml
..
Time: 0 seconds, Memory: 10.75Mb
OK (2 tests, 9 assertions)
Testing the editAction
and deleteAction
methods can be easily done in a manner similar
as shown for the addAction
.
When testing the editAction you will also need to mock out the getAlbum
method:
1 2 3 | $albumTableMock->expects($this->once())
->method('getAlbum')
->will($this->returnValue(new \Album\Model\Album()));
|
Testing model entities¶
Now that we know how to test our controllers, let us move to an other important part of our application - the model entity.
Here we want to test that the initial state of the entity is what we expect it to be, that we can convert the model’s parameters to and from an array, and that it has all the input filters we need.
Create the file AlbumTest.php
in module/Album/test/AlbumTest/Model
directory
with the following contents:
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 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 | <?php
namespace AlbumTest\Model;
use Album\Model\Album;
use PHPUnit_Framework_TestCase;
class AlbumTest extends PHPUnit_Framework_TestCase
{
public function testAlbumInitialState()
{
$album = new Album();
$this->assertNull(
$album->artist,
'"artist" should initially be null'
);
$this->assertNull(
$album->id,
'"id" should initially be null'
);
$this->assertNull(
$album->title,
'"title" should initially be null'
);
}
public function testExchangeArraySetsPropertiesCorrectly()
{
$album = new Album();
$data = array('artist' => 'some artist',
'id' => 123,
'title' => 'some title');
$album->exchangeArray($data);
$this->assertSame(
$data['artist'],
$album->artist,
'"artist" was not set correctly'
);
$this->assertSame(
$data['id'],
$album->id,
'"id" was not set correctly'
);
$this->assertSame(
$data['title'],
$album->title,
'"title" was not set correctly'
);
}
public function testExchangeArraySetsPropertiesToNullIfKeysAreNotPresent()
{
$album = new Album();
$album->exchangeArray(array('artist' => 'some artist',
'id' => 123,
'title' => 'some title'));
$album->exchangeArray(array());
$this->assertNull(
$album->artist, '"artist" should have defaulted to null'
);
$this->assertNull(
$album->id, '"id" should have defaulted to null'
);
$this->assertNull(
$album->title, '"title" should have defaulted to null'
);
}
public function testGetArrayCopyReturnsAnArrayWithPropertyValues()
{
$album = new Album();
$data = array('artist' => 'some artist',
'id' => 123,
'title' => 'some title');
$album->exchangeArray($data);
$copyArray = $album->getArrayCopy();
$this->assertSame(
$data['artist'],
$copyArray['artist'],
'"artist" was not set correctly'
);
$this->assertSame(
$data['id'],
$copyArray['id'],
'"id" was not set correctly'
);
$this->assertSame(
$data['title'],
$copyArray['title'],
'"title" was not set correctly'
);
}
public function testInputFiltersAreSetCorrectly()
{
$album = new Album();
$inputFilter = $album->getInputFilter();
$this->assertSame(3, $inputFilter->count());
$this->assertTrue($inputFilter->has('artist'));
$this->assertTrue($inputFilter->has('id'));
$this->assertTrue($inputFilter->has('title'));
}
}
|
We are testing for 5 things:
- Are all of the Album’s properties initially set to NULL?
- Will the Album’s properties be set correctly when we call
exchangeArray()
? - Will a default value of NULL be used for properties whose keys are not present in the
$data
array? - Can we get an array copy of our model?
- Do all elements have input filters present?
If we run phpunit
again, we will get the following output, confirming that our model is
indeed correct:
PHPUnit 3.7.13 by Sebastian Bergmann.
Configuration read from /var/www/zf2-tutorial/module/Album/test/phpunit.xml
.......
Time: 0 seconds, Memory: 11.00Mb
OK (7 tests, 25 assertions)
Testing model tables¶
The final step in this unit testing tutorial for Zend Framework 2 applications is writing tests for our model tables.
This test assures that we can get a list of albums, or one album by it’s ID, and that we can save and delete albums from the database.
To avoid actual interaction with the database itself, we will replace certain parts with mocks.
Create a file AlbumTableTest.php
in module/Album/test/AlbumTest/Model
with the following contents:
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 | <?php
namespace AlbumTest\Model;
use Album\Model\AlbumTable;
use Album\Model\Album;
use Zend\Db\ResultSet\ResultSet;
use PHPUnit_Framework_TestCase;
class AlbumTableTest extends PHPUnit_Framework_TestCase
{
public function testFetchAllReturnsAllAlbums()
{
$resultSet = new ResultSet();
$mockTableGateway = $this->getMock(
'Zend\Db\TableGateway\TableGateway',
array('select'),
array(),
'',
false
);
$mockTableGateway->expects($this->once())
->method('select')
->with()
->will($this->returnValue($resultSet));
$albumTable = new AlbumTable($mockTableGateway);
$this->assertSame($resultSet, $albumTable->fetchAll());
}
}
|
Since we are testing the AlbumTable
here and not the TableGateway
class (which has already been tested in Zend Framework),
we just want to make sure that our AlbumTable
class is interacting with the TableGateway
class the way that we expect it to. Above, we’re testing to see if the fetchAll()
method
of AlbumTable
will call the select()
method of the $tableGateway
property with
no parameters. If it does, it should return a ResultSet
object. Finally, we expect that
this same ResultSet
object will be returned to the calling method. This test should run
fine, so now we can add the rest of the test methods:
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 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 | public function testCanRetrieveAnAlbumByItsId()
{
$album = new Album();
$album->exchangeArray(array('id' => 123,
'artist' => 'The Military Wives',
'title' => 'In My Dreams'));
$resultSet = new ResultSet();
$resultSet->setArrayObjectPrototype(new Album());
$resultSet->initialize(array($album));
$mockTableGateway = $this->getMock(
'Zend\Db\TableGateway\TableGateway',
array('select'),
array(),
'',
false
);
$mockTableGateway->expects($this->once())
->method('select')
->with(array('id' => 123))
->will($this->returnValue($resultSet));
$albumTable = new AlbumTable($mockTableGateway);
$this->assertSame($album, $albumTable->getAlbum(123));
}
public function testCanDeleteAnAlbumByItsId()
{
$mockTableGateway = $this->getMock(
'Zend\Db\TableGateway\TableGateway',
array('delete'),
array(),
'',
false
);
$mockTableGateway->expects($this->once())
->method('delete')
->with(array('id' => 123));
$albumTable = new AlbumTable($mockTableGateway);
$albumTable->deleteAlbum(123);
}
public function testSaveAlbumWillInsertNewAlbumsIfTheyDontAlreadyHaveAnId()
{
$albumData = array(
'artist' => 'The Military Wives',
'title' => 'In My Dreams'
);
$album = new Album();
$album->exchangeArray($albumData);
$mockTableGateway = $this->getMock(
'Zend\Db\TableGateway\TableGateway',
array('insert'),
array(),
'',
false
);
$mockTableGateway->expects($this->once())
->method('insert')
->with($albumData);
$albumTable = new AlbumTable($mockTableGateway);
$albumTable->saveAlbum($album);
}
public function testSaveAlbumWillUpdateExistingAlbumsIfTheyAlreadyHaveAnId()
{
$albumData = array(
'id' => 123,
'artist' => 'The Military Wives',
'title' => 'In My Dreams',
);
$album = new Album();
$album->exchangeArray($albumData);
$resultSet = new ResultSet();
$resultSet->setArrayObjectPrototype(new Album());
$resultSet->initialize(array($album));
$mockTableGateway = $this->getMock(
'Zend\Db\TableGateway\TableGateway',
array('select', 'update'),
array(),
'',
false
);
$mockTableGateway->expects($this->once())
->method('select')
->with(array('id' => 123))
->will($this->returnValue($resultSet));
$mockTableGateway->expects($this->once())
->method('update')
->with(
array(
'artist' => 'The Military Wives',
'title' => 'In My Dreams'
),
array('id' => 123)
);
$albumTable = new AlbumTable($mockTableGateway);
$albumTable->saveAlbum($album);
}
public function testExceptionIsThrownWhenGettingNonExistentAlbum()
{
$resultSet = new ResultSet();
$resultSet->setArrayObjectPrototype(new Album());
$resultSet->initialize(array());
$mockTableGateway = $this->getMock(
'Zend\Db\TableGateway\TableGateway',
array('select'),
array(),
'',
false
);
$mockTableGateway->expects($this->once())
->method('select')
->with(array('id' => 123))
->will($this->returnValue($resultSet));
$albumTable = new AlbumTable($mockTableGateway);
try {
$albumTable->getAlbum(123);
}
catch (\Exception $e) {
$this->assertSame('Could not find row 123', $e->getMessage());
return;
}
$this->fail('Expected exception was not thrown');
}
|
These tests are nothing complicated and they should be self explanatory. In each test
we are injecting a mock table gateway into our AlbumTable
and set our expectations
accordingly.
We are testing that:
- We can retrieve an individual album by its ID.
- We can delete albums.
- We can save new album.
- We can update existing albums.
- We will encounter an exception if we’re trying to retrieve an album that doesn’t exist.
Running phpunit
command for one last time, we get the output as follows:
PHPUnit 3.7.13 by Sebastian Bergmann.
Configuration read from /var/www/zf2-tutorial/module/Album/test/phpunit.xml
.............
Time: 0 seconds, Memory: 11.50Mb
OK (13 tests, 34 assertions)
Conclusion¶
In this short tutorial we gave a few examples how different parts of a Zend Framework 2 MVC application can be tested. We covered setting up the environment for testing, how to test controllers and actions, how to approach failing test cases, how to configure the service manager, as well as how to test model entities and model tables.
This tutorial is by no means a definitive guide to writing unit tests, just a small stepping stone helping you develop applications of higher quality.