昨日は、symfonyに搭載されているlimeテストライブラリでJobeetクラスをユニットテストする方法を見ました。
今日は、job
とcategory
モジュールで実装した機能用の機能テストを書きます。
機能テスト
ブラウザーによるリクエストからサーバーによって送信されるレスポンスまで機能テストはアプリケーションの隅から隅までテストする偉大なツールです: これらはアプリケーションのすべてのレイヤー: ルーティング、モデル、アクション、とテンプレートをテストします。 これらはすでに手作業でやっていることと非常に似ています: アクションを追加もしくは修正するたびに、ブラウザーに向かいリンクをクリックしレンダリングされたページの要素をチェックしてすべてが期待どおりに動作することを確認する必要があります。 言い換えると、実装したばかりのユースケースに対応するシナリオを実行します。
手作業なので、退屈で間違いをしやすいです。 コードで何かを変更するたびに、何も壊していないことを保証するためにすべてのシナリオを行わなければなりません。 これは正気の沙汰ではありません。 symfonyの機能テストはシナリオを簡単に書く方法を提供します。 ユーザーがブラウザーで体験することをシミュレートすることでそれぞれのシナリオは自動的に何度も実行されます。 ユニットテストのように、これらもコードに信頼性を提供してくれます。
note
機能テストフレームワークは "Selenium"のようなツールを置き換えません。 多くのプラットフォームとブラウザーにまたがるテストを自動化するためにSeleniumはブラウザーで直接実行されます。 アプリケーションのJavaScriptをテストできます。
sfBrowser
クラス
symfonyにおいて、機能テストはsfBrowser
クラスによって実装される、特別なブラウザーをとおして実行されます。
これはアプリケーション用に仕立てられたブラウザーとしてふるまい、Webサーバーに接続しなくてもアプリケーションに直接接続します。
これによってリクエスト前後でsymfonyのすべてのオブジェクトにアクセスできるのでこれらをイントロスペクトしてプログラミング言語でこれらのチェックができます。
sfBrowser
は古典的なブラウザーで行われるナビゲーションをシミュレートするメソッドを提供します:
メソッド | 説明 |
---|---|
get() |
URLをGETする |
post() |
URLにPOSTする |
call() |
URLを呼び出す(PUT とDELETE メソッドに使われる) |
back() |
履歴の1ページを戻る |
forward() |
履歴の1ページを進む |
reload() |
現在のページをリロードする |
click() |
リンクかボタンをクリックする |
select() |
ラジオボタンもしくはチェックボックスを選択する |
deselect() |
ラジオボタンもしくはチェックボックスの選択を解除する |
restart() |
ブラウザーを再起動する |
sfBrowser
メソッドの使い方の例は次のとおりです:
$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を削除する |
clearCookies() |
現在のCookieをすべてクリアする |
followRedirect() |
リダイレクトに従う |
sfTestFunctional
クラス
ブラウザーでテストをすることもできますが、実際のテストを行うためにsymfonyオブジェクトをイントロスペクトする方法が必要です。
これはlimeおよびgetResponse()
とgetRequest()
などのメソッドを備えるsfBrowser
で行うことができます。
symfonyはより優れた方法を提供します。
テストメソッドは別のクラス、sfTestFunctional
によって提供されます。
このクラスはコンストラクターでsfBrowser
インスタンスを受け取ります。
sfTestFunctional
クラスはテスター(tester)オブジェクトのテストをデリゲートします。
いくつかのテスターがsymfonyに搭載されていますが、独自のものを作ることもできます。
昨日説明したように、機能テストはtest/functional/
ディレクトリの下で保存されます。
Jobeetに関しては、それぞれのアプリケーションが独自のサブディレクトリを持つので、
テストはtest/functional/frontend/
サブディレクトリで見つかります。
このディレクトリは常に2つのファイル: categoryActionsTest.php
、とjobActionsTest.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
のメソッドは$this
を常に返すことで
流れるようなインターフェイスを実装するからです。
これによってより優れた可読性のためにメソッド呼び出しを連結できます。
上記のスニペットは下記のコードと同等です:
// test/functional/frontend/categoryActionsTest.php include(dirname(__FILE__).'/../../bootstrap/functional.php'); $browser = new sfTestFunctional(new sfBrowser()); $browser->get('/category/index'); $browser->with('request')->begin(); $browser->isParameter('module', 'category'); $browser->isParameter('action', 'index'); $browser->end(); $browser->with('response')->begin(); $browser->isStatusCode(200); $browser->checkElement('body', '!/This is a temporary page/'); $browser->end();
テストはテスターブロックのコンテキストの範囲内で実行されます。
テスターブロックのコンテキストはwith('TESTER NAME')->begin()
で始まりend()
で終わります:
$browser-> with('request')->begin()-> isParameter('module', 'category')-> isParameter('action', 'index')-> end() ;
このコードはリクエストパラメーターのmodule
がcategory
に等しくaction
はindex
に等しいことをテストします。
tip
テスターで1つのテストメソッドだけしか呼び出す必要がない場合、ブロックを作る必要はありません: with('request')-isParameter('module', 'category')
リクエストテスター
リクエストテスター(request tester)はsfWebRequest
オブジェクトをイントロスペクトしてテストするテスターメソッドを提供します:
メソッド | 説明 |
---|---|
isParameter() |
リクエストパラメーターの値をチェックする |
isFormat() |
リクエストのフォーマットをチェックする |
isMethod() |
メソッドをチェックする |
hasCookie() |
リクエストが渡された名前のCookieを持つか |
チェックする | |
isCookie() |
Cookieの値をチェックする |
レスポンステスター
sfWebResponse
オブジェクトに対してテスターメソッドを提供するレスポンステスター(response tester)クラスもあります:
メソッド | 説明 |
---|---|
checkElement() |
レスポンスのCSSセレクターが基準を満たすかチェックする |
isHeader() |
ヘッダーの値をチェックする |
isStatusCode() |
レスポンスステータスコードをチェックする |
isRedirected() |
現在のレスポンスがリダイレクトであるかをチェックする |
note
後の日程でテスタークラス(フォーム、ユーザー、キャッシュ、...)をより詳しく説明します。
機能テストを実行する
ユニットテストに関しては、テストファイルを直接実行することで機能テストが行われます:
$ php test/functional/frontend/categoryActionsTest.php
test:functional
タスクを使う:
$ php symfony test:functional frontend categoryActions
テストデータ
Doctrineユニットテストに関しては、機能テストを起動させるたびにテストデータをロードする必要があります。 昨日書いたコードを再利用できます:
include(dirname(__FILE__).'/../../bootstrap/functional.php'); $browser = new sfTestFunctional(new sfBrowser()); Doctrine::loadData(sfConfig::get('sf_test_dir').'/fixtures');
すでにデータベースがブートストラップスクリプトで初期化されているので、機能テストでのデータのロードはユニットテストよりも少し簡単です。
ユニットテストに関しては、コードのスニペットをそれぞれのテストファイルにコピー&ペーストせず、sfTestFunctional
を継承する独自の機能クラスを作ります:
// lib/test/JobeetTestFunctional.class.php class JobeetTestFunctional extends sfTestFunctional { public function loadData() { Doctrine::loadData(sfConfig::get('sf_test_dir').'/fixtures'); return $this; } }
機能テストを書く
機能テストを書くことはブラウザーでシナリオを演じることに似ています。 2日目のストーリーの一部としてテストする必要のあるすべてのシナリオをすでに書きました。
最初に、jobActionsTest.php
テストファイルを編集してJobeetのホームページをテストしましょう。
コードを次の内容で置き換えます:
一覧表示されない期限切れの求人
// 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の内容のどこにもマッチしないことをチェックします(フィクスチャファイルにおいて、期限切れの求人のみが職種で"期限切れ"を含むことを覚えておいてください)。
checkElement()
メソッドの2番目の引数がブール値であるとき、メソッドはCSSセレクターにマッチするノードの存在をテストします。
tip
checkElement()
メソッドは有効なCSS3セレクターを解釈できます。
カテゴリに対して一覧が表示される求人の任意の件数
テストファイルの末尾に次のコードを追加します:
// 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()
メソッドの2番目の引数に整数を渡すことでCSSセレクタがドキュメントの'n'ノードにマッチすることもチェックします。
求人が多すぎる場合のみカテゴリはカテゴリページへのリンクを持つ
// test/functional/frontend/jobActionsTest.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
は存在する)をチェックします。
求人を日付によってソートする
$q = Doctrine_Query::create() ->select('j.*') ->from('JobeetJob j') ->leftJoin('j.JobeetCategory c') ->where('c.slug = ?', 'programming') ->andWhere('j.expires_at > ?', date('Y-m-d', time())) ->orderBy('j.created_at DESC'); $job = $q->fetchOne(); $browser->info('1 - The homepage')-> get('/')-> info(' 1.4 - Jobs are sorted by date')-> with('response')->begin()-> checkElement(sprintf('.category_programming tr:first a[href*="/%d/"]', $job->getId()))-> end() ;
求人が実際に日付でソートされているかテストするために、ホームページで一覧が表示される最初の求人が期待するものであるかをチェックする必要があります。 これはURLが期待する主キーを含むことをチェックすることで可能です。 主キーは実行の間に変わる可能性があるので、最初にデータベースからDoctrineオブジェクトを取得する必要があります。
テストがそのまま動作するとしても、コードを少しリファクタリングする必要があります。
programmingカテゴリの最初の求人の取得はテストの任意の場所で再利用できます。
コードはテスト固有のものなので、コードをModelレイヤーに移動させません。
代わりに、前に作成したJobeetTestFunctional
クラスにコードを移動させます。
このクラスはJobeetに対してドメイン固有の機能テスタークラスとしてふるまいます:
// lib/test/JobeetTestFunctional.class.php class JobeetTestFunctional extends sfTestFunctional { public function getMostRecentProgrammingJob() { $q = Doctrine_Query::create() ->select('j.*') ->from('JobeetJob j') ->leftJoin('j.JobeetCategory c') ->where('c.slug = ?', 'programming'); $q = Doctrine::getTable('JobeetJob')->addActiveJobsQuery($q); return $q->fetchOne(); } // ... }
前のテストコードを次の内容に置き換えることができます:
// test/functional/frontend/jobActionsTest.php $browser->info('1 - The homepage')-> get('/')-> info(' 1.4 - Jobs are sorted by date')-> with('response')->begin()-> checkElement(sprintf('.category_programming tr:first a[href*="/%d/"]', $browser->getMostRecentProgrammingJob()->getId()))-> end() ;
ホームページのそれぞれの求人はクリックできる
$browser->info('2 - The job page')-> get('/')-> info(' 2.1 - Each job on the homepage is clickable and give detailed information')-> click('Web Developer', array(), 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"のテキストのクリックをシミュレートします。
ページには同様のリンクがたくさんあるので、最初のものをクリックするように明示的にブラウザーに指示しました(array('position' => 1)
)。
ルーティングが求人を正しく表示することを確認するためにそれぞれのリクエストパラメーターがテストされます。
事例による学習
このセクションでは、求人とカテゴリページをテストするために必要なすべてのコードを提供しました。 新しくてすばらしいトリックを学べるのでコードを注意深く読んでください:
// lib/test/JobeetTestFunctional.class.php class JobeetTestFunctional extends sfTestFunctional { public function loadData() { Doctrine::loadData(sfConfig::get('sf_test_dir').'/fixtures'); return $this; } public function getMostRecentProgrammingJob() { $q = Doctrine_Query::create() ->select('j.*') ->from('JobeetJob j') ->leftJoin('j.JobeetCategory c') ->where('c.slug = ?', 'programming'); $q = Doctrine::getTable('JobeetJob')->addActiveJobsQuery($q); return $q->fetchOne(); } public function getExpiredJob() { $q = Doctrine_Query::create() ->from('JobeetJob j') ->where('j.expires_at < ?', date('Y-m-d', time())); return $q->fetchOne(); } } // 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() ; $max = sfConfig::get('app_max_jobs_on_homepage'); $browser->info('1 - The homepage')-> info(sprintf(' 1.2 - Only %s jobs are listed for a category', $max))-> with('response')-> checkElement('.category_programming tr', $max) ; $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() ; $browser->info('1 - The homepage')-> info(' 1.4 - Jobs are sorted by date')-> with('response')->begin()-> checkElement(sprintf('.category_programming tr:first a[href*="/%d/"]', $browser->getMostRecentProgrammingJob()->getId()))-> 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(), 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#') ;
機能テストをデバッグする
機能テストが失敗することがあります。
symfonyはグラフィカルなインターフェイスをシミュレートするので、問題を診断するのが難しいことがあり得ます。
ありがたいことに、symfonyはレスポンスのヘッダーと内容を出力するためにdebug()
メソッドを提供します:
$browser->with('response')->debug();
debug()
メソッドはresponse
テスターブロックの任意の場所に差し込むことが可能でスクリプトの実行を停止します。
機能テストハーネス
test:functional
タスクはアプリケーションのすべての機能テストを起動させるためにも使えます:
$ php symfony test:functional frontend
タスクはそれぞれのテストファイルに対して単独の行を出力します:
テストハーネス
ご期待どおり、プロジェクトのすべてのテストを起動させるタスクもあります(ユニットテストと機能テスト):
$ php symfony test:all
また明日
symfonyのテストツールのツアーをまとめます。
アプリケーションをテストしないことを言い訳してはなりません!
limeフレームワークと機能テストフレームワークによって、symfonyは少しの労力でテストを書く手助けをしてくれる強力なツールを提供します。
機能テストの表層的な内容を扱いました。 これからは、機能を実装するたびに、テストフレームワークの詳細な機能を学ぶためにもテストを書くことになります。
明日は、symfonyのまた別のすばらしい機能: フォームフレームワークを語ります。
This work is licensed under the Creative Commons Attribution-Share Alike 3.0 Unported License license.