モノノフ日記

普通の日記です

Jobeet - 9日目: 機能テスト

Jobeet - Day 9: The Functional Tests - Symfony
公式サイトのコメント欄でも指摘されていますが、getMostRecentProgrammingJob()メソッドで$categoryを絡めた条件を記述し忘れてるような気がするので追加しています。

前回までのJobeet

昨日、symfonyにパッケージングされているlimeテストライブラリを使ってJobeetのクラスをユニットテストする方法を見ました。
今日はすでに実装済みのjobとcategoryモジュールの要素に対する機能テストを書いていきます。

機能テスト

機能テストはアプリケーションの端から端まで(ブラウザによって生成されたリクエストから、サーバにレスポンスを送るまで)をテストするのに非常に有用なツールです。アプリケーションの全てのレイヤをテストします。全てのレイヤとはルーティング、モデル、アクション、テンプレートです。機能テストはおそらくすでに手動でやっている、アクションに要素を追加したり編集するたびにブラウザで表示されたページの要素が全て正常に動作しているか確認する、というテストと非常に似ています。言い換えれば、実装した通りのユースケースに相当するシナリオを実行することです。
そのテストプロセスを手動でやると、退屈で間違いが発生しやすいです。コードを変更するたびに必ず中断されないように全てのシナリオを確認しなければなりません。そんなのはばかげています。symfonyの機能テストは簡単にシナリオを書く方法を提供します。各シナリオはユーザがブラウザで体験することをシミュレートすることで何度も何度も自動で動作します。ユニットテストのように、機能テストは安心してコーディングするための確信を与えてくれます。

sfBrowserクラス

symfonyでは機能テストはsfBrowserクラスとして実装された特別なブラウザを通して実行されます。sfBrowserクラスはアプリケーションのために調整されたブラウザの役目を務め、Webサーバを必要せずにアプリケーションに直結します。アプリを確認したり、プログラムでチェックしたりする機会を提供できるよう、各リクエスト前後の全てのsymfonyオブジェクトへのアクセスも提供します。
sfBrowserはクラシックなブラウザでできることをシミュレートしたメソッドを提供します。

メソッド 説明
get() URLを取得
post() URLへPOST
call() URLを呼び出し(PUT and DELETEメソッドの代用)
back() 履歴の1つ前のページにも戻る
forward() 履歴の1つ先のページへジャンプ
reload() 現在のページを再読み込み
click() リンク、またはボタンをクリック
select() ラジオボタンチェックボックスを選択
deselect() ラジオボタンチェックボックスを非選択
restart() ブラウザの再起動

sfBrowserのメソッドのサンプルを下記に示します。

<?php
$browser = new sfBrowser();
 
$browser->
  get('/')->
  click('Design')->
  get('/category/programming?page=2')->
  get('/category/programming', array('page' => 2))->
  post('search', array('keywords' => 'php'))
;

sfBrowserはブラウザの振る舞いを設定する追加メソッドも含んでいます。

メソッド 説明
setHttpHeader() HTTPヘッダをセット
setAuth() ベーシック認証の証明書をセット
setCookie() Cookieをセット
removeCookie() Cookieを削除
clearCookie() 現在セットされている全てのCookieをクリア
followRedirect() リダイレクトさせる

sfTestFunctionalクラス

ブラウザを持ってはいますが、実際にテストさせるにはsymfonyオブジェクトをチェックさせる方法が必要となります。limeといくつかのsfBrowserのメソッド(getResopnse()やgetRequest()のような)で実行はできますが、symfonyはもっと良い方法を提供します。
テストメソッドは別のクラスである、sfBrowserのインスタンスをコンストラクタに持つsfTestFunctionalクラスで提供されます。sfTestFunctionalクラスはテスタオブジェクトにテストをデリゲートします。いくつかのテスタはsymfonyにバンドルされており、自分で作ることもできます。
昨日見たように、機能テストはtest/functionalディレクトリ下に保存されます。Jobeetでは、テストはtest/functional/frontendディレクトリ下にあるアプリケーションごとサブディレクトリで見つけられます。このディレクトリにはすでに2つのファイルがあります。それはモジュールを生成するタスクが自動で生成した基本の機能テストファイルである、categoryActionsTest.phpとjobActionsTest.phpです。

