Caution: You are browsing the legacy symfony 1.x part of this website.

День 11: Тестирование форм

1.2 / Doctrine

Вчера мы создали нашу первую форму с использованием Symfony. Пользователи теперь имеют возможность разместить новую вакансию на Jobeet, но мы вышли за рамки отпущенного времени до того, как успели добавить немного тестов.

Это то, чем мы займемся сегодня. Попутно мы также изучим фреймворк форм глубже.

sidebar

Использование Фреймворка форм без Symfony

Компоненты Symfony связаны гибко. Это означает, что большинство из них может использоваться отдельно от полного MVC фреймворка. Это касается и фреймворка форм, который не имеет зависимостей от Symfony. Вы можете использовать его в любом PHP приложении, включив в него каталоги lib/form/, lib/widgets/ и lib/validators/.

Другой компонент, допускающий отдельное использование, это фреймворк маршрутизации. Скопируйте каталог lib/routing/ в свой проект, не использующий Symfony, и свободно наслаждайтесь красивыми URL.

Независимые компоненты формируют платформу Symfony:

The symfony platform

Сохранение форм (submitting)

Давайте откроем файл jobActionsTest, чтобы добавить функциональные тесты для создания вакансии и процесса валидации формы.

В конец файла добавьте следующий код, чтобы получить страницу создания вакансии:

// test/functional/frontend/jobActionsTest.php
$browser->info('3 - Post a Job page')->
  info('  3.1 - Submit a Job')->
 
  get('/job/new')->
  with('request')->begin()->
    isParameter('module', 'job')->
    isParameter('action', 'new')->
  end()
;

Мы уже использовали метод click() для эмуляции перехода по ссылкам. Такой же метод click() может быть использован для сохранения формы. Для формы Вы можете задать значения каждого поля для сохранения в качестве второго аргумента метода. Так же, как и настоящий браузер, объект класса sfBrowser объединяет значения по умолчанию с заданными Вами значениями.

Но чтобы задать значения полей, Вы должны знать их имена. Если Вы откроете исходный код страницы, либо воспользуетесь возможностью Firefox Web Developer Toolbar "Forms > Display Form Details", Вы увидите, что имя поля для company на самом деле выглядит как jobeet_job[company].

note

Когда PHP сталкивается с именами вроде jobeet_job[company], он автоматически преобразует их в массив с именем jobeet_job.

Чтобы сделать названия более ясными, давайте добавим формат job[%s] для полей формы при помощи следующего кода в конце метода configure() класса JobeetJobForm:

// lib/form/doctrine/JobeetJobForm.class.php
$this->widgetSchema->setNameFormat('job[%s]');

После этого изменения имя поля company будет выглядеть как job[company] в Вашем браузере. Теперь самое время нажать на кнопку "Preview your job" и передать форме допустимые значения полей:

// test/functional/frontend/jobActionsTest.php
$browser->info('3 - Post a Job page')->
  info('  3.1 - Submit a Job')->
 
  get('/job/new')->
  with('request')->begin()->
    isParameter('module', 'job')->
    isParameter('action', 'new')->
  end()->
 
  click('Preview your job', array('job' => array(
    'company'      => 'Sensio Labs',
    'url'          => 'http://www.sensio.com/',
    'logo'         => sfConfig::get('sf_upload_dir').'/jobs/sensio-labs.gif',
    'position'     => 'Developer',
    'location'     => 'Atlanta, USA',
    'description'  => 'You will work with symfony to develop websites for our customers.',
    'how_to_apply' => 'Send me an email',
    'email'        => '[email protected]',
    'is_public'    => false,
  )))->
 
  with('request')->begin()->
    isParameter('module', 'job')->
    isParameter('action', 'create')->
  end()
;

Объект класса sfBrowser может также проэмулировать загрузку файлов, если Вы укажете абсолютный путь к файлу. После сохранения формы мы проверили, что было запущено действие create.

Тестер форм (form tester)

Форма, которую мы сохранили, должна быть валидна. Вы можете это проверить, используя тестер форм:

with('form')->begin()->
  hasErrors(false)->
end()->

Тестер форм имеет несколько методов для проверки текущего статуса формы, например, наличия ошибок.

