1. Home
  2. Documentation
  3. Tutorials
  4. MVC Tutorials
  5. Unit Testing A zend-mvc Application

MVC Tutorials

In This Article

Unit Testing a zend-mvc application

A solid unit test suite is essential for ongoing development in large projects,especially those with many people involved. Going back and manually testingevery individual component of an application after every change is impractical.Your unit tests will help alleviate that by automatically testing yourapplication's components and alerting you when something is not working the sameway it was when you wrote your tests.

This tutorial is written in the hopes of showing how to test different parts ofa zend-mvc application. As such, this tutorial will use the application writtenin thegetting started user guide. It is in no way aguide to unit testing in general, but is here only to help overcome the initialhurdles in writing unit tests for zend-mvc applications.

It is recommended to have at least a basic understanding of unit tests,assertions and mocks.

zend-test, which provides testingintegration for zend-mvc, usesPHPUnit; this tutorial willcover using that library for testing your applications.

Installing zend-test

zend-test provides PHPUnitintegration for zend-mvc, including application scaffolding and customassertions. You will need to install it:

$ composer require --dev zendframework/zend-test

The above command will update yourcomposer.json file and perform an updatefor you, which will also setup autoloading rules.

Running the initial tests

Out-of-the-box, the skeleton application provides several tests for the shippedApplication\Controller\IndexController class. Now that you have zend-testinstalled, you can run these:

$ ./vendor/bin/phpunit

PHPUnit invocation on Windows

On Windows, you need to wrap the command in double quotes:

$ "vendor/bin/phpunit"

You should see output similar to the following:

PHPUnit 5.4.6 by Sebastian Bergmann and contributors....                                                                 3 / 3 (100%)Time: 116 ms, Memory: 11.00MBOK (3 tests, 7 assertions)

There might be 2 failing tests if you followed the getting started guide. Thisis because theApplication\IndexController is overridden by theAlbumController. This can be ignored for now.

Now it's time to write our own tests!

Setting up the tests directory

As zend-mvc applications are built from modules that should bestandalone blocks of an application, we don't test the application in it'sentirety, but module by module.

We will demonstrate setting up the minimum requirements to test a module, theAlbum module we wrote in the user guide, which then can be used as a basefor testing any other module.

Start by creating a directory calledtest undermodule/Album/ withthe following subdirectories:

module/    Album/        test/            Controller/

Additionally, add anautoload-dev rule in yourcomposer.json:

"autoload-dev": {    "psr-4": {        "ApplicationTest\\": "module/Application/test/",        "AlbumTest\\": "module/Album/test/"    }}

When done, run:

$ composer dump-autoload

The structure of thetest directory matches exactly with that of the module'ssource files, and it will allow you to keep your tests well-organized and easyto find.

Bootstrapping your tests

Next, edit thephpunit.xml.dist file at the project root; we'll add a newtest suite to it. When done, it should read as follows:

<?xml version="1.0" encoding="UTF-8"?><phpunit colors="true">    <testsuites>        <testsuite name="ZendSkeletonApplication Test Suite">            <directory>./module/Application/test</directory>        </testsuite>        <testsuite name="Album">            <directory>./module/Album/test</directory>        </testsuite>    </testsuites></phpunit>

Now run your new Album test suite from the project root:

$ ./vendor/bin/phpunit --testsuite Album

Windows and PHPUnit

On Windows, don't forget to wrap thephpunit command in double quotes:

$ "vendor/bin/phpunit" --testsuite Album

You should get similar output to the following:

PHPUnit 5.4.6 by Sebastian Bergmann and contributors.Time: 0 seconds, Memory: 1.75MbNo tests executed!

Let's write our first test!

Your first controller test

Testing controllers is never an easy task, but the zend-test component makestesting much less cumbersome.

First, createAlbumControllerTest.php undermodule/Album/test/Controller/with the following contents:

<?phpnamespace AlbumTest\Controller;use Album\Controller\AlbumController;use Zend\Stdlib\ArrayUtils;use Zend\Test\PHPUnit\Controller\AbstractHttpControllerTestCase;class AlbumControllerTest extends AbstractHttpControllerTestCase{    protected $traceError = false;    public function setUp()    {        // The module configuration should still be applicable for tests.        // You can override configuration here with test case specific values,        // such as sample view templates, path stacks, module_listener_options,        // etc.        $configOverrides = [];        $this->setApplicationConfig(ArrayUtils::merge(            // Grabbing the full application configuration:            include __DIR__ . '/../../../../config/application.config.php',            $configOverrides        ));        parent::setUp();    }}

TheAbstractHttpControllerTestCase class we extend here helps us setting upthe application itself, helps with dispatching and other tasks that happenduring a request, and offers methods for asserting request params, responseheaders, redirects, and more. See thezend-testdocumentation for more information.

The principal requirement for any zend-test test case is to set the applicationconfig with thesetApplicationConfig() method. For now, we assume the defaultapplication configuration will be appropriate; however, we can override valueslocally within the test using the$configOverrides variable.

Now, add the following method to theAlbumControllerTest class:

public function testIndexActionCanBeAccessed(){    $this->dispatch('/album');    $this->assertResponseStatusCode(200);    $this->assertModuleName('Album');    $this->assertControllerName(AlbumController::class);    $this->assertControllerClass('AlbumController');    $this->assertMatchedRouteName('album');}

This test case dispatches the/album URL, asserts that the response code is200, and that we ended up in the desired module and controller.

Assert against controller service names

For asserting thecontroller name we are using the controller name wedefined in our routing configuration for the Album module. In our examplethis should be defined on line 16 of themodule.config.php file in the Albummodule.

If you run:

$ ./vendor/bin/phpunit --testsuite Album

again, you should see something like the following:

PHPUnit 5.4.6 by Sebastian Bergmann and contributors..                                                                   1 / 1 (100%)Time: 124 ms, Memory: 11.50MBOK (1 test, 5 assertions)

A successful first test!

A failing test case

We likely don't want to hit the same database during testing as we use for ourweb property. Let's add some configuration to the test case to remove thedatabase configuration. In yourAlbumControllerTest::setUp() method, add thefollowing lines right after the call toparent::setUp();:

$services = $this->getApplicationServiceLocator();$config = $services->get('config');unset($config['db']);$services->setAllowOverride(true);$services->setService('config', $config);$services->setAllowOverride(false);

The above removes the 'db' configuration entirely; we'll be replacing it withsomething else before long.

When we run the tests now:

$ ./vendor/bin/phpunit --testsuite AlbumPHPUnit 5.4.6 by Sebastian Bergmann and contributors.FTime: 0 seconds, Memory: 8.50MbThere was 1 failure:1) AlbumTest\Controller\AlbumControllerTest::testIndexActionCanBeAccessedFailed asserting response code "200", actual status code is "500"{projectPath}/vendor/zendframework/zend-test/src/PHPUnit/Controller/AbstractControllerTestCase.php:{lineNumber}{projectPath}/module/Album/test/AlbumTest/Controller/AlbumControllerTest.php:{lineNumber}FAILURES!Tests: 1, Assertions: 0, Failures: 1.

The failure message doesn't tell us much, apart from that the expected statuscode is not 200, but 500. To get a bit more information when something goeswrong in a test case, we set the protected$traceError member totrue (whichis the default; we set it tofalse to demonstrate this capability). Modify thefollowing line from just above thesetUp method in ourAlbumControllerTest class:

protected $traceError = true;

Running thephpunit command again and we should see some more informationabout what went wrong in our test. You'll get a list of the exceptions raised,along with their messages, the filename, and line number:

1) AlbumTest\Controller\AlbumControllerTest::testIndexActionCanBeAccessedFailed asserting response code "200", actual status code is "500"Exceptions raised:Exception 'Zend\ServiceManager\Exception\ServiceNotCreatedException' with message 'Service with name "Zend\Db\Adapter\AdapterInterface" could not be created. Reason: createDriver expects a "driver" key to be present inside the parameters' in {projectPath}/vendor/zendframework/zend-servicemanager/src/ServiceManager.php:{lineNumber}Exception 'Zend\Db\Adapter\Exception\InvalidArgumentException' with message 'createDriver expects a "driver" key to be present inside the parameters' in {projectPath}/vendor/zendframework/zend-db/src/Adapter/Adapter.php:{lineNumber}