<?php
// test/functional/frontend/categoryActionsTest.php
include(dirname(__FILE__).'/../../bootstrap/functional.php');
 
$browser = new sfTestFunctional(new sfBrowser());
 
$browser->
  get('/category/index')->
 
  with('request')->begin()->
    isParameter('module', 'category')->
    isParameter('action', 'index')->
  end()->
 
  with('response')->begin()->
    isStatusCode(200)->
    checkElement('body', '!/This is a temporary page/')->
  end()
;

最初に、上のコードを見て少し変だとは思いませんか? sfBrowserとsfTestFunctionalのメソッドがfluentインタフェースを有効にするために常に$thisを返しているからです。メソッドをチェインして呼ぶのは可読性を向上させます。
テストはテスタブロックのコンテキスト内で実行されます。testerブロックのコンテキストはwith('TESTER NAME')->begin()で始まり、end()で終わります。

<?php
$browser->
  with('request')->begin()->
    isParameter('module', 'category')->
    isParameter('action', 'index')->
  end()
;

上の例だとリクエストパラメータのmoduleがcategoryであり、actionがindexであるということをテストしています。

テスタの1テストメソッドだけが必要なときは、ブロックを生成する必要はありません。
with('request')->isParameter('module', 'category');

リクエストテスタ

リクエストテスタはsfWebRequestオブジェクトをチェックするメソッドを提供します。

メソッド 説明
isParameter() リクエストパラメータ値をチェック
isFormat() リクエストのフォーマットをチェック
isMethod() メソッドをチェック
hasCookie() リクエストが設定した名前のcookieを持っているかチェック
isCookie() cookieの値をチェック
レスポンステスタ

レスポンステスタクラスもsfWebRequestオブジェクトに対するテスタメソッドを提供します。

メソッド 説明
checkElement() CSSセレクタにマッチするかどうかチェック
isHeader() ヘッダの値をチェック
isStatusCode() ステータスコードをチェック
isRedirected() 現在のレスポンスがリダイレクトかどうかチェック

数日中にさらなるテスタクラスについて説明します(フォームやユーザやキャッシュなどで)

機能テストの実行

ユニットテストのように、機能テストはファイルを直接実行することでテストできます。

$ php test/functional/frontend/categoryActionsTest.php

または、test:functionalタスクも使えます。

$ symfony test:functional frontend categoryActions

f:id:Kiske:20081224135148p:image

テストデータ

Propelユニットテストのように、機能テストが実行されるたびにテストデータを読み込む必要があります。昨日書いたコードを再利用できます。

<?php
include(dirname(__FILE__).'/../../bootstrap/functional.php');
 
$browser = new JobeetTestFunctional(new sfBrowser());
$loader = new sfPropelData();
$loader->loadData(sfConfig::get('sf_test_dir').'/fixtures');

bootstrapスクリプトでデータベースが初期化済みであるため、機能テスト内でデータを読み込むことはユニットテストでやるより少し簡単です。
ユニットテストのように各テストファイルでコードのsnippetをコピペすることはしません。そうではなく、sfTestFunctionalクラスを継承したオリジナルのクラスを作ります。

<?php
// lib/test/JobeetTestFunctional.class.php
class JobeetTestFunctional extends sfTestFunctional
{
  public function loadData()
  {
    $loader = new sfPropelData();
    $loader->loadData(sfConfig::get('sf_test_dir').'/fixtures');
    return $this;
  }
}

機能テストの作成

機能テストの作成はブラウザで再生されるシナリオのようなものです。2日目でテストに必要な全てのシナリオはすでに書きました。
最初に、jobActionsTest.phpファイルを編集して、Jobeetのホームページをテストしましょう。下記のコードに置き換えてください。

期限切れの仕事は一覧に表示されない
<?php
// test/functional/frontend/jobActionsTest.php
include(dirname(__FILE__).'/../../bootstrap/functional.php');
 