Если Вы сделали ошибку в тесте, и он не срабатывает, Вы можете использовать выражение with('response')->debug(), которое мы уже видели в дне 9. Но Вы будете вынуждены погрузиться в сгенерированный HTML, чтобы увидеть сообщения об ошибках. Это неудобно. Тестер форм также предоставляет метод debug(), который отображает статус формы и все связанные с ним сообщения об ошибках:

with('form')->debug()

Тестирование перенаправления (redirect)

Поскольку форма валидна, вакансия должна быть создана и пользователь перенаправлен на страницу show:

isRedirected()->
followRedirect()->
 
with('request')->begin()->
  isParameter('module', 'job')->
  isParameter('action', 'show')->
end()->

Метод isRedirected() проверяет, было ли выполнено перенаправление, а метод followRedirect() осуществляет перенаправление.

note

Класс sfBrowser не выполняет перенаправление автоматически, поскольку Вы можете захотеть проверить состояние объектов перед перенаправлением.

Doctrine-тестер

В конце концов, мы хотим проверить, что вакансия была создана в базе данных и поле is_activated установлено в false, поскольку пользователь еще не опубликовал вакансию.

Это может быть сделано довольно просто при помощи не использовавшегося нами до сих пор Doctrine-тестера. Поскольку Doctrine-тестер по умолчанию не устанавливается, давайте его добавим:

$browser->setTester('doctrine', 'sfTesterDoctrine');

Doctrine-тестер предлагает метод check() для проверки, что один или более объектов в базе данных удовлетворяют критерию, задаваемому в аргументе метода.

with('doctrine')->begin()->
  check('JobeetJob', array(
    'location'     => 'Atlanta, USA',
    'is_activated' => false,
    'is_public'    => false,
  ))->
end()

Критерий может задаваться массивом значений, как в примере выше, или экземпляром объекта Doctrine_Query для более сложных запросов. Вы можете проверять существование объектов, удовлетворяющих критерию, задавая значение Boolean в качестве третьего аргумента (по умолчанию true), или количество совпадающих объектов, задавая целое число.

Тестирование на наличие ошибок валидации

Создание формы вакансии работает как ожидается, если мы сохраняем правильные значения. Давайте добавим тест, чтобы проверить поведение, когда делается попытка сохранения невалидных значений:

$browser->
  info('  3.2 - Submit a Job with invalid values')->
 
  get('/job/new')->
  click('Preview your job', array('job' => array(
    'company'      => 'Sensio Labs',
    'position'     => 'Developer',
    'location'     => 'Atlanta, USA',
    'email'        => 'not.an.email',
  )))->
 
  with('form')->begin()->
    hasErrors(3)->
    isError('description', 'required')->
    isError('how_to_apply', 'required')->
    isError('email', 'invalid')->
  end()
;

Метод hasErrors() может протестировать количество ошибок, если вызывается с целым аргументом. Метод isError() тестирует код ошибки для данного поля.

tip

В тестах, которые мы написали для сохранения невалидных данных, мы не вызываем тесты для всей формы. Мы добавили тесты только для специфических полей.

Вы можете также протестировать сгенерированный HTML, чтобы проверить, что он содержит сообщения об ошибках, но это не является необходимым в нашем случае, поскольку мы не меняли отображение формы.

Теперь нам нужно проверить панель администратора на странице предварительного просмотра вакансии. Пока вакансия еще не активирована, Вы можете отредактировать, удалить или опубликовать ее. Чтобы проверить эти три ссылки, сначала мы должны создать вакансию. Но нам придется скопировать много кода. Поскольку я не люблю засорять код копипастом, давайте добавим метод создания вакансии в класс JobeetTestFunctional:

// lib/test/JobeetTestFunctional.class.php
class JobeetTestFunctional extends sfTestFunctional
{
  public function createJob($values = array())
  {
    return $this->
      get('/job/new')->
      click('Preview your job', array('job' => array_merge(array(
        'company'      => 'Sensio Labs',
        'url'          => 'http://www.sensio.com/',
        'position'     => 'Developer',
        'location'     => 'Atlanta, USA',
        'description'  => 'You will work with symfony to develop websites for our customers.',
        'how_to_apply' => 'Send me an email',
        'email'        => '[email protected]',
        'is_public'    => false,
      ), $values)))->
      followRedirect()
    ;
  }
 
