Caution: You are browsing the legacy 1.x part of this website.
This version of symfony is not maintained anymore. If some of your projects still use this version, consider upgrading.

11日目: フォームをテストする

Jobeet

昨日は symfony で最初にフォームを作りました。Jobeet で新しい求人を投稿できますが、テストを追加する前に時間切れになりました。

これが今日行うことです。この先、フォームフレームワークについてさらに詳しく学びます。

sidebar

symfony なしでフォームフレームワークを使う

symfony フレームワークコンポーネントはよく疎結合されています。このことは MVC フレームワーク全体を利用せずにそれらの大部分を利用できることを意味します。これはフォームフレームワークにあてはまります。これは symfony に依存していません。lib/form/lib/widgets/lib/validators/ ディレクトリを入手できることで任意の PHP アプリケーションで利用できます。

別の再利用可能なコンポーネントはルーティングフレームワークです。lib/routing/ ディレクトリを非 symfony プロジェクトに非 symfony プロジェクトにコピーし、自由にプリティ URL の恩恵を得られます。

コンポーネントは symfony プラットフォームから独立しています:

symfony プラットフォーム

フォームを投稿する

求人作成とバリデーション処理の機能テストを追加するために、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() メソッドはフォームを投稿するために使うことができます。フォームに関して、メソッドの第2引数としてそれぞれのフィールドに対して投稿する値を渡すことができます。実際のブラウザのように、ブラウザオブジェクトはフォームのデフォルト値と投稿された値をマージします。

フィールドの値を渡すために、これらの名前を知る必要があります。ソースコードを開くもしくは Firefox の Web Developer Toolbar の「Forms > Display Form Details」機能を使う場合、company フィールドの名前が jobeet_job[company] であることがわかります。

note

PHP が jobeet_job[company] のような名前をもつ入力フィールドに遭遇するとき、自動的にこれを名前が jobeet_job である配列に変換します。

より明確にするために、JobeetJobFormconfigure() メソッドの終わりで、次のコードを追加することでフォーマットを job[%s] に変更してみましょう:

// 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'        => 'for.a.job@example.com',
    '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() |デバッグ~ステートメントを利用できます。しかし、エラーメッセージを確認するために生成される HTML を徹底的に調べなければなりません。これは本当に便利ではありません。フォームテスターはフォームのステータスとこれに関連するすべてのエラーメッセージを出力する debug() メソッドも提供します:

with('form')->debug()

リダイレクトのテスト

フォームが有効なので、求人は作成され、ユーザーは show ページにリダイレクトされます:

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

isRedirected() はページがリダイレクトされるかどうかをテストし、followRedirect() メソッドはリダイレクトにしたがいます。

note

ブラウザクラスは自動的にリダイレクトにしたがいません。リダイレクトの前にオブジェクトをイントロスペクトするとよいでしょう。

Doctrine テスター

結局、求人がデータベースに作成されたことをテストし、ユーザーがまだ公開していないので、is_activated カラムが false にセットされていることをチェックしたい場合を考えます。

これは別のテスター、Doctrine テスターを使うことで簡単にできます。Doctrine テスターは登録されていないので、今追加してみましょう:

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

Doctrine テスターは check() メソッドを提供します。このメソッドは、データベースの1つもしくは複数のオブジェクトが引数として渡される条件を満たすことをチェックします。

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

条件は上記のような値の配列もしくはより複雑なクエリに対する Doctrine_Query インスタンスになります。第3引数 (デフォルトは true) としてブール値の 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 もテストできますが、フォームのレイアウトをカスタマイズしていないので、私たちの場合は必要ありません。

これで、求人プレビューページで見つかる admin バーをテストする必要があります。求人がまだアクティベートされていないとき、job を編集、削除もしくは公開できます。これら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'        => 'for.a.job@example.com',
        'is_public'    => false,
      ), $values)))->
      followRedirect()
    ;
  }
 
  // ...
}

createJob() メソッドは求人を作成し、リダイレクトにしたがい、流れるようなインターフェイスを壊さないようにブラウザを返します。デフォルトの値にマージされる値の配列を渡すことができます。

