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

Ngày 11: Test Form

Tóm tắt

Hôm qua chúng ta đã tạo các form với symfony. Bây giờ, người dùng đã có thể gửi một công việc mới lên Jobeet, nhưng chúng ta đã không đủ thời gian để thực hiện test.

Chúng ta sẽ thực hiện việc này vào hôm nay. Trong quá trình thực hiện, chúng ta sẽ học nhiều hơn về form framework.

sidebar

Sử dụng Form Framework không cần symfony

symfony framework được ghép từ các thành phần khác nhau. Điều đó có nghĩa là bạn có thể sử dụng từng thành phần riêng biệt mà không cần sử dụng toàn bộ framework. Bạn có thể dùng form framework độc lập với symfony. Bạn có thể dùng nó trong bất kì ứng dụng PHP nào bằng cách copy các thư mục lib/form/, lib/widgets/, và lib/validators/.

Một thành phần khác có thể dùng lại là routing framework. Copy thư mục lib/routing/ vào project bất kì, và bạn có thể dùng nó để tạo ra các URL theo ý muốn.

Các thành phần trong symfony platform:

The symfony plaform

Submit một Form

Mở file jobActionsTest và thêm functional tests cho quá trình tạo công việc và validation.

Thêm đoạn code sau vào cuối file:

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

Chúng ta đã dùng phương thức click() để giả lập việc click vào một link. Phương thức click() cũng có thể dùng để submit một form. Với một form, bạn có thể cung cấp các giá trị cho mỗi field ở tham số thứ 2 của phương thức. Giống như một trình duyệt thật, trình duyệt sẽ bổ sung các giá trị mặc định cho form khi cần thiết.

Nhưng để cung cấp giá trị cho các field, chúng ta cần biết tên của chúng. Nếu bạn mở source code hay sử dụng Firefox Web Developer Toolbar "Forms > Display Form Details", bạn sẽ thấy rằng tên của field companyjobeet_job[company].

note

Khi PHP gặp một input field có tên là jobeet_job[company], nó sẽ tự động chuyển thành một mảng jobeet_job.

Để dễ nhìn, chúng ta sẽ chuyển thành job[%s] bằng cách thêm đoạn code sau vào cuối phương thức configure() của JobeetJobForm:

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

Sau khi thay đổi, tên của field company sẽ là job[company]. Bây giờ chúng ta thực hiện việc click vào nút "Preview your job" và cung cấp giá trị cho form:

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

Form phải được submit tới action create:

with('request')->begin()->
  isParameter('module', 'job')->
  isParameter('action', 'create')->
end()->

Trình duyệt cũng giả lập việc upload file bằng cách cung cấp đường dẫn tuyệt đối tới file cần upload.

Form Tester

Form chúng ta đã submit cần được valid. Chúng ta có thể kiểm tra điều này bằng cách sử dụng form tester:

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

Form tester có vài phương thức để kiểm tra trạng thái hiện tại của form, như errors...

Nếu bạn có lỗi trong test, và test không pass, bạn có thể sử dụng lệnh with('response')->debug() đã học trong ngày 9 để debug. Nhưng bạn sẽ phải mò trong đống HTML sinh ra để kiểm tra thông báo lỗi. Điều đó không được tiện lợi cho lắm. Vì thế, form tester cũng cung cấp phương thức debug() trả về trạng thái của form và tất cả thông báo lỗi liên quan đến nó:

with('form')->debug()

Redirection Test

Khi form được valid, công việc được tạo và user chuyển sang trang show:

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

isRedirected() kiểm tra xem trang đã được redirect hay chưa và phương thức followRedirect() chuyển đến trang redirect.

Doctrine Tester

Cuối cùng, chúng ta muốn test rằng công việc đã được tạo trong database và kiểm tra rằng cột is_public có giá trị là false khi user chưa published nó.

Điều này có thể thực hiện dễ dàng nhờ một tester khác, Doctrine tester. Mặc định, Doctrine tester không được đăng kí, chúng ta cần thêm nó:

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

Doctrine tester cung cấp phương thức check() để kiểm tra xem một hay nhiều object trong database có match với các tham số được cung cấp.

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

Criteria có thể là một mảng các giá trị như ở trên, hoặc là một instance của Doctrine_Query đối với các câu truy vấn phức tạp. Bạn có thể kiểm tra các objects có match với criteria với tham số thứ 3 là Boolean (mặc định là true), hoặc số các matching objects (kiểu integer).

Test các lỗi

Job form đã tạo công việc đúng như mong đợi khi chúng ta submit các giá trị hợp lệ. Hãy thêm một test để kiểm tra khi chúng ta cung cấp dữ liệu không hợp lệ:

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

Phương thức hasErrors() có thể kiểm tra số lỗi xảy ra. Phương thức isError() kiểm tra error code với từng field.

tip

Trong khi test chúng ta viết các giá trị không hợp lệ, chứ không test lại toàn bộ form. Chúng ta chỉ thêm các test cho các trường cụ thể.