  // ...
}

Метод createJob() создает вакансию, выполняет перенаправление и возвращает объект браузера, чтобы не прерывать цепочку плавающего синтаксиса (fluent interface). Вы также можете передать в метод массив значений, которые будут объединены со значениями по умолчанию перед сохранением.

Установка HTTP метода для ссылки

Тестирование ссылки "Publish" теперь стало намного проще:

$browser->info('  3.3 - On the preview page, you can publish the job')->
  createJob(array('position' => 'FOO1'))->
  click('Publish', array(), array('method' => 'put', '_with_csrf' => true))->
 
  with('doctrine')->begin()->
    check('JobeetJob', array(
      'position'     => 'FOO1',
      'is_activated' => true,
    ))->
  end()
;

Если Вы помните день 10, то ссылка "Publish" была настроена на то, чтобы использовать HTTP метод PUT. Поскольку браузеры не понимают PUT запросов, то помощник (helper) link_to() преобразует ссылку, используя некоторое количество JavaScript. Т.к. тестовый браузер не запускает JavaScript, мы должны установить для метода значение PUT, передав его в качестве третьего параметра метода click(). Более того, помощник link_to() также встраивает CSRF токен поскольку мы разрешили использование CSRF-защиты в первый день; опция _with_csrf эмулирует этот токен.

Тестирование ссылки "Delete" выглядит аналогично:

$browser->info('  3.4 - On the preview page, you can delete the job')->
  createJob(array('position' => 'FOO2'))->
  click('Delete', array(), array('method' => 'delete', '_with_csrf' => true))->
 
  with('doctrine')->begin()->
    check('JobeetJob', array(
      'position' => 'FOO2',
    ), false)->
  end()
;

Тесты как элемент защиты

Когда вакансия опубликована, Вы больше не можете ее редактировать. Даже если ссылка "Edit" более не показывается на странице просмотра вакансии, давайте добавим несколько тестов для проверки этого требования.

Сначала добавим новый аргумент в метод createJob() для разрешения автоматической публикации вакансии и создадим метод getJobByPosition(), возвращающий вакансию по значению позиции:

// lib/test/JobeetTestFunctional.class.php
class JobeetTestFunctional extends sfTestFunctional
{
  public function createJob($values = array(), $publish = false)
  {
    $this->
      get('/job/new')->
      click('Preview your job', array('job' => array_merge(array(
        'company'      => 'Sensio Labs',
        'url'          => 'http://www.sensio.com/',
        'position'     => 'Developer',
        'location'     => 'Atlanta, USA',
        'description'  => 'You will work with symfony to develop websites for our customers.',
        'how_to_apply' => 'Send me an email',
        'email'        => '[email protected]',
        'is_public'    => false,
      ), $values)))->
      followRedirect()
    ;
 
    if ($publish)
    {
      $this->
        click('Publish', array(), array('method' => 'put', '_with_csrf' => true))->
        followRedirect()
      ;
    }
 
    return $this;
  }
 
  public function getJobByPosition($position)
  {
    $q = Doctrine_Query::create()
      ->from('JobeetJob j')
      ->where('j.position = ?', $position);
 
    return $q->fetchOne();
  }
 
  // ...
}

Если вакансия опубликована, страница редактирования должна возвращать код ошибки 404:

$browser->info('  3.5 - When a job is published, it cannot be edited anymore')->
  createJob(array('position' => 'FOO3'), true)->
  get(sprintf('/job/%s/edit', $browser->getJobByPosition('FOO3')->getToken()))->
 
  with('response')->begin()->
    isStatusCode(404)->
  end()
;

Но если Вы запустите тесты, Вы не получите ожидаемых результатов, поскольку мы забыли вчера реализовать защиту в полной мере. Написание тестов - это очень правильный способ найти ошибки в коде, поскольку Вам надо позаботиться обо всех крайних случаях.