リンクの 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 (HTTP メソッド)~ メソッドで呼び出せるように設定できます。ブラウザは PUT リクエストを理解しないので、link_to() ヘルパーはリンクを JavaScript つきのフォームに変換します。テストブラウザは JavaScript を実行しないので、click() メソッドの3番目のオプションとしてメソッドを渡すことで、メソッドに PUT を強制する必要があります。さらに、link_to() ヘルパーは CSRF トークンも埋め込みます。1日目に 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()
;

SafeGuard としてのテスト

求人が公開されるとき、もはや編集できません。以前のページで「Edit」リンクがもはや表示されなくても、この要件のテストを追加しましょう。

最初に、求人が自動的に公開されるように、別の引数を createJob() メソッドに追加し、職業 (position) の値に渡される求人を返す 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'        => 'for.a.job@example.com',
        '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+

それから、_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.', $job->getDateTimeObject('expires_at')->format('m/d/Y')));
 
  $this->redirect('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(
  $job->getDateTimeObject('expires_at')->format('y/m/d'),
  date('y/m/d', time() + 86400 * sfConfig::get('app_active_days'))
);

このテストのシナリオは少数の内容を導入します:

  • call() メソッドは GET もしくは POST からのメソッドで URL を読み取ります。
  • アクションで求人情報を更新した後で、$job->refresh() でローカルオブジェクトをリロードする必要があります。
  • 最後に、新しい有効期間をテストするために埋め込みの lime オブジェクトを直接使います。

フォームのセキュリティ

フォームのシリアライゼーションマジック!

Doctrine フォームは多くの作業を自動化するのでとても便利です。たとえば、フォームをデータベースにシリアライズするのに $form->save() を呼び出すだけです。

しかしどのように動作するのでしょうか?基本的に、save() メソッドは次のステップに従います:

  • トランザクションを始める (入れ子の Doctrine フォームは一度にすべて保存される)
  • 投稿された値を処理する (値が存在する場合に 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

このセクションで書かれたテストの目的はデモンストレーションのみです。テストは symfony の機能をバリデートする必要がないので、これらを Jobeet プロジェクトから削除できます。

XSS と CSRF の保護

1日目において、generate:app タスクがデフォルトでセキュアなアプリケーションを作ることを学びました。

XSS に対する保護が有効にされました。これはテンプレートで使われるすべての変数がデフォルトでエスケープされることを意味します。HTML タグ内部で求人の説明を投稿しようとすると、symfony が求人ページをレンダリングするとき、説明文からの HTML タグがインタープリタで処理されず、プレーンなテキストとしてレンダリングされていることがわかります。

それから、CSRF 保護を有効にしました。CSRF トークンがセットされるとき、すべてのフォームは _csrf_token 隠しフィールドを組み込みます。

tip

apps/frontend/config/settings.yml 設定ファイルを編集することで、escaping_strategycsrf_secret はいつでも変更できます。databases.yml ファイルに関して、コンフィギュレーションは環境ごとに設定可能です:

all:
  .settings:
    # フォームセキュリティの秘密の文字列 (CSRF 保護)
    csrf_secret: Unique$ecret
 
    # 出力エスケーピングの設定項目
    escaping_strategy: true
    escaping_method:   ESC_SPECIALCHARS

メンテナンスタスク

Web フレームワークではありますが、symfony にはコマンドラインツールが付属しています。プロジェクトとアプリケーションのデフォルトのディレクトリ構造を作るおよびモデル用のさまざまなファイルを生成するためにも使ってきました。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_Core::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 フォームフレームワークはウィジェットバリデータ以外にもたくさんの機能を提供します: フォームをテストする方法を提供しフォームがデフォルトでセキュアであることを保証します。

symfony の偉大な機能のツアーは今日で終わりません。明日は、Jobeet 用のバックエンドアプリケーションを作ります。バックエンドインターフェイスはたいていの Web プロジェクトで必須であり、Jobeet は難しくありません。しかし1時間以内にこのようなインターフェイスを開発する方法は?シンプルです。symfony のアドミンジェネレータフレームワークを使います。それまでは、お元気で。