Bạn cũng có thể test mã HTML sinh ra để kiểm tra rằng nó chứa thông báo lỗi, nhưng việc này là không cần thiết do chúng ta không chỉnh sửa form layout.

Bây giờ, chúng ta cần test thanh admin bar ở trang job preview. Khi một công việc chưa được activated, bạn có thể edit, delete, hoặc publish công việc. Để test 3 link này, trước tiên chúng ta cần tạo một công việc. Chúng ta có thể copy & paste code đã có. Để tránh việc lặp lại này, hãy thêm một phương thức tạo công việc trong lớp 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,
        'type'         => 'full-time',
      ), $values)))->
      followRedirect()
    ;
  }
 
  // ...
}

Bắt buộc HTTP Method với một link

Test link "Publish" trở nên đơn giản:

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

Nếu bạn nhớ lại trong ngày 10, link "Publish" đã được cấu hình để gọi với HTTP PUT method. Do trình duyệt không hiểu PUT requests, helper link_to() chuyển link thành một form với một vài JavaScript. Do test browser không thực thi JavaScript, chúng ta cần bắt buộc method là PUT bằng cách cung cấp nó như là tham số thứ 3 của phương thức click(). Thêm vào đó, helper link_to() cũng nhúng một CSRF token do chúng ta đã enable CSRF protection từ ngày 1; _with_csrf option mô phỏng token này.

Test link "Delete" hoàn toàn tương tự:

$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 SafeGuard

Khi một công việc được publish, bạn không thể sửa nó nữa. Link "Edit" sẽ không xuất hiện ở trang preview, ta thêm một vài test cho yêu cầu này.

Đầu tiên, thêm một tham số khác cho phương thức createJob() để tự động publication công việc, và tạo phương thức getJobByPosition() để trả về công việc theo giá trị position cung cấp:

// 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,
        'type'         => 'full-time',
      ), $values)))->
      followRedirect()
    ;
 
    if ($publish)
    {
      $this->click('Publish', array(), array('method' => 'put', '_with_csrf' => true));
    }
 
    return $this;
  }
 
  public function getJobByPosition($position)
  {
    $q = Doctrine_Query::create()
      ->from('JobeetJob j')
      ->where('j.position = ?', $position);
 
    return $q->fetchOne();
  }
  // ...
}

Nếu một công việc được publish, trang edit phải trả về mã trạng thái 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()
;

Nhưng nếu bạn chạy test, bạn sẽ không thu được kết quả như mong đợi, do chúng ta đã quên thực hiện vấn đề này ngày hôm qua. Viết test cũng là một cách tốt để tìm ra các bug, và bạn cần nghĩ tới tất cả các trường hợp có thể.

Sửa lỗi này khá đơn giản, chúng ta cần forward trang edit tới trang 404 khi một công việc đã activated:

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

Cách sửa thật đơn giản, nhưng bạn có chắc rằng mọi thứ sẽ làm việc như mong đợi? Bạn có thể mở trình duyệt và bắt đầu test mọi cách có thể để truy cập trang edit. Nhưng có một cách đơn giản hơn: chạy test suite của bạn;

Chuyển đến tương lai trong một Test

Khi một công việc hết hạn sau ít nhất 5 ngày, user có thể gia hạn cho công việc thêm 30 ngày nữa kể từ ngày hiện tại.

Test yêu cầu này từ trình duyệt không dễ dàng do ngày hết hạn được tự động tạo là sau 30 ngày từ ngày tạo công việc. Vì thế, khi chuyển đến trang công việc, link để extend cho công việc chưa tồn tại. Tất nhiên, bạn có thể sửa lại ngày hết hạn trong database, hoặc chỉnh lại template để hiển thị link này, nhưng việc này thật chán ngắt và dễ sinh ra lỗi. Bạn cũng có thể đoán được, viết một vài test sẽ giúp chúng ta thực hiện dễ dàng.

Trước tiên, chúng ta cần thêm một route mới cho extend:

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

Sau đó, sửa lại link "Extend" trong partial _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; ?>

Sau đó, tạo action 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 extend until %s.', date('m/d/Y', strtotime($job->getExpiresAt()))));
 
  $this->redirect($this->generateUrl('job_show_user', $job));
}

Phương thức extend() của JobeetJob trả về true nếu công việc đã được gia hạn và false trong trường hợp ngược lại:

// 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;
  }
 
  // ...
}

Cuối cùng, thêm một kịch bản test:

$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'))
);

Kịch bản test này có một vài thứ mới:

  • Phương thức call() nhận một URL với một method khác GET hay POST
  • Sau khi công việc đã được update bởi action, chúng ta cần nạp lại local object với $job->reload()
  • Cuối cùng, chúng ta dùng đối tượng lime nhúng trực tiếp để test ngày hết hạn mới.

Forms Security

Form Serialization Magic!

