モノノフ日記

普通の日記です

Jobeet - 11日目: フォームのテスト

遅くなりましたが、明けましておめでとうございます。
今年もマイペースで訳していきますのでどうぞよろしくお願いします。

今回はフォームのテストの書き方についてです。

Day 11: Testing your Forms (1_2) - Symfony

前回までのJobeet

昨日はsymfonyで初めてフォームを作成しました。ユーザはJobeetに新しい仕事を投稿できるようになっていますが、テストを追加する時間が無くなってしまいました。

今日はフォーム用のテストを追加します。その途中で、フォームフレームワークについて更に学習を進めていきます。

symfony無しでのフォームフレームワークの利用

symfonyフレームワークの構成要素はかなり疎結合となっています。このことはそれらのほとんどがMVCフレームワーク全体を使うことなしに利用できるということを意味します。フォームフレームワークsymfonyに非依存なケースの1つです。lib/form/, lib/widgets/, lib/validators/ディレクトリを取得すれば、あらゆるPHPアプリケーションで利用できます。
別の再利用可能なコンポーネントとして、ルーティングフレームワークが挙げられます。lib/routing/ディレクトリをsymfonyではないプロジェクトにコピーすれば、無料できれいなURLが利用できるようになります。

symfonyの独立した形式の構成要素のことをsymfonyプラットフォームと呼びます。
f:id:Kiske:20090106114724p:image

フォームの送信

仕事の生成とバリデーションのプロセスの機能テストを追加するためjobActionsTestファイルを開きましょう。
ファイルの末尾に仕事を生成するページを取得する下記コードを追加します。

<?php
// 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引数として各フィールドごとに送信することで値が渡されます。実際のブラウザのように、ブラウザオブジェクトは送信された値をフォームのデフォルト値にマージします。
フィールドに値を渡すには、フィールド名を知っておく必要があります。ソースコードを見たり、FirefoxWeb Developer Toolbarの「フォーム > フォームの情報を表示する」を使うのであれば、companyフィールドがjobeet_job[company]という名前であることを確認できるでしょう。

PHPがjobeet_job[company]のような名前のインプットフィールドに出くわしたとき、配列名jobeet_jobへ自動で変換されます。

もう少しきれいに見れるようにするため、下記コードをJobeetJobFormのconfigure()メソッドの末尾に追加することでjob[%s]へフォーマットを変更しましょう。

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

上記コードの変更後、company名はブラウザ上でjob[company]とすべきです。実際に「Preview your job」ボタンがクリックされ、フォームへ有効な値が渡されるときに。

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

フォームはcreateアクションへ送信しなければなりません。

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

ブラウザはアップロードすべきファイルの絶対パスを受け取り、ファイルアップロードもシミュレートします。

フォームテスタ

送信されたフォームは有効であるべきです。フォームテスタを使ってテストすることができます。

<?php
with('form')->begin()->
  hasErrors(false)->
end()->

フォームテスタは現在のフォームステータス、例えばエラーのようなステータスをテストするいくつかのメソッドを持っています。
もしテストに失敗したなら、9日目で触れたwith('response')->debug()文を使うことができます。しかしエラーメッセージをチェックするため生成されたHTMLに埋め込まなければなりません。それは本当に使いにくいです。だから、フォームテスタはステータスや、関連する全てエラーメッセージを出力するdebug()メソッドを提供します。

<?php
with('form')->debug()

リダイレクトテスト

フォームが有効であるなら、仕事は生成されてユーザはshowページへリダイレクトされます。

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

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

Propelテスタ

最終的に、データベースに生成された仕事のテストやユーザがまだ公開していないようなis_activatedカラムにfalseがセットされているかのチェックをしたいと思っています。
このテストはさらにもう1つのテスタであるPropelテスタを使えば非常に簡単です。Propelテスタはデフォルトでは登録されていないので追加しましょう。

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

Propelテスタはデータベースの1つ以上のオブジェクトが渡された引数にマッチするcriteriaがあるかチェックするcheck()メソッドを提供します。

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

criteriaは上記コードのような配列値やもっと複雑なクエリのCriteriaインスタンスになります。第3引数にBooleanを使い(デフォルトではtrue)、criteriaがマッチしているオブジェクトが存在するかテストできます。または渡す整数がオブジェクトとマッチする数のテストもできます。

エラーテスト

有効値を送信したとき、作った仕事のフォームを予想通りに動作します。無効なデータが送信されたときの振る舞いをチェックするテストを追加しましょう。

<?php
$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()メソッドは得られたフィールドのエラーコードのテストができます。

テスト中に、無効なデータの送信が書かれていても、さらにフォーム全体を再テストはしません。特定な対象のみのテストを追加します。

エラーメッセージを含んでいるかをチェックすることで生成されたHTMLのテストもできます。しかし、フォームレイアウトをカスタマイズしていないようなケースでは必要ありません。
さて、仕事のプレビューページにあるadminバーのテストが必要です。まだ仕事が有効になっていないとき、仕事の編集や削除、公開が可能です。それら3つのリンクをテストするため、仕事を作る必要があります。しかしたくさんコピペすることになります。e-treesを無駄に使いたくないのであれば、JobeetTestFunctionalクラスに仕事の生成メソッドを追加しましょう。

<?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()メソッドは仕事を生成し、リダイレクトをし、fluent interfaceを壊さないようにブラウザオブジェクトを返します。デフォルト値をマージした配列値を渡すこともできます。

リンクのHTTPメソッドを強制

「Publish」リンクのテストはもっと単純です。

