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

第十一天:表单测试

昨天我们用symfony创建了第一个表单。人们可以在Jobeet上发布招聘信息,但是因为时间不够, 没有添加测试。

今天我们将完成这个工作。这个过程中,我们会学到更多关于表单框架的知识。

sidebar

在symfony之外使用表单框架

symfony框架是由许多独立的、可分离元件组成。这意味着,你可以单独使用其中某一部分,而不必使用 整个MVC框架。表单框架就是一个例子,它并不依赖于symfony。通过访问lib/form/lib/widgets/lib/validators/目录,就可以在任何程序中使用它。

另一个可重用元件是路由框架。复制lib/routing/目录到你的项目中,就可以使用了。

下面是symfony平台各元件依赖关系图:

The symfony platform

提交表单

打开jobActionsTest文件,为招聘信息创建和验证功能添加功能测试(functional test)。

在文件的尾部加入下面的代码,访问信息创建页面:

// 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()模拟点击链接。这个方法还可以用来提交表单。你只需要将表单字段值 作为该方法的第二个参数。同真正的浏览器一样,浏览器对象会将表单默认值与提交的值合并。

但是要传送字段值,我们需要知道它们对应的字段名。如果你查看网页源文件或使用 Firefox Web Developer Toolbar”Forms > Display Form Details”功能,你将看到 company的字段名为jobeet_job[company]

note

当PHP遇到input字段使用象jobeet_job[company]这样的名字时,会将自动将它转换为一个 名为jobeet_job的数组。

为了让代码看起来更整洁,我们将格式改为job[%s],添加下面的代码添加到JobeetJobFormconfigure()方法尾部:

// 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()
;

通过传送上传文件的决定路径,浏览器同样可以模拟文件上传。

提交表单后, 我们检测当前执行的是否是create动作。

表单测试器

我们刚才提交的表单应该是合法的。你可以使用表单测试器(form tester)进行测试:

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

表单测试器有许多方法测试表单当前状态,如错误。

如果你在测试中犯了错,测试没有通过,你可以使用第9天用过的with(’response’)->~debug|Debug~()语句。 但你必须深入研究生成的HTML检查错误信息。这不是很方便。所以我们使用表单测试器,它同样提供 debug()方法,输出相关的表单状态和错误信息:

with('form')->debug()

重定向测试

因为表单是合法的,新的招聘信息应该已经发布,用户也应该被重定向(redirected)到show页面:

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

isRedirected()测试页面是否已经重定向,而followRedirect()方法跟踪重定向(到达新页面)。

note

浏览程序类不能自动跟踪重定向,因为你可能在重定向之前内省对象。

Doctrine测试器

最后,我们想测试存储到数据库的招聘信息,并检查is_activated字段是否设置成false, 因为用户还没有发布它。

使用另一个Doctrine测试器(Doctrine tester)很容易完成这个测试。因为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实例。你可以通过 设第3个参数为布尔值(默认为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

在检测提交无效数据(non-valid)的表单行为测试中,我们并没有重新检测整个表单。 只是给特定的内容添加了测试。

你也可以通过测试生成的HTML的方法来检查包含的错误信息,但是在我们的情况中是 不需要的,因为我们没有定制表单布局。

现在,我们需要测试预览页面的管理栏功能。当一个招聘信息还没有激活,你可以对它进行 编辑、删除或发布操作。测试这3个链接,我们需要先建一个招聘信息。这会包含许多复制粘贴。 因为我不喜欢浪费电子树(啥意思?是不是还是造轮子的事),让我们在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)。 你也可以传送一个值数组,这些值将与一些默认值合并。

Forcing the HTTP Method of a link

测试”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 (HTTP Method)~方式请求。 因为浏览器不能理解PUT请求,link_to()辅助函数将这个链接转换为一个带有 JavaScript脚本的表单链接。而测试浏览器不能执行JavaScript,我们需要通过Click() 第三个参数,强制使用PUT方式。此外,link_to()辅助函数还嵌入一个CSRF标记(token), 因为我们第1天已经激活了CSRF保护;_with_csrf选项模拟这个标记(token)。

测试”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()
;

Tests as a SafeGuard

当一个招聘信息发布,你就无法再编辑它。即使”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()
;

但是如果你运行测试,你不会获得期待的结果,因为我们昨天忘记实现这个安全(security)设施。 写测试同时也是发现bug的好方法,因为你需要考虑所有特例(edge cases)。

修复bug非常简单,因为如果招聘信息被激活,我们只需要指向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);
}

这个修复微不足道,但你确定其它所有内容都始终按我们的预期工作吗?你可以打开 浏览器测试访问编辑页面所有可能的组合。但这里有个更简单的方法:运行你测试套件; 如果你已经引入了回归(regression),symfony会立刻告诉你。

Back to the Future in a Test

当一条招聘信息5天内就要过期,或已经过期,用户可以扩展30天有效期,从扩展之日起算。

在(真实)浏览器中测试这个需求并不容易,因为过期日期是招聘信息创建时自动设置为 未来30天。所以,当访问招聘页面时,扩展有效期的链接并不存在。当然,你可以在数据库 中hack过期日期,或者让模板一直显示扩展链接,但这样做很麻烦而且很容易出错。你也许 已经猜到,编写一些测试将可以解决这个问题。

一如既往,我们首先需要给extend方法添加一条新路由:

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

然后,局部模板_admin中更新”Extend”链接:

<!-- 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));
}

同我们期待的一样,当有效期被扩展成功后,JobeetJobextend()方法会返回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()方法使用不同与GETPOST方式,调取URL
  • 动作更新招聘信息后,我们需要使用$job->refresh()重新载入当前对象。
  • 在脚本结尾处,我们使用嵌入的lime对象,直接测试过期日期。

表单安全

Form Serialization Magic!

Doctrine表单非常容易使用,因为它们自动做许多工作。例如将一个表单存如数据库只需要 调用$form->save()

它是如何工作的呢?save()方法主要进行如下几个步骤:

  • 开始事务处理
  • 加工提交的数据(通过调用updateCOLUMNColumn()方法,如果它存在的话)
  • 调用Doctrine对象fromArray()方法更新字段值
  • 保存对象到数据库
  • 提交事务处理

内置安全特性

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保护

第1天的时候,我们用下面的命令行创建frontend程序:

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

--escaping-strategy选项用于激活XSS选项。这意味着默认情况下,模板中所有的变量 都已经被转义。如果你尝试在工作描述中提交一些HTML标记,你会注意到,当显示在页面的时候, 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

Maintenance Tasks

虽然symfony是一个网页框架,但它带有命令行(command line)工具。 你已经使用它在项目和程序中了创建默认的目录结构,也为模型生成了各种各样的文件。 添加一个新的命令(task )非常轻松,因为命令行使用的工具, 都包装在一个框架中。

当用户创建一条招聘信息,他必须激活它并在线发布。否则数据库中将堆满过期的招聘信息。 让我们创建一个命令来移除这些过期数据。这个命令必须有定时运行。

// 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命令定义2个选项:--env--days,它们都有合理的默认值。

运行方式与symfony内建的命令运行方式相似:

$ 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. 明天,我们将为Jobeet创建后台程序。 创建后台界面是很多网站项目必须做的事,Jobeet也不例外。创建一个后台需要很多的工作量, 但是Jobeet不需要. 我们如何在一个小时内完成这个界面?很简单,我们使用symfony的管理生成器框架。 Until then, take care.