Ошибку исправить просто, поскольку нам просто нужно перенаправить пользователя на страницу, соответствующую коду ошибки 404, если вакансия активирована:

// apps/frontend/modules/job/actions/actions.class.php
public function executeEdit(sfWebRequest $request)
{
  $job = $this->getRoute()->getObject();
  $this->forward404If($job->getIsActivated());
 
  $this->form = new JobeetJobForm($job);
}

Исправление оказалось элементарным, но Вы уверены, что все остальное по-прежнему работает, как ожидается? Вы можете открыть свой браузер и начать тестирование всех возможных комбинаций доступа к странице редактирования. Но есть способ лучше: запустить свой набор тестов; если Вы используете регрессионное тестирование, Symfony немедленно Вам сообщит о проблемах.

Назад в будущее при помощи тестов

Когда до истечения срока действия вакансии остается меньше 5 дней, либо если он уже истек, пользователь может продлить этот срок еще на 30 дней, начиная с текущей даты.

Тестирование этого требования в браузере - непростая задача, поскольку дата окончания срока действия автоматически ставится на 30 дней вперед при создании вакансии. Поэтому при открытии страницы вакансий ссылка для продления вакансии отсутствует. Конечно, Вы можете переустановить дату прямо в базе данных или изменить шаблон страницы, чтобы ссылка отображалась всегда, но это утомительно и чревато ошибками. Как Вы уже догадались, написание нескольких тестов поможет нам еще раз.

Как всегда, первым делом мы должны добавить новый маршрут для метода extend:

# apps/frontend/config/routing.yml
job:
  class:   sfDoctrineRouteCollection
  options:
    model:          JobeetJob
    column:         token
    object_actions: { publish: PUT, extend: PUT }
  requirements:
    token: \w+

Теперь обновим код, формирующий ссылку "Extend" в партиале _admin:

<!-- apps/frontend/modules/job/templates/_admin.php -->
<?php if ($job->expiresSoon()): ?>
 - <?php echo link_to('Extend', 'job_extend', $job, array('method' => 'put')) ?> for another <?php echo sfConfig::get('app_active_days') ?> days
<?php endif; ?>

Создадим действие extend в контроллере:

// apps/frontend/modules/job/actions/actions.class.php
public function executeExtend(sfWebRequest $request)
{
  $request->checkCSRFProtection();
 
  $job = $this->getRoute()->getObject();
  $this->forward404Unless($job->extend());
 
  $this->getUser()->setFlash('notice', sprintf('Your job validity has been extended until %s.', date('m/d/Y', strtotime($job->getExpiresAt()))));
 
  $this->redirect($this->generateUrl('job_show_user', $job));
}

Контроллер ожидает, что метод extend() класса JobeetJob возвращает true, если действие вакансии было продлено, и false иначе:

// lib/model/doctrine/JobeetJob.class.php
class JobeetJob extends BaseJobeetJob
{
  public function extend()
  {
    if (!$this->expiresSoon())
    {
      return false;
    }
 
    $this->setExpiresAt(date('Y-m-d', time() + 86400 * sfConfig::get('app_active_days')));
 
    $this->save();
 
    return true;
  }
 
  // ...
}

Напоследок добавим тестовый сценарий:

$browser->info('  3.6 - A job validity cannot be extended before the job expires soon')->
  createJob(array('position' => 'FOO4'), true)->
  call(sprintf('/job/%s/extend', $browser->getJobByPosition('FOO4')->getToken()), 'put', array('_with_csrf' => true))->
  with('response')->begin()->
    isStatusCode(404)->
  end()
;
 
$browser->info('  3.7 - A job validity can be extended when the job expires soon')->
  createJob(array('position' => 'FOO5'), true)
;
 
$job = $browser->getJobByPosition('FOO5');
$job->setExpiresAt(date('Y-m-d'));
$job->save();
 
$browser->
  call(sprintf('/job/%s/extend', $job->getToken()), 'put', array('_with_csrf' => true))->
  with('response')->isRedirected()
;
 
$job->refresh();
$browser->test()->is(
  date('y/m/d', strtotime($job->getExpiresAt())),
  date('y/m/d', time() + 86400 * sfConfig::get('app_active_days'))
);