$browser = new JobeetTestFunctional(new sfBrowser());
$browser->loadData();
 
$browser->info('1 - The homepage')->
  get('/')->
  with('request')->begin()->
    isParameter('module', 'job')->
    isParameter('action', 'index')->
  end()->
  with('response')->begin()->
    info('  1.1 - Expired jobs are not listed')->
    checkElement('.jobs td.position:contains("expired")', false)->
  end()
;

limeでは出力時の可読性を上げるための情報メッセージを挿入するのにinfo()メソッドを使いました。ホームページ上から無効となった仕事を除外するのにCSSセレクタ .jobs td.position:contains("expired") がHTMLコンテンツのレスポンスのどこにも一致しないことをチェックします。(fixturesファイル内で、無効となった仕事にはpositionに"expired"を含めていたのを思い出してください)

checkElement()メソッドはほとんどの有効なCSS3セレクタを解釈できます。

カテゴリーページで表示するのはn個の仕事のみ

下記コードをテストファイルの終わりに追加してください。

<?php
// test/functional/frontend/jobActionsTest.php
$max = sfConfig::get('app_max_jobs_on_homepage');
 
$browser->info('1 - The homepage')->
  get('/')->
  info(sprintf('  1.2 - Only %s jobs are listed for a category', $max))->
  with('response')->
    checkElement('.category_programming tr', $max)
;

checkElement()メソッドはCSSセレクタがマッチした回数のチェックもできます。

多くの仕事を持っている場合のみ、カテゴリーページへのリンクを持つ
<?php
$browser->info('1 - The homepage')->
  get('/')->
  info('  1.3 - A category has a link to the category page only if too many jobs')->
  with('response')->begin()->
    checkElement('.category_design .more_jobs', false)->
    checkElement('.category_programming .more_jobs')->
  end()
;

designカテゴリーには"more jobs"リンクが無いことをチェックし(.category_design .more_jobsが存在しない)、programmingカテゴリーには"more jobs"リンクがあることをチェックしています(.category_programming .more_jobsが存在する)。

仕事は日付順にソートされる
<?php
// most recent job in the programming category
$criteria = new Criteria();
$criteria->add(JobeetCategoryPeer::SLUG, 'programming');
$category = JobeetCategoryPeer::doSelectOne($criteria);
 
$criteria = new Criteria();
$criteria->add(JobeetJobPeer::EXPIRES_AT, time(), Criteria::GREATER_THAN);
$criteria->add(JobeetJobPeer::CATEGORY_ID, $category->getId());
$criteria->addDescendingOrderByColumn(JobeetJobPeer::CREATED_AT);
 
$job = JobeetJobPeer::doSelectOne($criteria);
 
$browser->info('1 - The homepage')->
  get('/')->
  info('  1.4 - Jobs are sorted by date')->
  with('response')->begin()->
    checkElement('.category_programming tr:last:contains("102")')->
    checkElement(sprintf('.category_programming tr:first a[href*="/%d/"]', $job->getId()))->
  end()
;

実際に仕事が日付順にソートされているかテストするため、ホームページの最後に表示される仕事がcompanyコンテンツ内に102を含んでいるかをチェックします。しかしprogrammingのリストでテストする最初の仕事は役職、会社、就業地が全く同じ2つの仕事であるので扱いにくいです。そのため、プライマリキーを含んでいると予想できるURLをチェックする必要があります。プライマリキーはテスト実行の間変化するので、最初にデータベースからPropelオブジェクトを取得する必要があります。
たとえテストが現状のまま動作しても、programmingカテゴリーの最初の仕事が他のテストでも再利用できるように少しコードをリファクタリングする必要があります。コードはテスト固有のものなのでモデルレイヤへは移していません。代わりに、より簡単に生成できるJobeetTestFunctionalクラスへコードを移します。このクラスはJobeetにおいて、ドメインに特化した機能テストクラスの役割になります。

