English spoken conference

Symfony 5: The Fast Track

A new book to learn about developing modern Symfony 5 applications.

Support this project

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

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

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

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

sidebar

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

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

別の再利用可能なコンポーネントはルーティングフレームワークです。 非symfonyプロジェクトにlib/routing/ディレクトリをコピーし、自由にプリティ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/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()|デバッグ~ステートメントを利用できます。 しかし、エラーメッセージを確認するために生成されるHTMLを徹底的に調べなければなりません。 これは本当に便利ではありません。 フォームテスターはフォームのステータスとこれに関連するすべてのエラーメッセージを出力するdebug()メソッドも提供します:

with('form')->debug()

リダイレクトのテスト

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

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

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

note

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

Propelテスター

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

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

$browser->setTester('propel', 'sfTesterPropel');

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

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

基準は上記のような値の配列もしくは もしくはより複雑なクエリに対するCriteriaインスタンスになります。 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

テストにおいて有効ではないデータの投稿のためにテストを書き、フォーム全体を繰り返し再テストしませんでした。 特定の内容に対してのみテストを追加しました。

エラーメッセージが含まれるかどうか確認するために生成された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'        => '[email protected]',
        '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('propel')->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('propel')->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'        => '[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)
  {
    $criteria = new Criteria();
    $criteria->add(JobeetJobPeer::POSITION, $position);
 
    return JobeetJobPeer::doSelectOne($criteria);
  }
 
  // ...
}

求人が公開される場合、編集ページは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:   sfPropelRouteCollection
  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->getExpiresAt('m/d/Y')));
 
  $this->redirect($this->generateUrl('job_show_user', $job));
}

アクションに期待されるように求人期間が延長される場合JobeetJobextend()メソッドはtrueを返し、そうでなければfalseを返します:

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

最終的に、テストのシナリオを追加します:

$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(time());
$job->save();
 
$browser->
  call(sprintf('/job/%s/extend', $job->getToken()), 'put', array('_with_csrf' => true))->
  with('response')->isRedirected()
;
 
$job->reload();
$browser->test()->is(
  $job->getExpiresAt('y/m/d'),
  date('y/m/d', time() + 86400 * sfConfig::get('app_active_days'))
);

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

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

フォームのセキュリティ

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

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

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

  • トランザクションを始める(入れ子のPropelフォームは一度にすべて保存される)
  • 投稿された値を処理する(値が存在する場合にupdateCOLUMNColumn()メソッドを呼び出す)
  • カラムの値を更新するためにPropelオブジェクトの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日目において、次のコマンドラインでfrontendアプリケーションを作りました:

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

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

--csrf-secretオプションはCSRFの保護を有効にしました。 このオプションを提供するとき、すべてのフォームは_csrf_token隠しフィールドを埋め込みます。

tip

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

all:
  .settings:
    # Form security secret (CSRF protection)
    csrf_secret: Unique$ecret
 
    # Output escaping settings
    escaping_strategy: on
    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('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 = JobeetJobPeer::cleanup($options['days']);
    $this->logSection('propel', 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

常に、データベースのクリーンナップはJobeetJobPeerクラスで行われます:

// lib/model/JobeetJobPeer.php
static public function cleanup($days)
{
  $criteria = new Criteria();
  $criteria->add(self::IS_ACTIVATED, false);
  $criteria->add(self::CREATED_AT, time() - 86400 * $days, Criteria::LESS_THAN);
 
  return self::doDelete($criteria);
}

doDelete()メソッドは渡されるCriteriaオブジェクトを満たすデータベースのレコードを削除します。 このメソッドは主キーの配列を受け取ることもできます。

note

タスクの成功に応じて値を返すのでsymfonyのタスクは環境に応じたふるまいをします。 タスクの最後で明示的に整数を返すことで戻り値を強制できます。

また明日

テストはsymfonyの哲学とツールの中心です。 開発プロセスを簡単で、速く、より重要で、安全にするために今日は、symfonyのツールの活用方法を再び学びました。

symfonyフォームフレームワークはウィジェットバリデーター以外にもたくさんの機能を提供します: フォームをテストする方法を提供しフォームがデフォルトでセキュアであることを保証します。

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