在一个大型项目的开发过程中特别是牵涉到许多人员参与时,一个可靠的单元测试是必不可少的。对于应用程序每当有所变化后都返回并手动对每个组件进行测试是不切实际的。在你用相同的方法写自己的测试时单元测试可以帮助减轻工作量,它可以自动测试你的应用程序中的组件并且在某些组件不工作时对你发出提醒。
这篇教程希望能够展示如何在ZF2 MVC应用程序中测试不同的部分。同样的,这篇教程将继续使用在快速教程中的应用程序。它不是一般单元测试指南,但是这里只是帮助克服首次在ZF2应用程序中编写单元测试的障碍。
建议读者基本了解单元测试,断言(assertions)和mocks测试。
本教程中,ZF2框架API使用PHPUnit,假设已经安装好了PHPUnit,PHPUnit版本应该是3.7.*
由于ZF2的应用程序是由独立的应用程序模块所构建的,我们不整体的测试应用程序,而是一个模块一个模块的测试。
我们将展示如何构建测试模块的最低要求,我们在快速学习中编写的唱片模块以及任何其它可以作为基本测试的模块。
从在zf2-tutorial\module\Album目录下建立test目录开始,构建以下的目录结构
zf2-tutorial/ /module /Album /test /AlbumTest /Controller
test目录的结构完全匹配模块的源文件,它可以让你保持你的测试井井有条,很容易找到。
接下来在zf2-tutorial/module/Album/test目录下创建一个phpunit.xml文件,代码如下:
<?xml version="1.0" encoding="UTF-8"?> <phpunit bootstrap="Bootstrap.php" colors="true"> <testsuites> <testsuite name="zf2tutorial"> <directory>./AlbumTest</directory> </testsuite> </testsuites> </phpunit>还要创建一个Bootstrap.php文件,同样在 zf2-tutorial/module/Album/test目录下 ,代码如下:
<?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();引导文件的内容初看之下令人怯步,但是所有的代码实际上保证为我们的测试自动调用必要的文件。最重要的一行是第38行,这行指定了我们需要调用并测试的模块。在这个案例中我们只调用了唱片模块,它没有依赖其它的模块。
现在,如果你转到zf2-tutorial/module/Album/test/目录并且运行phpunit,你将得到类似以下的结果
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!
即使没有执行测试,我么至少知道自动调用找到了ZF2的文件,否则它会抛出一个RuntimeException,这是定义在引导文件的第74行。
测试一个控制器永远不是一个简单的任务,但是ZF2框架的Zend\Test可以减少非常多的麻烦。
首先,在zf2-tutorial/module/Album/test/AlbumTest/Controller目录下建立一个IndexControllerTest.php文件,代码如下:
<?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(); } }
我们在这里扩展的AbstractHttpControllerTestCase类帮助我们设置应用程序自身,有助于在发生请求过程中的调度和其它任务,同样提供声明请求参数的方法,响应头,从定向以及更多。更多内容见Zend\Test文档。
有一件需要说明的事情是使用setApplicationConfig方法设置应用程序的配置。
下载,在AlbumControllerTest类中添加以下函数
public function testIndexActionCanBeAccessed() { $this->dispatch('/album'); $this->assertResponseStatusCode(200); $this->assertModuleName('Album'); $this->assertControllerName('Album\Controller\Album'); $this->assertControllerClass('AlbumController'); $this->assertMatchedRouteName('album'); }这个测试用例分派/album URL,断言响应码是200,我们已经完成了需要的模块和控制器。
注意:
为声明控制器名称,我们使用的是我们定义在唱片模块路由配置中的控制器名称。在我们的例子中这定义在唱片模块中module.config.php文件的19行。
最后,进入zf2-tutorial/module/Album/test/并且运行phpunit。呕!测试失败了
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.错误信息没有告诉我们很多,除此之外预期的状态码不是200而是500。为了在测试案例中当某些错误发生时得到多一些的信息,我们设置受保护的成员变量$traceError为true。在AlbumControllerTest类中的setUp方法里添加以下代码:
protected $traceError = true;再次运行phpunit命令,我们可以看到测试错误中更多的信息。我们感兴趣的主要错误信息看上去像这样:
Zend\ServiceManager\Exception\ServiceNotFoundException: Zend\ServiceManager\ServiceManager::get was unable to fetch or create an instance for Zend\Db\Adapter\Adapter
对于这个错误信息,很清楚在服务管理器中没有提供给我们所有的依赖性。让我们来看看如何修复这个错误。
错误信息说明服务管理器不能为我们创建数据库适配器的实例。数据库适配器间接的被我们的Album\Model\AlbumTable所使用从数据库中获得唱片列表。
第一个想法是创建一个适配器实例,把它传递给服务管理器并且让代码在那里运行。这样做的问题是我们结束我们的测试案例实际上是对数据库进行了查询。为了保持我们测试速度快,在我们的测试中尽可能减少错误点的数量,这点应该被避免。
第二个想法是创建一个模拟的数据库适配器,并且阻止真实的数据库调用。这是好得多的方法,但是创建模拟适配器是乏味的(但是无疑我们在某个时刻必须创建它)
最好的方法是模拟出我们的Album\Model\AlbumTable类,它从数据库中检索出唱片列表。记住,我们现在正在测试我们的控制器(Controller),所以我们可以模拟真实的调用fetchAll以及使用虚拟的值来替换返回的值。在这点上,我们对fetchAll如何检索唱片不感兴趣,只对它被调用并且返回一个唱片列表有兴趣,这就是为什么我们可以模拟成功。当我们要测试AlbumTable自身,我们将为fetchAll方法写真实的测试。
这里是我们如何实现这个,按照以下代码修改testIndexActionCanBeAccessed测试方法
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'); }默认情况下,服务管理器不允许我们替换已经存在的服务。象Album\Model\AlbumTable已经设置了,我们允许覆盖(第12行),然后使用模拟器来替换真实的AlbumTable实例。由于模拟器已经被创建所以当调用fetchAll方法时它只返回一个空数组。这允许我们测试我们在测试中所关心的内容,那就是调度/album URL,我们到唱片模块的AlbumController中获得。
运行phpunit命令,我们将获得以下的输出信息并且测试通过了
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)
在控制器(Controller)中最常见的action是提交表单并POST一些数据。测试这个惊人的简单
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', ); $this->dispatch('/album/add', 'POST', $postData); $this->assertResponseStatusCode(302); $this->assertRedirectTo('/album'); }这里我们测试对/album/add URL的请求,Album\Model\AlbumTable的saveAlbum方法将被调用,然后我们将被重定向到/album URL。
运行phpunit得到以下结果
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)测试editAction和deleteAction方法可以和addAction一样容易的做到。
现在我们知道了如何测试控制器(Controller),让我们转移到另一个应用程序重要的部分 - 模型实体。
这里我们要测试我们期望的实体的初始状态,我们可以从数组转换方法的参数,它有我们需要的所有的输入过滤。
在module/Album/test/AlbumTest/Model目录中创建AlbumTest.php文件,并输入以下代码:
<?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')); } }我们测试五件事情:
如果我们再次运行phpunit,我们会得到以下输出,确认我们的模型确实是正确的
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)
Zend Framework 2单元测试教程的最后一步是为我们的模型表写测试
这个测试确保我们能得到一个唱片列表,或者根据ID获得一个唱片,以及我们可以在数据库中保存或者删除唱片。
为避免真实的与数据库交互,我们将使用模拟器代替部分内容
在module/Album/test/AlbumTest/Model目录中建立AlbumTableTest.php文件,并输入以下代码:
<?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()); } }由于我们在这里测试AlbumTable,而不是TableGateway类(这个类已经在Zend Framework里测试过了),我们只要保证我们的AlbumTable类与TableGateway类按照我们期望的那样相互作用。上文中我们测试了AlbumTable的fetchAll()方法是否会无参数的调用$tableGateway的select()方法。如果确实这样运行了,这将返回一个ResultSet对象。最后,我们期望这个同样的ResultSet对象将被调用的方法返回。这个测试将运行的很好,所以现在我们可以添加剩下的测试代码:
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'); }这些测试代码没什么难懂的而且是不接子明的。在每次测试中,我们注入一个模拟表途径到AlbumTable并且设定了我们相应的期望。
我们测试以下内容:
最后一次运行phpunit命令,得到以下的输出:
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)
在这个简短的教程中,我们得到了一些例子,如何测试Zend Framework 2 MVC 应用程度的不同部分。我们隐藏了设置测试环境,如何测试控制器(Controller)和action,如何处理失败的测试案例,如何配置服务管理器,测试模型实体和模型表。
本教程绝非一个明确的编写单元测试指导,只是一个小的敲门砖,帮助您开发更高质量的应用。
未完待续,谢谢......