On this page
Extensions for Album Module
Unit Testing a Laminas 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 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 laminas-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 laminas-mvc applications.
It is recommended to have at least a basic understanding of unit tests, assertions and mocks.
laminas-test, which provides testing integration for laminas-mvc, uses PHPUnit; this tutorial will cover using that library for testing your applications.
Installing laminas-test
laminas-test provides PHPUnit integration for laminas-mvc, including application scaffolding and custom assertions. You will need to install it:
$ composer require --dev laminas/laminas-test
This will also install phpunit/phpunit since it is required by laminas-test.
The above command will update your composer.json
file and perform an update
for you, which will also setup autoloading rules.
Running the initial tests
Out-of-the-box, the skeleton application provides several tests for the shipped
Application\Controller\IndexController
class. Now that you have laminas-test
installed, you can run these:
$ ./vendor/bin/phpunit
PHPUnit invocation on Windows Command Shell
On Windows, you need to wrap the command in double quotes:
C:\> "vendor/bin/phpunit"
You should see output similar to the following:
PHPUnit 10.5.13 by Sebastian Bergmann and contributors.
.... 4 / 4 (100%)
Time: 00:00.334, Memory: 16.00 MB
Tests: 4, Assertions: 6, Failures: 0.
There might be 1 failing test if you followed the getting started guide. This
is because the Application\IndexController
is overridden by the
AlbumController
. This can be ignored for now.
Now it's time to write our own tests!
Setting up the tests directory
As laminas-mvc applications are built from modules that should be standalone blocks of an application, we don't test the application in its entirety, but module by module.
We will demonstrate setting up the minimum requirements to test a module, the
Album
module we wrote in the user guide, which then can be used as a base
for testing any other module.
Start by creating a directory called test
under module/Album/
with
the following subdirectories:
module/
Album/
test/
Controller/
Additionally, add an autoload-dev
rule in your composer.json
:
"autoload-dev": {
"psr-4": {
"ApplicationTest\\": "module/Application/test/",
"AlbumTest\\": "module/Album/test/"
}
}
When done, run:
$ composer dump-autoload
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, edit the phpunit.xml.dist
file at the project root; we'll add a new
test suite to it and modify the existing "Laminas MVC Application Test Suite". When done, it should read as follows:
<?xml version="1.0" encoding="UTF-8"?>
<phpunit colors="true">
<testsuites>
<testsuite name="Laminas MVC Application 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 the
phpunit
command in double quotes:$ "vendor/bin/phpunit" --testsuite Album
You should get similar output to the following:
PHPUnit 10.5.13 by Sebastian Bergmann and contributors.
Runtime: PHP 8.3.2
Configuration: <your_local_path>\phpunit.xml.dist
No tests executed!
Let's write our first test!
Your first controller test
Testing controllers is never an easy task, but the laminas-test component makes testing much less cumbersome.
First, create AlbumControllerTest.php
under module/Album/test/Controller/
with the following contents:
<?php
namespace AlbumTest\Controller;
use Album\Controller\AlbumController;
use Laminas\Stdlib\ArrayUtils;
use Laminas\Test\PHPUnit\Controller\AbstractHttpControllerTestCase;
class AlbumControllerTest extends AbstractHttpControllerTestCase
{
protected $traceError = false;
protected function setUp() : void
{
// 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();
}
}
The AbstractHttpControllerTestCase
class we extend here helps us setting up
the application itself, helps with dispatching and other tasks that happen
during a request, and offers methods for asserting request params, response
headers, redirects, and more. See the laminas-test
documentation for more information.
The principal requirement for any laminas-test test case is to set the application
config with the setApplicationConfig()
method. For now, we assume the default
application configuration will be appropriate; however, we can override values
locally within the test using the $configOverrides
variable.
Now, add the following method to the AlbumControllerTest
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 is
200, and that we ended up in the desired module and controller.
Assert against controller service names
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 16 of the
module.config.php
file in the Album module.
If you run:
$ ./vendor/bin/phpunit --testsuite Album
again, you should see something like the following:
PHPUnit 10.5.13 by Sebastian Bergmann and contributors.
Runtime: PHP 8.3.2
Configuration: <your_local_path>\phpunit.xml.dist
. 1 / 1 (100%)
Time: 00:00.210, Memory: 14.00 MB
OK (1 test, 7 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 our
web property. Let's add some configuration to the test case to remove the
database configuration. In your AlbumControllerTest::setUp()
method, add the
following lines right after the call to parent::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 with something else before long.
When we run the tests now:
$ ./vendor/bin/phpunit --testsuite Album
PHPUnit 10.5.13 by Sebastian Bergmann and contributors.
Runtime: PHP 8.3.2
Configuration: <your_local_path>\phpunit.xml.dist
F 1 / 1 (100%)
Time: 00:00.208, Memory: 12.00 MB
There was 1 failure:
1) AlbumTest\Controller\AlbumControllerTest::testIndexActionCanBeAccessed
Failed asserting response code "200", actual status code is "500"
<your_local_path>\vendor\laminas\laminas-test\src\PHPUnit\Controller\AbstractControllerTestCase.php:433
<your_local_path>\module\Album\test\Controller\AlbumControllerTest.php:38
--
There was 1 risky test:
1) AlbumTest\Controller\AlbumControllerTest::testIndexActionCanBeAccessed
This test did not perform any assertions
<your_local_path>\module\Album\test\Controller\AlbumControllerTest.php:35
--
1 test triggered 1 PHP warning:
1) <your_local_path>\vendor\laminas\laminas-db\src\Adapter\AdapterServiceFactory.php:21
Undefined array key "db"
Triggered by:
* AlbumTest\Controller\AlbumControllerTest::testIndexActionCanBeAccessed
<your_local_path>\module\Album\test\Controller\AlbumControllerTest.php:35
FAILURES!
Tests: 1, Assertions: 0, Failures: 1, Warnings: 1, Risky: 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
(which
is the default; we set it to false
to demonstrate this capability).
We also got risky test and warning report. The warning was expected since we removed the db
key from the configuration which is expected by the AdapterServiceFactory
database adapter factory.
And since the test case does not execute beyond the response status code assertion, we get a risk
test indication that the test did not performed any assertions.
Modify the
following line from just above the setUp
method in our AlbumControllerTest
class:
protected $traceError = true;
Running the phpunit
command again and we should see some more information
about what went wrong in our test. You'll get a list of the exceptions raised,
along with their messages, the filename, and line number:
There was 1 failure:
1) AlbumTest\Controller\AlbumControllerTest::testIndexActionCanBeAccessed
Failed asserting response code "200", actual status code is "500"
Exceptions raised:
Exception 'Laminas\ServiceManager\Exception\ServiceNotCreatedException' with message 'Service with name "Laminas\Db\Adapter\AdapterInterface" could not be created. Reason: The supplied
or instantiated driver object does not implement Laminas\Db\Adapter\Driver\DriverInterface' in <your_local_path>\vendor\laminas\laminas-servicemanager\src\ServiceManager.php:649
Exception 'Laminas\Db\Adapter\Exception\InvalidArgumentException' with message 'The supplied or instantiated driver object does not implement Laminas\Db\Adapter\Driver\DriverInterface'
in <your_local_path>\vendor\laminas\laminas-db\src\Adapter\Adapter.php:78
Based on the exception messages, it appears we are unable to create a laminas-db adapter 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 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 some 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; these facts allow us to provide mock
instances. When we test AlbumTable
itself, we can write the actual tests for
the fetchAll
method.
First, let's do some setup.
Add import statements to the top of the test class file for each of the
AlbumTable
and ServiceManager
classes:
use Album\Model\AlbumTable;
use Laminas\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): void
{
$services->setAllowOverride(true);
$services->setService('config', $this->updateConfig($services->get('config')));
$services->setService(AlbumTable::class, $this->mockAlbumTable());
$services->setAllowOverride(false);
}
protected function updateConfig($config)
{
$config['db'] = [];
return $config;
}
protected function mockAlbumTable(): AlbumTable
{
$this->albumTable = $this->createMock(AlbumTable::class);
return $this->albumTable;
}
By default, the ServiceManager
does not allow us to replace existing services.
configureServiceManager()
calls a special method on the instance to enable
overriding services, and then we inject specific overrides we wish to use.
When done, we disable overrides to ensure that if, during dispatch, any code
attempts to override a service, an exception will be raised.
The last method above creates a mock instance of our AlbumTable
. The instance returned by
$this->createMock()
is a mock AlbumTable
object that will then be asserted against.
With this in place, we can update our setUp()
method to read as follows:
protected function setUp() : void
{
// 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 the testIndexActionCanBeAccessed()
method to add a line asserting
the AlbumTable
's fetchAll()
method will be called, and return an array. This is achieved
by configuring the mock AlbumTable
to expect the saveAlbum
method to be called once and to return
an empty array.
public function testIndexActionCanBeAccessed()
{
$this->albumTable->expects($this->once())
->method('fetchAll')
->willReturn([]);
$this->dispatch('/album');
$this->assertResponseStatusCode(200);
$this->assertModuleName('Album');
$this->assertControllerName(AlbumController::class);
$this->assertControllerClass('AlbumController');
$this->assertMatchedRouteName('album');
}
Running phpunit
at this point, we will get the following output as the tests
now pass:
$ ./vendor/bin/phpunit --testsuite Album
PHPUnit 10.5.13 by Sebastian Bergmann and contributors.
Runtime: PHP 8.3.2
Configuration: <your_local_path>\phpunit.xml.dist
. 1 / 1 (100%)
Time: 00:00.219, Memory: 12.00 MB
OK (1 test, 8 assertions)
Testing actions with POST
A common scenario with controllers is processing POST data submitted via a form,
as we do in the AlbumController::addAction()
. Let's write a test for that.
public function testAddActionRedirectAfterValidPost()
{
$this->albumTable->expects($this->once())
->method('saveAlbum')
->with($this->isInstanceOf(Album::class));
$postData = [
'title' => 'Lez Zeppelin III',
'artist' => 'Led Zeppelin',
'id' => '',
];
$this->dispatch('/album/add', 'POST', $postData);
$this->assertResponseStatusCode(302);
$this->assertRedirectTo('/album');
}
This test case references the Album
class that we need to import; add the following import statement
at the top of the class file:
use Album\Model\Album;
For this test case, the AlbumTable
mock is configured to expect the saveAlbum
method to be called once with an argument that must be an instance of Album
.
(We could have also done deeper assertions to ensure the
Album
instance contained expected data.)
When we dispatch the application this time, we use the request method POST, and pass data to it. This test case then asserts a 302 response status, and introduces a new assertion against the location to which the response redirects.
Running phpunit
gives us the following output:
$ ./vendor/bin/phpunit --testsuite Album
PHPUnit 10.5.13 by Sebastian Bergmann and contributors.
Runtime: PHP 8.3.2
Configuration: <your_local_path>\phpunit.xml.dist
.. 2 / 2 (100%)
Time: 00:00.236, Memory: 14.00 MB
OK (2 tests, 11 assertions)
Testing the editAction()
and deleteAction()
methods can be performed
similarly; however, when testing the editAction()
method, you will also need
to assert against the AlbumTable::getAlbum()
method:
$this->albumTable->expects($this->once())->method('getAlbum')->willReturn(new Album());
Ideally, you should test all the various paths through each method. For example:
- Test that a non-POST request to
addAction()
displays an empty form. - Test that a invalid data provided to
addAction()
re-displays the form, but with error messages. - Test that absence of an identifier in the route parameters when invoking
either
editAction()
ordeleteAction()
will redirect to the appropriate location. - Test that an invalid identifier passed to
editAction()
will redirect to the album landing page. - Test that non-POST requests to
editAction()
anddeleteAction()
display forms.
and so on. Doing so will help you understand the paths through your application and controllers, as well as ensure that changes in behavior bubble up as test failures.
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/Model
directory
with the following contents:
<?php
namespace 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:
- Are all of the
Album
's properties initially set toNULL
? - Will the
Album
's properties be set correctly when we callexchangeArray()
? - 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:
$ ./vendor/bin/phpunit --testsuite Album
PHPUnit 10.5.13 by Sebastian Bergmann and contributors.
Runtime: PHP 8.3.2
Configuration: <your_local_path>\phpunit.xml.dist
....... 7 / 7 (100%)
Time: 00:00.319, Memory: 14.00 MB
OK (7 tests, 27 assertions)
Testing model tables
The final step in this unit testing tutorial for laminas-mvc applications is writing tests for our model tables.
This test assures that we can get a list of albums, or one album by its 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/Model/
with the
following contents:
<?php
namespace AlbumTest\Model;
use Album\Model\AlbumTable;
use Laminas\Db\ResultSet\ResultSetInterface;
use Laminas\Db\TableGateway\TableGatewayInterface;
use PHPUnit\Framework\TestCase;
class AlbumTableTest extends TestCase
{
private $tableGateway;
private $albumTable;
protected function setUp(): void
{
$this->tableGateway = $this->createMock(TableGatewayInterface::class);
$this->albumTable = new AlbumTable($this->tableGateway);
}
public function testFetchAllReturnsAllAlbums(): void
{
$resultSet = $this->createMock(ResultSetInterface::class);
$this->tableGateway->expects($this->once())
->method('select')
->willReturn($resultSet);
$this->assertSame($resultSet, $this->albumTable->fetchAll());
}
}
Since we are testing the AlbumTable
here and not the TableGateway
class
(which has already been tested in laminas-db), we only 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
instance. 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:
public function testCanDeleteAnAlbumByItsId(): void
{
$this->tableGateway->expects($this->once())
->method('delete');
$this->albumTable->deleteAlbum(123);
}
public function testSaveAlbumWillInsertNewAlbumsIfTheyDontAlreadyHaveAnId(): void
{
$albumData = [
'artist' => 'The Military Wives',
'title' => 'In My Dreams'
];
$album = new Album();
$album->exchangeArray($albumData);
$this->tableGateway->expects($this->once())
->method('insert');
$this->albumTable->saveAlbum($album);
}
public function testSaveAlbumWillUpdateExistingAlbumsIfTheyAlreadyHaveAnId(): void
{
$albumData = [
'id' => 123,
'artist' => 'The Military Wives',
'title' => 'In My Dreams',
];
$album = new Album();
$album->exchangeArray($albumData);
$resultSet = $this->createMock(ResultSetInterface::class);
$resultSet->expects($this->once())
->method('current')
->willReturn($album);
$this->tableGateway->expects($this->once())
->method('select')
->with(['id' => 123])
->willReturn($resultSet);
$this->tableGateway->expects($this->once())
->method('update')
->with(
array_filter($albumData, function ($key) {
return in_array($key, ['artist', 'title']);
}, ARRAY_FILTER_USE_KEY),
['id' => 123]
);
$this->albumTable->saveAlbum($album);
}
public function testExceptionIsThrownWhenGettingNonExistentAlbum(): void
{
$resultSet = $this->createMock(ResultSetInterface::class);
$resultSet->expects($this->once())
->method('current')
->willReturn(null);
$this->tableGateway->expects($this->once())
->method('select')
->willReturn($resultSet);
$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 each
test, we add assertions to our mock table gateway, and then call and assert
against methods in our AlbumTable
.
We are testing that:
- We can retrieve an individual album by its ID.
- We can delete albums.
- We can save a 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
one last time, we get the output as follows:
$ ./vendor/bin/phpunit --testsuite Album
PHPUnit 10.5.13 by Sebastian Bergmann and contributors.
Runtime: PHP 8.3.2
Configuration: <your_local_path>\phpunit.xml.dist
............ 12 / 12 (100%)
Time: 00:00.326, Memory: 14.00 MB
OK (12 tests, 37 assertions)
Conclusion
In this short tutorial, we gave a few examples how different parts of a laminas-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.