<?php
$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リクエストを理解できないので、link_to()ヘルパーがJavaScriptを使ってフォームへのリンクを変換します。テスト用ブラウザはJavaScriptが実行できないので、click()メソッドの第3引数にPUTメソッドを強制するような指定が必要となります。さらに、link_to()ヘルパーは1日目で見たようにCSRFプロテクションを有効にするためのCSRFトークンも組み込まれます。_with_csrfオプションがこのトークンをシミュレートします。

「Delete」リンクのテストも非常に似ています。

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

セーフガードのテスト

仕事が公開されたとき、もう編集はできません。プレビューページ上で「Edit」リンクがもう表示されないとしても、この要件を満たすためいくつかのテストを追加しましょう。
最初に仕事の公開が自動になるようcreateJob()メソッドに引数を追加します、そしてフィールド値から仕事を取得し返すためのgetJobByPosition()メソッドを生成します。

<?php
// 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));
    }
 
    return $this;
  }
 
  public function getJobByPosition($position)
  {
    $criteria = new Criteria();
    $criteria->add(JobeetJobPeer::POSITION, $position);
 
    return JobeetJobPeer::doSelectOne($criteria);
  }
  // ...
}

仕事が公開されているなら、編集ページは404ステータスコードを返さなければなりません。

<?php
$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ページへ転送させるのでバグを修正することは非常に単純です。

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

この修正はささいなものです。しかし、その他すべてのことがまだ予想通りに動作すると思っていますか?ブラウザを開いて、編集ページにアクセスするために利用可能な全ての組み合わせをテストを始めることができます。しかしもっと簡単な方法があります。テストスイートを実行すればよいです。もしリグレッションテストを導入するなら、symfonyはすぐにテスト結果を教えてくれます。

テスト内で過去や未来へ行き来する

5日以内に終了する仕事、または既に終了した仕事があるときユーザは現在の日付からさらに30日後まで仕事の公開を延長できます。
ブラウザで必要要件をテストすることは仕事が生成されたときに有効期限が30日後に自動でセットされるので簡単ではありません。だから、仕事のページを取得するとき、仕事を延長するためのリンクは表示されません。データベースの有効期限をハックすることやテンプレートに延長するリンクを常に表示するように調整することは可能です。しかし、そうすることは退屈だし、エラーを起こしやすくなります。もう予想されていると思いますが、テストを書くことで我々はもう1度助けてもらえます。

いつもの通り、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アクションを生成します。

<?php
// 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.', $job->getExpiresAt('m/d/Y')));
 
  $this->redirect($this->generateUrl('job_show_user', $job));
}

アクションの予想通り、仕事が延長されるかfalseであったなら、JobeetJobのextend()メソッドはtrueを返します。q

<?php
// 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();
  }
 
  // ...
}

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

<?php
$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フィールド付きの仕事の送信をシミュレートするテストを書きましょう。

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

フォームが送信されるとき、extra_fieldsはグローバルエラーを持たなければなりません。このことはデフォルトのフォームでは送信された値を表示させるextraフィールドが承知されていないからです。全てのフォームフィールドが関連したバリデータを持たなければならないという理由でもあります。

FirefoxのWeb Develop Toolbarのようなツールを使えばブラウザから快適に追加フィールドを送信することもできます。

allow_extra_fieldsオプションをtrueにセットすることで、このセキュリティ対策を無視することができます。

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

テストはパスするようになりましたが、tokenの値は漏れて伝わっています。よって、まだセキュリティ対策を無視することはできません。しかし本当に値が欲しいのであれば、filter_extra_fieldsオプションをfalseにセットしてください。

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

このセクションで書かれたテストはデモンストレーション目的のためだけです。テストするのに不必要なバリデートのためのsymfony要素をJobeetプロジェクトからは削除してください。

XSSCSRFの防御

1日目で、下記コマンドラインを使ってfrontendアプリケーションを作成しました。

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

--escaping-strategyオプションはXSSに対する保護を有効にします。このことはテンプレートで使われる全ての変数がデフォルトでエスケープされるということを意味しています。もしHTMLタグを使って仕事の説明を送信しようとしたなら、symfonyが表示した仕事のページを見たときに説明の中で利用したHTMLタグは解釈されずにプレーンテキストとして表示されていることに気づくでしょう。
--csrf-secretオプションはCSRFの保護を有効にします。このオプションが適用されると、全てのフォームに_csrf_tokenという名前のhiddenフィールドが組み込まれます。

>
escaping strategyとCSRF secretの設定は 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はWebフレームワークだったとしても、コマンドラインツールは備えています。すでにプロジェクトやアプリケーションのデフォルトのディレクトリ構造を生成するのに使っていますし、モデル用のさまざまなファイルを生成するのにも使っています。新しいタスクを追加することはフレームワークにパッケージングされているコマンドラインを使えば非常に簡単です。
ユーザが仕事を生成するとき、オンラインで有効にしなければなりません。でもそうしなければ、データベースには古い仕事が増えていきます。データベースから古い仕事を削除するためのタスクを作ってみましょう。このタスクはcronで定期的に実行されなければなりません。

<?php
// 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)、引数やオプションももつことができます。

ビルトインされているsymfonyのタスクは(lib/task/)で見ることができ、よいサンプルとなります。

jobeet:cleanupタスクは2つのオプションが定義されています。それらはデフォルトとしてしっくりくる--envと--daysです。
タスクの実行はsymfonyにビルトインされているタスクの実行と似ています。

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

いつものように、データベースのクリーンナップコードをJobeetJobPeerクラスに追加します。

<?php
// 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オブジェクトにマッチするデータベースのレコードを削除します。プライマリキーの配列でも指定することができます。

symfonyのタスクはタスクが成功することで値が返されるので、環境にとって良い振る舞いをします。タスクの末尾に整数を明示的に返すようにすれば強制的に値を返すようにすることができます。