Based on the exception messages, it appears we are unable to create a zend-dbadapter instance, due to missing configuration!

Configuring the service manager for the tests

The error says that the service manager can not create an instance of a databaseadapter for us. The database adapter is indirectly used by ourAlbum\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 theservice manager, and let the code run from there as is. The problem with thisapproach is that we would end up with our test cases actually doing queriesagainst the database. To keep our tests fast, and to reduce the number ofpossible failure points in our tests, this should be avoided.

The second thought would be then to create a mock of the database adapter, andprevent the actual database calls by mocking them out. This is a much betterapproach, but creating the adapter mock is tedious (but no doubt we will have tocreate it at some point).

The best thing to do would be to mock out ourAlbum\Model\AlbumTable classwhich retrieves the list of albums from the database. Remember, we are nowtesting our controller, so we can mock out the actual call tofetchAll andreplace the return values with dummy values. At this point, we are notinterested in howfetchAll() retrieves the albums, but only that it gets calledand that it returns an array of albums; these facts allow us to provide mockinstances. When we testAlbumTable itself, we can write the actual tests forthefetchAll method.

First, let's do some setup.

Add import statements to the top of the test class file for each of theAlbumTable andServiceManager classes:

use Album\Model\AlbumTable;use Zend\ServiceManager\ServiceManager;

Now add the following property to the test class:

protected $albumTable;

Next, we'll create three new methods that we'll invoke during setup:

protected function configureServiceManager(ServiceManager $services){    $services->setAllowOverride(true);    $services->setService('config', $this->updateConfig($services->get('config')));    $services->setService(AlbumTable::class, $this->mockAlbumTable()->reveal());    $services->setAllowOverride(false);}protected function updateConfig($config){    $config['db'] = [];    return $config;}protected function mockAlbumTable(){    $this->albumTable = $this->prophesize(AlbumTable::class);    return $this->albumTable;}

By default, theServiceManager does not allow us to replace existing services.configureServiceManager() calls a special method on the instance to enableoverriding services, and then we inject specific overrides we wish to use.When done, we disable overrides to ensure that if, during dispatch, any codeattempts to override a service, an exception will be raised.

The last method above creates a mock instance of ourAlbumTable usingProphecy, an object mocking frameworkthat's bundled and integrated in PHPUnit. The instance returned byprophesize() is a scaffold object; callingreveal() on it, as done in theconfigureServiceManager() method above, provides the underlying mock objectthat will then be asserted against.

With this in place, we can update oursetUp() method to read as follows:

public function setUp(){    // The module configuration should still be applicable for tests.    // You can override configuration here with test case specific values,    // such as sample view templates, path stacks, module_listener_options,    // etc.    $configOverrides = [];    $this->setApplicationConfig(ArrayUtils::merge(        include __DIR__ . '/../../../../config/application.config.php',        $configOverrides    ));    parent::setUp();    $this->configureServiceManager($this->getApplicationServiceLocator());}

Now update thetestIndexActionCanBeAccessed() method to add a line assertingtheAlbumTable'sfetchAll() method will be called, and return an array:

public function testIndexActionCanBeAccessed(){    $this->albumTable->fetchAll()->willReturn([]);    $this->dispatch('/album');    $this->assertResponseStatusCode(200);    $this->assertModuleName('Album');    $this->assertControllerName(AlbumController::class);    $this->assertControllerClass('AlbumController');    $this->assertMatchedRouteName('album');}

Runningphpunit at this point, we will get the following output as the testsnow pass:

$ ./vendor/bin/phpunit --testsuite AlbumPHPUnit 5.4.6 by Sebastian Bergmann and contributors..                                                                   1 / 1 (100%)Time: 105 ms, Memory: 10.75MBOK (1 test, 5 assertions)

Testing actions with POST

A common scenario with controllers is processing POST data submitted via a form,as we do in theAlbumController::addAction(). Let's write a test for that.

public function testAddActionRedirectsAfterValidPost(){    $this->albumTable        ->saveAlbum(Argument::type(Album::class))        ->shouldBeCalled();    $postData = [        'title'  => 'Led Zeppelin III',        'artist' => 'Led Zeppelin',        'id'     => '',    ];    $this->dispatch('/album/add', 'POST', $postData);    $this->assertResponseStatusCode(302);    $this->assertRedirectTo('/album');}

This test case references two new classes that we need to import; add thefollowing import statements at the top of the class file:

use Album\Model\Album;use Prophecy\Argument;

Prophecy\Argument allows us to perform assertions against the values passed asarguments to mock objects. In this case, we want to assert that we received anAlbum instance. (We could have also done deeper assertions to ensure theAlbum instance contained expected data.)

When we dispatch the application this time, we use the request method POST, andpass data to it. This test case then asserts a 302 response status, andintroduces a new assertion against the location to which the response redirects.

Runningphpunit gives us the following output:

$ ./vendor/bin/phpunit --testsuite AlbumPHPUnit 5.4.6 by Sebastian Bergmann and contributors...                                                                  2 / 2 (100%)Time: 1.49 seconds, Memory: 13.25MBOK (2 tests, 8 assertions)

Testing theeditAction() anddeleteAction() methods can be performedsimilarly; however, when testing theeditAction() method, you will also needto assert against theAlbumTable::getAlbum() method:

$this->albumTable->getAlbum($id)->willReturn(new Album());

Ideally, you should test all the various paths through each method. For example:

  • Test that a non-POST request toaddAction() displays an empty form.
  • Test that a invalid data provided toaddAction() re-displays the form, but with error messages.
  • Test that absence of an identifier in the route parameters when invoking eithereditAction() ordeleteAction() will redirect to the appropriate location.
  • Test that an invalid identifier passed toeditAction() will redirect to the album landing page.
  • Test that non-POST requests toeditAction() anddeleteAction() display forms.

and so on. Doing so will help you understand the paths through your applicationand controllers, as well as ensure that changes in behavior bubble up as testfailures.

Testing model entities

Now that we know how to test our controllers, let us move to an other importantpart of our application: the model entity.

Here we want to test that the initial state of the entity is what we expect itto be, that we can convert the model's parameters to and from an array, and thatit has all the input filters we need.

Create the fileAlbumTest.php inmodule/Album/test/Model directorywith the following contents:

<?phpnamespace AlbumTest\Model;use Album\Model\Album;use PHPUnit\Framework\TestCase;class AlbumTest extends TestCase{    public function testInitialAlbumValuesAreNull()    {        $album = new Album();        $this->assertNull($album->artist, '"artist" should be null by default');        $this->assertNull($album->id, '"id" should be null by default');        $this->assertNull($album->title, '"title" should be null by default');    }    public function testExchangeArraySetsPropertiesCorrectly()    {        $album = new Album();        $data  = [            '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([            'artist' => 'some artist',            'id'     => 123,            'title'  => 'some title',        ]);        $album->exchangeArray([]);        $this->assertNull($album->artist, '"artist" should default to null');        $this->assertNull($album->id, '"id" should default to null');        $this->assertNull($album->title, '"title" should default to null');    }    public function testGetArrayCopyReturnsAnArrayWithPropertyValues()    {        $album = new Album();        $data  = [            '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:

  1. Are all of theAlbum's properties initially set toNULL?
  2. Will theAlbum's properties be set correctly when we callexchangeArray()?
  3. Will a default value ofNULL be used for properties whose keys are not present in the$data array?
  4. Can we get an array copy of our model?
  5. Do all elements have input filters present?

If we runphpunit again, we will get the following output, confirming that ourmodel is indeed correct:

$ ./vendor/bin/phpunit --testsuite AlbumPHPUnit 5.4.6 by Sebastian Bergmann and contributors........                                                             7 / 7 (100%)Time: 186 ms, Memory: 13.75MBOK (7 tests, 24 assertions)

Testing model tables

The final step in this unit testing tutorial for zend-mvc applications iswriting tests for our model tables.

This test assures that we can get a list of albums, or one album by its ID, andthat we can save and delete albums from the database.

To avoid actual interaction with the database itself, we will replace certainparts with mocks.

Create a fileAlbumTableTest.php inmodule/Album/test/Model/ with thefollowing contents:

<?phpnamespace AlbumTest\Model;use Album\Model\AlbumTable;use Album\Model\Album;use PHPUnit\Framework\TestCase;use RuntimeException;use Zend\Db\ResultSet\ResultSetInterface;use Zend\Db\TableGateway\TableGatewayInterface;class AlbumTableTest extends TestCase{    protected function setUp()    {        $this->tableGateway = $this->prophesize(TableGatewayInterface::class);        $this->albumTable = new AlbumTable($this->tableGateway->reveal());    }    public function testFetchAllReturnsAllAlbums()    {        $resultSet = $this->prophesize(ResultSetInterface::class)->reveal();        $this->tableGateway->select()->willReturn($resultSet);        $this->assertSame($resultSet, $this->albumTable->fetchAll());    }}

Since we are testing theAlbumTable here and not theTableGateway class(which has already been tested in zend-db), we only want to make surethat ourAlbumTable class is interacting with theTableGateway class the waythat we expect it to. Above, we're testing to see if thefetchAll() method ofAlbumTable will call theselect() method of the$tableGateway propertywith no parameters. If it does, it should return aResultSet instance. Finally,we expect that this sameResultSet object will be returned to the callingmethod. This test should run fine, so now we can add the rest of the testmethods:

public function testCanDeleteAnAlbumByItsId(){    $this->tableGateway->delete(['id' => 123])->shouldBeCalled();    $this->albumTable->deleteAlbum(123);}public function testSaveAlbumWillInsertNewAlbumsIfTheyDontAlreadyHaveAnId(){    $albumData = [        'artist' => 'The Military Wives',        'title'  => 'In My Dreams'    ];    $album = new Album();    $album->exchangeArray($albumData);    $this->tableGateway->insert($albumData)->shouldBeCalled();    $this->albumTable->saveAlbum($album);}public function testSaveAlbumWillUpdateExistingAlbumsIfTheyAlreadyHaveAnId(){    $albumData = [        'id'     => 123,        'artist' => 'The Military Wives',        'title'  => 'In My Dreams',    ];    $album = new Album();    $album->exchangeArray($albumData);    $resultSet = $this->prophesize(ResultSetInterface::class);    $resultSet->current()->willReturn($album);    $this->tableGateway        ->select(['id' => 123])        ->willReturn($resultSet->reveal());    $this->tableGateway        ->update(            array_filter($albumData, function ($key) {                return in_array($key, ['artist', 'title']);            }, ARRAY_FILTER_USE_KEY),            ['id' => 123]        )->shouldBeCalled();    $this->albumTable->saveAlbum($album);}public function testExceptionIsThrownWhenGettingNonExistentAlbum(){    $resultSet = $this->prophesize(ResultSetInterface::class);    $resultSet->current()->willReturn(null);    $this->tableGateway        ->select(['id' => 123])        ->willReturn($resultSet->reveal());    $this->expectException(RuntimeException::class);    $this->expectExceptionMessage('Could not find row with identifier 123');    $this->albumTable->getAlbum(123);}

These tests are nothing complicated and should be self explanatory. In eachtest, we add assertions to our mock table gateway, and then call and assertagainst methods in ourAlbumTable.

We are testing that:

  1. We can retrieve an individual album by its ID.
  2. We can delete albums.
  3. We can save a new album.
  4. We can update existing albums.
  5. We will encounter an exception if we're trying to retrieve an album that doesn't exist.

Runningphpunit one last time, we get the output as follows:

$ ./vendor/bin/phpunit --testsuite AlbumPHPUnit 5.4.6 by Sebastian Bergmann and contributors..............                                                     13 / 13 (100%)Time: 151 ms, Memory: 14.00MBOK (13 tests, 31 assertions)

Conclusion

In this short tutorial, we gave a few examples how different parts of a zend-mvcapplication can be tested. We covered setting up the environment for testing,how to test controllers and actions, how to approach failing test cases, how toconfigure the service manager, as well as how to test model entities and modeltables.

This tutorial is by no means a definitive guide to writing unit tests, just asmall stepping stone helping you develop applications of higher quality.

Found a mistake or want to contribute to the documentation? Edit this page on GitHub!