Doctrine forms rất dễ sử dụng do chúng đã tự động thực hiện nhiều công việc. Ví dụ, lưu một form vào database đơn giản là gọi $form->save(). Nó hoạt động như thế nào?

Bình thường, phương thức save() tiến hành qua các bước sau:

  • Bắt đầu một transaction (because nested Doctrine forms are all saved in one fell swoop)
  • Thực hiện việc submit giá trị (bằng cách gọi phương thức updateCOLUMNColumn() nếu chúng tồn tại)
  • Gọi Doctrine object fromArray() method để update giá trị các cột
  • Lưu object vào database
  • Commit transaction

Tính năng bảo mật có sẵn

Phương thức fromArray() nhận một mảng các giá trị và updates giá trị các cột tương ứng. Vấn đề bảo mật ở đây là gì? Điều gì xảy ra nếu ai đó thử submit một giá trị cho một cột mà anh ta không được phép? Ví dụ cột token chẳng hạn?

Hãy viết một test để mô phỏng việc submit một công việc với field token:

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

Khi submit form, bạn phải có một extra_fields global error. Đó là bởi vì mặc định, form không cho phép các field mở rộng để chứa các giá trị submit. Đó cũng là lí do tại sao tất cả các form field phải có một validator.

tip

Bạn cũng có thể submit thêm các field từ trình duyệt bằng các sử dụng các công cụ như Firefox Web Developer Toolbar.

Bạn có thể bypass giới hạn bảo mật này bằng cách setting allow_extra_fields thành true:

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

Test bây giờ phải pass nhưng giá trị token được filtered out of the values. Vì thế bạn vẫn không thể bypass giới hạn bảo mật này. Nếu bạn vẫn muốn lưu giá trị này, set filter_extra_fields thành false:

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

note

Test viết trong mục này chỉ mang tính chất giới thiệu. Bạn có thể xoá nó khỏi Jobeet project do ta không cần test các tính năng của symfony.

XSS và CSRF Protection

Trong ngày 1, chúng ta đã tạo application frontend với lệnh sau:

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

Option --escaping-strategy cho phép chống lại tấn công XSS. Điều đó có nghĩa là tất cả các giá trị dùng trong templates đều được escaped. Nếu bạn thử submit một công việc với mô tả chứa một vài tag HTML, bạn sẽ thấy rằng khi symfony render trang job, các HTML tag trong phần mô tả không được thực thi, mà hiển thị như plain text.

Option --csrf-secret giúp bảo vệ ứng dụng trước tấn công CSRF. Khi bạn cung cấp lựa chọn này, tất cả các form đều nhúng thêm một field ẩn _csrf_token.

tip

Escaping strategy và CSRF secret có thể thay đổi bằng cách chỉnh sửa trong file cấu hình apps/frontend/config/settings.yml. Tương tự như file databases.yml, cấu hình cũng phụ thuộc môi trường:

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

Maintenance Tasks

Symfony là một web framework nhưng lại sử dụng rất nhiều công cụ dòng lệnh. Bạn đã dùng nó để tạo cấu trúc thư mục mặc định của project. Bạn cũng có thể thêm các lệnh mới một cách dễ dàng.

Trong ứng dụng Jobeet, database sẽ phình to với rất nhiều công việc cũ. Hãy tạo thêm một task để xóa các công việc cũ khỏi database. Task này sẽ được chạy thường xuyên trong một cron job.

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

Task được cấu hình trong phương thức configure(). Mỗi task phải có một tên duy nhất (namespace:name), và có thể có các tham số (argument) và lựa chọn (option).

tip

Mở thư mục lib/task/ để tham khảo các task có sẵn của symfony.

Lệnh jobeet:cleanup có 2 lựa chọn: --env--days với một vài giá trị mặc định.

Chạy lệnh này tương tự như chạy các lệnh khác trong symfony:

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

Để code trở nên sáng sủa ta chuyển phương thức cleanup vào model:

// 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

The symfony tasks behave nicely with their environment as they return a value according to the success of the task. You can force a return value by returning an integer explicitly at the end of the task.

Hẹn gặp lại ngày mai

Test là một phần quan trọng trong symfony. Hôm nay, chúng ta đã học thêm về cách sử dụng các công cụ của symfony khiến cho việc phát triển ứng dụng trở nên nhanh chóng, dễ dàng, và an toàn hơn.

Symfony form framework cung cấp nhiều widget và validator: nó giúp bạn dễ dàng test form để đảm bảo rằng form của bạn hoàn toàn bảo mật.

Chuyến hành trình khám phá những tính năng tuyệt vời của symfony vẫn chưa kết thúc. Ngày mai, chúng ta sẽ tạo backend application cho Jobeet. Tạo backend interface là yêu cầu bắt buộc với hầu hết các dự án web, và Jobeet cũng không là ngoại lệ. Nhưng chúng ta có thể phát triển toàn bộ chúng trong một giờ? Với symfony admin generator framework, việc đó trở nên đơn giản. Hãy đón xem vào ngày mai :)).