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

9日: 機能テスト

1.2 / Doctrine

昨日は、symfonyに搭載されているlimeテストライブラリでJobeetクラスをユニットテストする方法を見ました。

今日は、jobcategoryモジュールで実装した機能用の機能テストを書きます。

機能テスト

ブラウザーによるリクエストからサーバーによって送信されるレスポンスまで機能テストはアプリケーションの隅から隅までテストする偉大なツールです: これらはアプリケーションのすべてのレイヤー: ルーティング、モデル、アクション、とテンプレートをテストします。 これらはすでに手作業でやっていることと非常に似ています: アクションを追加もしくは修正するたびに、ブラウザーに向かいリンクをクリックしレンダリングされたページの要素をチェックしてすべてが期待どおりに動作することを確認する必要があります。 言い換えると、実装したばかりのユースケースに対応するシナリオを実行します。

手作業なので、退屈で間違いをしやすいです。 コードで何かを変更するたびに、何も壊していないことを保証するためにすべてのシナリオを行わなければなりません。 これは正気の沙汰ではありません。 symfonyの機能テストはシナリオを簡単に書く方法を提供します。 ユーザーがブラウザーで体験することをシミュレートすることでそれぞれのシナリオは自動的に何度も実行されます。 ユニットテストのように、これらもコードに信頼性を提供してくれます。

note

機能テストフレームワークは "Selenium"のようなツールを置き換えません。 多くのプラットフォームとブラウザーにまたがるテストを自動化するためにSeleniumはブラウザーで直接実行されます。 アプリケーションのJavaScriptをテストできます。

sfBrowserクラス

symfonyにおいて、機能テストはsfBrowserクラスによって実装される、特別なブラウザーをとおして実行されます。 これはアプリケーション用に仕立てられたブラウザーとしてふるまい、Webサーバーに接続しなくてもアプリケーションに直接接続します。 これによってリクエスト前後でsymfonyのすべてのオブジェクトにアクセスできるのでこれらをイントロスペクトしてプログラミング言語でこれらのチェックができます。

sfBrowserは古典的なブラウザーで行われるナビゲーションをシミュレートするメソッドを提供します:

メソッド 説明
get() URLをGETする
post() URLにPOSTする
call() URLを呼び出す(PUTDELETEメソッドに使われる)
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()
;

最初に、上記のコードは少し奇妙に見えるかもしれません。 sfBrowsersfTestFunctionalのメソッドは$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()
;

このコードはリクエストパラメーターのmodulecategoryに等しくactionindexに等しいことをテストします。

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のまた別のすばらしい機能: フォームフレームワークを語ります。