Тестовый сценарий демонстрирует несколько новых вещей:

  • Метод call() осуществляет запрос по URL, позволяя указать метод, отличный от GET и POST
  • После того, как вакансия была обновлена контроллером в базе данных, нам нужно перезагрузить локальный объект при помощи $job->refresh()
  • И наконец, мы используем встроенный объект lime, чтобы непосредственно протестировать дату истечения срока действия вакансии.

Защита, обеспечиваемая формами

Магия сериализации форм

Doctrine-формы очень просты в использовании, поскольку они автоматически выполняют массу работы. Например, сохранение полей формы в базу данных выполняется простым вызовом метода $form->save().

Но как это работает? Прежде всего, метод save() выполняет следующие шаги:

  • Начинает транзакцию (так как все вложенные Doctrine-формы сохраняются вместе с основной формой)
  • Обрабатывает сохраняемые значения полей (вызывая методы updateCOLUMNColumn(), если они существуют)
  • Вызывает метод fromArray() Doctrine-объекта, чтобы обновить значения его свойств
  • Сохраняет объект в базе данных
  • Подтверждает транзакцию (commit)

Встроенные методы обеспечения безопасности

Метод fromArray() берет массив значений и обновляет соответствующие им значения столбцов. Может ли это создать проблемы с безопасностью? Что если кто-то попытается сохранить значение столбца, для работы с которым он не авторизован? Например, могу ли я принудительно обновить столбец token?

Давайте напишем тест, который проэмулирует сохранение вакансии вместе с полем token:

// test/functional/frontend/jobActionsTest.php
$browser->
  get('/job/new')->
  click('Preview your job', array('job' => array(
    'token' => 'fake_token',
  )))->
 
  with('form')->begin()->
    hasErrors(7)->
    hasGlobalError('extra_fields')->
  end()
;

Когда форма будет сохраняться, Вы должны получить глобальную ошибку extra_fields. Это происходит потому, что по умолчанию форма не позволяет использовать дополнительные поля в качестве сохраняемых значений. Это также объясняет, почему все поля формы должны иметь валидаторы.

tip

Вы также можете попробовать сохранить дополнительные поля при помощи браузера, используя инструменты вроде Firefox Web Developer Toolbar.

Вы можете отключить эту проверку безопасности, установив опцию allow_extra_fields в true:

class MyForm extends sfForm
{
  public function configure()
  {
    // ...
 
    $this->validatorSchema->setOption('allow_extra_fields', true);
  }
}

Тест теперь сработает, но значение поля token будет отброшено при сохранении. Таким образом, Вы по-прежнему не можете нарушить безопасность сохранения формы. Но если Вам действительно нужно это значение, установите опцию filter_extra_fields в false:

$this->validatorSchema->setOption('filter_extra_fields', false);

note

Тесты, написанные в этом разделе предназначены только для демонстрационных целей. Вы теперь можете их удалить из проекта Jobeet, поскольку Вам не нужны тесты для проверки возможностей самой Symfony.

Защита от XSS и CSRF атак

В течение первого дня, мы создали приложение frontend, используя следующую командную строку:

$ php symfony generate:app --escaping-strategy=on --csrf-secret=Unique$ecret frontend

Опция --escaping-strategy активирует защиту против XSS-атак. Это означает, что все переменные, используемые в шаблонах, по умолчанию экранируются (escaped). Если Вы попытаетесь сохранить описание вакансии с HTML тэгами внутри, Вы заметите, что когда Symfony обрабатывает страницу, HTML тэги из описания не воспринимаются как тэги, а интерпретируются как простой текст.

Опция --csrf-secret активирует защиту против CSRF-атак. Когда Вы используете эту опцию, все формы имеют встроенные скрытые поля _csrf_token.

tip

Стратегия экранирования и секретный ключ CSRF могут быть изменены в любое время при помощи редактирования конфигурационного файла apps/frontend/config/settings.yml. Так же, как и в файле databases.yml, настройки указываются для конкретной среды выполнения:

all:
  .settings:
    # Form security secret (CSRF protection)
    csrf_secret: Unique$ecret
 
    # Output escaping settings
    escaping_strategy: on
    escaping_method:   ESC_SPECIALCHARS