<?php
// lib/test/JobeetTestFunctional.class.php
class JobeetTestFunctional extends sfTestFunctional
{
  public function getMostRecentProgrammingJob()
  {
    // most recent job in the programming category
    $criteria = new Criteria();
    $criteria->add(JobeetCategoryPeer::SLUG, 'programming');
    $category = JobeetCategoryPeer::doSelectOne($criteria);
 
    $criteria = new Criteria();
    $criteria->add(JobeetJobPeer::EXPIRES_AT, time(), Criteria::GREATER_THAN);
    $criteria->add(JobeetJobPeer::CATEGORY_ID, $category->getId());
    $criteria->addDescendingOrderByColumn(JobeetJobPeer::CREATED_AT);
 
    return JobeetJobPeer::doSelectOne($criteria);
  }
 
  // ...
}
ホームページ上の各仕事はクリックすることができる
<?php
$browser->info('2 - The job page')->
  get('/')->
 
  info('  2.1 - Each job on the homepage is clickable')->
  click('Web Developer', array('position' => 1))->
  with('request')->begin()->
    isParameter('module', 'job')->
    isParameter('action', 'show')->
    isParameter('company_slug', 'sensio-labs')->
    isParameter('location_slug', 'paris-france')->
    isParameter('position_slug', 'web-developer')->
    isParameter('id', $browser->getMostRecentProgrammingJob()->getId())->
  end()
;

ホームページ上の仕事へのリンクをテストするため、"Web Developer"テキストをクリックすることをシミュレートします。ページ上にはそのテキストは多くあるので、ブラウザが最初に1つ目をクリックするようにはっきりと要求します(array('position' => 1))。
各リクエストパラメータはルーティングが正しい仕事を選択していることを確実にするためテストされます。

サンプルで学習しましょう

このセクションでは、仕事ページとカテゴリーページをテストするために必要な全てのコードを提供します。新しい上手いトリックが学習できているか、コードを注意深く読みましょう。

<?php
// lib/test/JobeetTestFunctional.class.php
class JobeetTestFunctional extends sfTestFunctional
{
  public function loadData()
  {
    $loader = new sfPropelData();
    $loader->loadData(sfConfig::get('sf_test_dir').'/fixtures');
 
    return $this;
  }
 
  public function getMostRecentProgrammingJob()
  {
    // most recent job in the programming category
    $criteria = new Criteria();
    $criteria->add(JobeetCategoryPeer::SLUG, 'programming');
    $category = JobeetCategoryPeer::doSelectOne($criteria);
 
    $criteria = new Criteria();
    $criteria->add(JobeetJobPeer::EXPIRES_AT, time(), Criteria::GREATER_THAN);
    $criteria->addDescendingOrderByColumn(JobeetJobPeer::CREATED_AT);
 
    return JobeetJobPeer::doSelectOne($criteria);
  }
 
  public function getExpiredJob()
  {
    // expired job
    $criteria = new Criteria();
    $criteria->add(JobeetJobPeer::EXPIRES_AT, time(), Criteria::LESS_THAN);
 
    return JobeetJobPeer::doSelectOne($criteria);
  }
}
 
// test/functional/frontend/jobActionsTest.php
include(dirname(__FILE__).'/../../bootstrap/functional.php');
 
$browser = new JobeetTestFunctional(new sfBrowser());
$browser->loadData();
 
$browser->info('1 - The homepage')->
  get('/')->
  with('request')->begin()->
    isParameter('module', 'job')->
    isParameter('action', 'index')->
  end()->
  with('response')->begin()->
    info('  1.1 - Expired jobs are not listed')->
    checkElement('.jobs td.position:contains("expired")', false)->
 
    info(sprintf('  1.2 - Only %s jobs are listed for a category', sfConfig::get('app_max_jobs_on_homepage')))->
    checkElement('.category_programming tr', sfConfig::get('app_max_jobs_on_homepage'))->
 
    info('  1.3 - A category has a link to the category page only if too many jobs')->
    checkElement('.category_design .more_jobs', false)->
    checkElement('.category_programming .more_jobs')->
 
    info('  1.4 - Jobs are sorted by date')->
    checkElement(sprintf('.category_programming tr:first a[href*="/%d/"]', $browser->getMostRecentProgrammingJob()->getId()))->
    checkElement('.category_programming tr:last:contains("102")')->
  end()
