Юнит тестирование приложений на Zend Framework’е
// PHP

Если вы изучаете PHP больше года, то, наверняка, уже сталкивались с юнит тестированием, и даже писали тесты, ну а в этой статье я приведу лишь “рецепт” для быстрого приготовления тестов на Zend Framework’е.
Установка
Для тестирование приложений на Zend Framework’е используется компонент самого фреймворка Zend_Test, который в свою очередь является лишь потомком PHPUnit’a, и вот его нам надо будет еще установить, детально процесс установки PHPUnit’a описан на домашней странице проекта: Chapter 3. Installing PHPUnit, тут выложу лишь краткий пересказ:
Первый способ заключается в использовании PEAR инсталлера:
pear channel-discover pear.phpunit.de pear install phpunit/PHPUnit
Второй – вручную:
- скачиваем архив с страницы http://pear.phpunit.de/get/ и распаковываем его в папку, которая включена в include_path (см. php.ini)
- переименовываем phpunit.php в phpunit
- заменяем строку @php_bin@ на путь к PHP (например /usr/bin/php)
- закидываем этот файл ко всем бинарникам, и выполняем команду chmod +x phpunit
- в файле PHPUnit/Util/Fileloader.php заменяем строку @php_bin@ на путь к PHP (например /usr/bin/php)
Структура проекта
Приведу пример структуры каталога для проекта созданного с использование Zend_Tool (дабы не заблудиться, структура каталога tests напоминает структуру всего приложения):
project
|-- application
|-- data
|-- docs
|-- library
|-- public
`-- tests
|-- application
| |-- models
| | `-- PageTest.php
| |-- controllers
| | |-- IndexControllerTest.php
| | `-- ErrorControllerTest.php
| |-- ControllerTestCase.php
| `-- bootstrap.php
|-- library
| `-- bootstrap.php
`-- phpunit.xml
Кто забыл – проект создаем следующим образом:
zf create project zf-project
Конфигурационный файл phpunit.xml
Для организации тестирования создадим конфигурационный файл phpunit.xml, phpunit необходимо будет запускать в папке tests:
$~/zf-project/tests/phpunit ./
Описание настроек см. в документации
<?xml version="1.0" encoding="UTF-8" ?>
<phpunit bootstrap="./application/bootstrap.php"
colors="true"
convertErrorsToExceptions="true"
convertNoticesToExceptions="true"
convertWarningsToExceptions="true"
stopOnFailure="true"
syntaxCheck="true">
<!-- запускаем все тесты из корневой директории -->
<testsuite name="Main Test Suite">
<directory>./</directory>
</testsuite>
<filter>
<!-- не смотрим на следующие директории -->
<blacklist>
<directory suffix=".php">/usr/share/php</directory>
<directory suffix=".php">../tests</directory>
</blacklist>
<!-- смотрим лишь на следующие директории -->
<whitelist>
<directory suffix=".php">../application</directory>
<directory suffix=".php">../library</directory>
<exclude>
<directory suffix=".phtml">../application</directory>
<file>../application/Bootstrap.php</file>
</exclude>
</whitelist>
</filter>
<logging>
<!-- логирование и создание отчета -->
<log type="coverage-html" target="./report" charset="UTF-8" yui="true" highlight="true" lowUpperBound="35" highLowerBound="70"/>
</logging>
</phpunit>
Инициализация в файле bootstrap.php
В файле phpunit.xml указан bootstrap файл, он отвечает за загрузку нашего приложени, и лишь немногим отличается от привычного index.php:
<?php
// Define path to application directory
defined('APPLICATION_PATH')
|| define('APPLICATION_PATH', realpath(dirname(__FILE__) . '/../../application'));
// Define application environment
defined('APPLICATION_ENV')
|| define('APPLICATION_ENV', (getenv('APPLICATION_ENV') ? getenv('APPLICATION_ENV') : 'testing'));
// Ensure library/ is on include_path
set_include_path(implode(PATH_SEPARATOR, array(
realpath(APPLICATION_PATH . '/../library'),
get_include_path(),
)));
/** Zend_Application */
require_once 'Zend/Application.php';
require_once 'ControllerTestCase.php';
ControllerTestCase.php
Класс ControllerTestCase является предком для всех тесткейсов:
<?php
require_once 'Zend/Application.php';
require_once 'Zend/Test/PHPUnit/ControllerTestCase.php';
abstract class ControllerTestCase extends Zend_Test_PHPUnit_ControllerTestCase
{
/**
* @var Zend_Application
*/
protected $_application;
public function setUp()
{
// указываем функцию, которая будет выполнена до запуска тестов
$this->bootstrap = array($this, 'appBootstrap');
parent::setUp();
}
public function appBootstrap()
{
// инициализируем наше приложение
$this->_application = new Zend_Application(
APPLICATION_ENV,
APPLICATION_PATH . '/configs/application.ini'
);
$this->_application->bootstrap();
}
}
Написание Тестов
Первый блин
Стоит начать с минимума – проверим, что у нас тесты запускаются и работают, создадим простой тест:
class IndexControllerTest extends ControllerTestCase
{
public function testTestAction()
{
$this->assertTrue(true);
}
}
Если мы ничего не забыли, то увидем что-то вроде:
PHPUnit 3.4.0beta3 by Sebastian Bergmann. Time: 0 seconds OK (1 tests, 1 assertions) Generating code coverage report, this may take a moment.
Еще чуть-чуть блинов
Теперь немного усложним задачу – будем проверять, туда ли мы попали, для этого будем использовать следующие методы:
- dispatch – указываем URL куда идем
- assertModule – проверяем тот ли модуль, который нам нужен
- assertController – проверям контроллер
- assertAction – проверяем экшен
class IndexControllerTest extends ControllerTestCase
{
public function testIndexAction()
{
$this->dispatch('/');
$this->assertModule('default');
$this->assertController('index');
$this->assertAction('index');
}
}
Тоже самое сделаем для ErrorController’а:
class ErrorControllerTest extends ControllerTestCase
{
public function testErrorURL()
{
$this->dispatch('foo');
$this->assertModule('default');
$this->assertController('error');
$this->assertAction('error');
}
}
Запускаем тесты:
PHPUnit 3.4.0beta3 by Sebastian Bergmann. Time: 0 seconds OK (3 tests, 7 assertions) Generating code coverage report, this may take a moment.
Можно теперь взглянуть на отчет – для этого идем по адресу http://%project%/tests/reports/ и тама мы должны увидеть что-то вроде:

Если вы ничего не увидели – значит – либо директория tests/reports не writable, либо XDebug у вас не установлен.
Проверяем DOM
Теперь создадим новый экшен:
zf create action about index
Добавим функционала для вывода сообщения во view:
public function aboutAction()
{
// action body
$message = $this->_getParam('m');
if ($message) {
$this->view->message = $message;
} else {
$this->view->message = "no message";
}
}
Изменим представление:
<h2 id="message"><?= $this->message ?></h2>
Напишем еще немного тестов:
class IndexControllerTest extends ControllerTestCase
{
// проверяем что данный экшен доступен
public function testAboutAction()
{
$this->dispatch('/index/about');
$this->assertModule('default');
$this->assertController('index');
$this->assertAction('about');
}
// проверяем поведение экшена по умолчанию
public function testAboutNoMessageAction()
{
$this->dispatch('/index/about');
$this->assertModule('default');
$this->assertController('index');
$this->assertAction('about');
$this->assertResponseCode(200);
// на странице должен присутствовать элемент с ID="messages" в количестве 1 штука
$this->assertQueryCount('#message', 1);
// и в нем должен быть текст "no message"
$this->assertQueryContentContains('#message', "no message");
}
// проверяем поведение экшена при входящих параметрах
public function testAboutWithMessageAction()
{
$this->dispatch('/index/about/m/true-lya-lya');
$this->assertModule('default');
$this->assertController('index');
$this->assertAction('about');
$this->assertResponseCode(200);
$this->assertQueryCount('#message', 1);
// текст теперь должен быть "true-lya-lya"
$this->assertQueryContentContains('#message', "true-lya-lya");
}
// альтернативный способ передачи параметров в реквест
// данный тест дублирует предыдущий
public function testAboutWithMessageAltAction()
{
$this->getRequest()
->setParams(array("m" => "true-lya-lya"))
->setMethod('GET');
$this->dispatch('/index/about/');
$this->assertModule('default');
$this->assertController('index');
$this->assertAction('about');
$this->assertResponseCode(200);
$this->assertQueryCount('#message', 1);
$this->assertQueryContentContains('#message', "true-lya-lya");
}
Описание assert’ов по DOM’у можно найти в документации:
Тестирование аутентификации пользователей
Для начала нам понадобиться таки написать простую систему аутентификации, создадим новый контроллер Login:
<?php
/**
* PagesController for default module
*
* @category Application
* @package Default
*/
class LoginController extends Zend_Controller_Action
{
public function indexAction()
{
$this->view->login = false;
// проверка не залогинен ли пользователь
if (!Zend_Auth::getInstance()->getIdentity()) {
// берем форму логина и закидываем во view
$form = $this->_getLoginForm();
$this->view->form = $form;
// если форма была отправлена, мы должны её проверить
if ($this->_request->isPost()) {
$formData = $this->_request->getPost();
// проверяем форму
if ($form->isValid($formData)) {
// проверяем входные данные
$result = $this->_authenticate($form->getValue('realm'), $form->getValue('username'), $form->getValue('password'));
if ($result->isValid()) {
// запомним пользователя на 2 недели
if ($form->getValue('rememberMe')) {
Zend_Session::rememberMe(60*60*24*14);
}
// отправляем на главную
$this->_redirect('/');
} else {
// failure: выводим сообщение о ошибке
$this->view->error = 'Authorization error. Please check login or/and password';
}
} else {
$form->populate($formData);
}
}
} else {
$this->view->login = true;
$this->view->username = Zend_Auth::getInstance()->getIdentity();
}
}
// генерация формы логина
private function _getLoginForm()
{
$form = new Zend_Form();
$form->setMethod('POST');
$form->setName('userLoginForm');
$username = new Zend_Form_Element_Text('username');
$username->setLabel('User name')
->setRequired(true)
->addFilter('StripTags')
->addFilter('StringTrim')
->addValidator('Alnum')
->addValidator('StringLength', false,
array(3,
24));
$password = new Zend_Form_Element_Password('password');
$password->setLabel('Password')
->setRequired(true)
->setValue(null)
->addValidator('StringLength', false,
array(6));
$realm = new Zend_Form_Element_Select('realm');
$realm->setLabel('Role')
->addMultiOptions(array('user'=>'User', 'admin'=>'Admin'))
->setRequired(true)
->setValue('user');
$rememberMe = new Zend_Form_Element_Checkbox('rememberMe');
$rememberMe->setLabel('Remember Me');
$submit = new Zend_Form_Element_Submit('submit');
$submit->setLabel('Login');
$form->addElements(array($realm, $username, $password, $rememberMe, $submit));
return $form;
}
// аутентификация - самая простая - используя Digest Adapter
protected function _authenticate($realm, $login, $password)
{
$authAdapter = new Zend_Auth_Adapter_Digest(APPLICATION_PATH . '/configs/auth', $realm, $login, $password);
$result = $authAdapter->authenticate();
if ($result->isValid()) {
// success: сохраняем роль пользователя в Zend_Auth
Zend_Auth::getInstance()->getStorage()->write($authAdapter->getRealm());
}
return $result;
}
// разлогиниваемся
public function logoutAction()
{
Zend_Auth::getInstance()->clearIdentity();
$this->_redirect('/');
}
}
В комплекте добавим еще и view:
<div id="login">
<?php if ($this->login) :?>
<h2>Username</h2>
You are logged in as <strong class="username"><?php echo $this->username ?></strong>
<?php else : ?>
<h2>Login</h2>
<!-- выводим ошибку -->
<?php if(!empty($this->error)) :?>
<div id="error"><?php echo $this->escape($this->error);?></div>
<?php endif; ?>
<?php echo $this->form; ?>
<?php endif; ?>
</div>
Теперь необходимо написать тесты для этих контроллеров, но начну я с эмуляции авторизации в юнит-тестах – добавлю метод _doLogin в ControllerTestCase:
protected function _doLogin($realm, $login, $password)
{
$authAdapter = new Zend_Auth_Adapter_Digest(APPLICATION_PATH . '/configs/auth', $realm, $login, $password);
$result = $authAdapter->authenticate();
if ($result->isValid()) {
// success: сохраняем роль пользователя в Zend_Auth
Zend_Auth::getInstance()->getStorage()->write($authAdapter->getRealm());
}
}
Внимание! Для тестирования не следует использовать реальные данные, для данного примера лучше использовать либо тестовый файл аутентификации, либо создать mock для класса Zend_Auth
Теперь стоит проверить как мы логинимся/логаутимся:
class LoginControllerTest extends ControllerTestCase
{
// логинимся "правильным" данными
public function testTrueUserLoginAction()
{
// эмулируем отправку формы
$this->getRequest()
->setMethod('POST')
->setPost(array( "realm" => "user",
"username" => "user",
"password" => "123456",
"rememberMe"=>1));
$this->dispatch('/login/');
// аутентификация должна пройти успешно, идентифицироваться мы должны как user
$this->assertEquals(Zend_Auth::getInstance()->getIdentity(), 'user');
// мы должны быть перенаправлены на главную страницу
$this->assertRedirectTo('/');
}
// логинимся "неправильным" данными
public function testFalseUserLoginAction()
{
$this->getRequest()
->setMethod('POST')
->setPost(array( "realm" => "user",
"username" => "user",
"password" => "654321",
"rememberMe"=>0));
$this->dispatch('/');
// ищем в доме элемент с ID="error" и контентом 'Authorization error. Please check login or/and password'
// лучше использовать assertQueryCount
$this->assertQueryContentContains('#error', 'Authorization error. Please check login or/and password');
}
public function testLogoutAction()
{
// логинимся
$this->_doLogin('admin', 'admin', '123456');
// вызываем логаут
$this->dispatch('/login/logout/');
// теперь мы должны быть "забыты" Zend_Auth'ом
$this->assertNull(Zend_Auth::getInstance()->getIdentity());
}
}
Напишем еще один контроллер – Admin – с очень простой логикой – если заходит не админ – то его должно перенаправит на страницу denied:
class AdminController extends Zend_Controller_Action
{
public function indexAction()
{
// проверяем пользователя на принадлежность к админам
if (Zend_Auth::getInstance()->getIdentity() !== 'admin') {
// если нет - то отправляем на страницу ошибки
$this->_forward('denied','error');
}
}
}
Тесты тоже будут простенькими:
class AdminControllerTest extends ControllerTestCase
{
public function testIndexAction()
{
$this->dispatch('/admin/');
$this->assertModule('default');
$this->assertController('error');
$this->assertAction('denied');
}
public function testIndexUnderAdminAction()
{
$this->_doLogin('admin', 'admin', '123456');
$this->dispatch('/admin/');
$this->assertModule('default');
$this->assertController('admin');
$this->assertAction('index');
}
}
Результат:
PHPUnit 3.4.0beta3 by Sebastian Bergmann. Time: 2 seconds OK (13 tests, 39 assertions) Generating code coverage report, this may take a moment.
Скачать
Вы можете скачать данный пример по ссылке ниже, в архиве нет Zend Framework’a, пример работает с версией 1.8.4, версия PHPUnit’a – 3.4.0b:
Ссылки
- Материал к данной статье: Zend Framework and Unit testing
- Скрин-каст: Unit Testing with the Zend Framework with Zend_Test and PHPUnit
- Zend_Test – официальный мануал в скудном состоянии
- An Introduction to the Art of Unit Testing in PHP
- PHPUnit: Testing Zend Framework Controllers ( перевод)
- Setting up your Zend_Test test suites
- Automatic testing of MVC applications created with Zend Framework
- Автоматизированное тестирование Zend Framework приложений
- Топик на форуме zendframework.ru