Создание задач для поддержки

Несмотря на то, что Symfony - это веб-фреймворк, он включает инструменты, запускаемые из командной строки. Вы уже использовали их для создания структуры каталогов проекта и приложения, а также для генерации различных файлов модели. Добавление новой задачи (task) довольно просто осуществить, поскольку инструменты командной строки Symfony находятся в самом фреймворке.

Когда пользователь создает вакансию, он должен ее активировать, чтобы она стала доступной другим. Но если он этого не сделает, база данных начнет разрастаться за счет неактуальных вакансий. Давайте создадим задачу для удаления таких вакансий из базы данных. Эта задача должна будет регулярно запускаться как задача cron.

// lib/task/JobeetCleanupTask.class.php
class JobeetCleanupTask extends sfBaseTask
{
  protected function configure()
  {
    $this->addOptions(array(
      new sfCommandOption('application', null, sfCommandOption::PARAMETER_REQUIRED, 'The application', 'frontend'),
      new sfCommandOption('env', null, sfCommandOption::PARAMETER_REQUIRED, 'The environement', 'prod'),
      new sfCommandOption('days', null, sfCommandOption::PARAMETER_REQUIRED, '', 90),
    ));
 
    $this->namespace = 'jobeet';
    $this->name = 'cleanup';
    $this->briefDescription = 'Cleanup Jobeet database';
 
    $this->detailedDescription = <<<EOF
The [jobeet:cleanup|INFO] task cleans up the Jobeet database:
 
  [./symfony jobeet:cleanup --env=prod --days=90|INFO]
EOF;
  }
 
  protected function execute($arguments = array(), $options = array())
  {
    $databaseManager = new sfDatabaseManager($this->configuration);
 
    $nb = Doctrine::getTable('JobeetJob')->cleanup($options['days']);
    $this->logSection('doctrine', sprintf('Removed %d stale jobs', $nb));
  }
}

Конфигурация задачи выполняется в методе configure(). Каждая задача должна иметь уникальное сочетание namespace:name, а также может иметь аргументы и опции.

tip

Посмотрите встроенные задачи Symfony в каталоге (lib/task/), чтобы увидеть больше примеров использования.

Задача jobeet:cleanup определяет две опции: --env и --days с некоторыми приемлемыми значениями по умолчанию.

Запуск новой задачи аналогичен запуску любой встроенной задачи:

$ php symfony jobeet:cleanup --days=10 --env=dev

Как всегда, код очистки базы данных сосредоточен в классе JobeetJobTable:

// lib/model/doctrine/JobeetJobTable.class.php
public function cleanup($days)
{
  $q = $this->createQuery('a')
    ->delete()
    ->andWhere('a.is_activated = ?', 0)
    ->andWhere('a.created_at < ?', date('Y-m-d', time() - 86400 * $days));
 
  return $q->execute();
}

note

Задачи Symfony хорошо взаимодействуют со средой выполнения, т.к. возвращают значение, указывающее на успешность завершения задачи. Вы можете явно указать возвращаемое целое число в конце выполнения задачи.

Увидимся завтра

Тестирование - это сердце философии и инструментария Symfony. Сегодня мы снова изучали, как применять инструменты Symfony, чтобы сделать процесс разработки легче, быстрее и, что более важно, надежнее.

Фреймворк форм Symfony - это гораздо больше, чем просто виджеты и валидаторы: он дает Вам простой способ тестирования форм а также уверенность, что формы защищены по умолчанию.

Our tour of great symfony features do not end today. Tomorrow, we will create the backend application for Jobeet. Creating a backend interface is a must for most web projects, and Jobeet is no different. But how will we be able to develop such an interface in just one hour? Simple, we will use the symfony admin generator framework. Until then, take care.

Наше путешествие в удивительный мир Symfony сегодня не заканчивается. Завтра мы создадим бэкэнд-приложение для Jobeet. Бэкэнд-интерфейс - важная часть большинства веб-проектов и Jobeet не является исключением. Но ка мы сможем создать его всего за час? Очень просто - мы будем использовать фреймворк Symfony для генерации админки. А до тех пор - берегите себя.