;
 
$browser->info('2 - The job page')->
  info('  2.1 - Each job on the homepage is clickable and give detailed information')->
  click('Web Developer', array('position' => 1))->
  with('request')->begin()->
    isParameter('module', 'job')->
    isParameter('action', 'show')->
    isParameter('company_slug', 'sensio-labs')->
    isParameter('location_slug', 'paris-france')->
    isParameter('position_slug', 'web-developer')->
    isParameter('id', $browser->getMostRecentProgrammingJob()->getId())->
  end()->
 
  info('  2.2 - A non-existent job forwards the user to a 404')->
  get('/job/foo-inc/milano-italy/0/painter')->
  with('response')->isStatusCode(404)->
 
  info('  2.3 - An expired job page forwards the user to a 404')->
  get(sprintf('/job/sensio-labs/paris-france/%d/web-developer', $browser->getExpiredJob()->getId()))->
  with('response')->isStatusCode(404)
;
 
// test/functional/frontend/categoryActionsTest.php
include(dirname(__FILE__).'/../../bootstrap/functional.php');
 
$browser = new JobeetTestFunctional(new sfBrowser());
$browser->loadData();
 
$browser->info('1 - The category page')->
  info('  1.1 - Categories on homepage are clickable')->
  get('/')->
  click('Programming')->
  with('request')->begin()->
    isParameter('module', 'category')->
    isParameter('action', 'show')->
    isParameter('slug', 'programming')->
  end()->
 
  info(sprintf('  1.2 - Categories with more than %s jobs also have a "more" link', sfConfig::get('app_max_jobs_on_homepage')))->
  get('/')->
  click('22')->
  with('request')->begin()->
    isParameter('module', 'category')->
    isParameter('action', 'show')->
    isParameter('slug', 'programming')->
  end()->
 
  info(sprintf('  1.3 - Only %s jobs are listed', sfConfig::get('app_max_jobs_on_category')))->
  with('response')->checkElement('.jobs tr', sfConfig::get('app_max_jobs_on_category'))->
 
  info('  1.4 - The job listed is paginated')->
  with('response')->begin()->
    checkElement('.pagination_desc', '/32 jobs/')->
    checkElement('.pagination_desc', '#page 1/2#')->
  end()->
 
  click('2')->
  with('request')->begin()->
    isParameter('page', 2)->
  end()->
  with('response')->checkElement('.pagination_desc', '#page 2/2#')
;

機能テストのデバッグ

数回機能テストは失敗します。symfonyGUI無しでブラウザをシミュレートするので、問題の原因を突き止めるのが難しいです。幸いなことにsymfonyはレスポンスヘッダやコンテンツを出力するためのdebug()メソッドを提供します。

<?php
$browser->with('response')->debug();

debug()メソッドはレスポンスヘッダのどこにでも挿入でき、スクリプトの実行を中断させます。

機能テストの利用法

test:functionalタスクはアプリケーションに対する全ての機能テストを実行するのによく使われます。

$ symfony test:functional frontend

タスクはテストファイルごとに1行ずつ出力します。
f:id:Kiske:20081224181922p:image

(全体)テストの利用法

予想しているように、プロジェクトに対する全てのテストを実行するタスクもあります(ユニットテストと機能テスト)

$ symfony test:all

f:id:Kiske:20081224181923p:image

また明日

symfonyのテストツールのツアーはこれで終了です。アプリケーションのテストをしないことはもう弁解にはなりません。limeフレームワークと機能テストフレームワークがあるように、symfonyは少しの努力でテストが書けるように手助けする強力なツールが用意されています。
機能テストの表面の初歩についてやってみました。今後は要素を実装するたびにテストフレームワークのさらなる要素を学習するためテストを書くでしょう。
機能テストフレームワークは"Selenium"のようなツールで置き換えることはできません。Seleniumはブラウザ内で直接多くのプラットフォームを横断した自動化テストを実行します。それはJavaScriptのテストができるということです。
symfonyの別の素晴らしい要素であるフォームフレームワークについて話すつもりなので、明日は必ず戻ってきてください。