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

15日目: ユニットテスト

1.0
Language

復習

昨日に追加したコミュニティのタグづけ機能のおかげで、askeetのWebサイトの質問はよく編成されました。

Webアプリケーションの寿命において重要であるにもかかわらず、今まで語られなかったことがあります。ユニットテスト(単体テスト)はオブジェクト指向以降の最も偉大な進歩の一つです。ユニットテストによって安全な開発プロセスを実現し、恐れることなくリファクタリングができます。アプリケーションが何をするのかはっきりと記述することができるので、時にドキュメントに置き換えるができます。symfonyはユニットテストの推奨とサポートし、そのためのツールを提供します。 これらのツールの概要 - askeetのいくつかのユニットテストの追加 - は今日のチュートリアルの多くの時間を占めます。

Simple Test

PHPの世界ではJunitに基づくユニットテストフレームワークがたくさんあります。私たちはsymfonyのために別の物を開発しません。しかし、かわりにそれらの中でもっとも完成されているものを統合しました。Simple Testです。安定しており、十分なドキュメントが作成され、symfonyを含む、すべてのPHPプロジェクトに重要な価値を持つ多くの機能を提供します。まだ知らなければ、これらの明快で進歩的なドキュメントを見ることを強くお勧めいたします。

Simple Testはsymfonyにバンドルされていませんが、インストールは簡単です。最初にSourceForgeのPEARでインストール可能なアーカイブのSimple Testをダウンロードします。PEAR経由でインストールします:

$ pear install simpletest_1.0.0.tgz

Simple Testライブラリを使ったバッチスクリプトを書きたい場合、すべきことはスクリプトのトップにわずかなコードを挿入するだけです:

<?php
 
require_once('simpletest/unit_tester.php');
require_once('simpletest/reporter.php');
 
?>

symfonyはテストのコマンドラインを使うかどうかをしてくれます; 手短に説明します。

note

PHP 5.0.5の後方互換性がない変更のため、Simple TestはPHP 5.0.4以上の場合は現在動作しません。短く変更されるのですが、(この問題に取り組んだアルファバージョンは利用可能です)不幸なことに、このチュートリアルの残りは後のバージョンである場合はおそらく動作しません。

symfonyプロジェクトでユニットテスト

デフォルトのユニットテスト

symfonyプロジェクトはアプリケーションのサブディレクトリに分割されたtest/ディレクトリを格納します。askeetの場合、askeet/test/functional/frontend/ディレクトリを眺めると、すでにいくつのファイルが存在していることを確認できます:

answerActionsTest.php
feedActionsTest.php
mailActionsTest.php
sidebarActionsTest.php
userActionsTest.php

これらはすべて同じ初期コードを持ちます:

<?php
 
class answerActionsWebBrowserTest extends UnitTestCase
{
  private
    $browser = null;
 
  public function setUp ()
  {
    // 新しいテストブラウザを作成する
    $this->browser = new sfTestBrowser();
    $this->browser->initialize('hostname');
  }
 
  public function tearDown ()
  {
    $this->browser->shutdown();
  }
 
  public function test_simple()
  {
    $url = '/answer/index';
    $html = $this->browser->get($url);
    $this->assertWantedPattern('/answer/', $html);
  }
}
 
?>

UnitTestCaseはSimple Testによるユニットテストの中心的なクラスです。setUp()メソッドはそれぞれのテストメソッドの前で動作し、tearDown()はそれぞれのテストメソッドの後で動作します。実際のテストメソッドは'test'という言葉で動作を始めます。コードのピースがあなたの期待通りに動作するのか、確認するには何がtrueなのか確認するメソッド呼び出しであるアサーションを使います。Simple Testにおいてアサーションはassertで始まります。この例の場合、1つのユニットテストが実装され、モジュールのデフォルトページにおいて「user」が捜索されます。この自動生成されたファイルはあなたが始めるためのスタブです。

実際のところ、synmfony init-moduleを呼び出すたびにsymfonyは作成されたモジュールに関連するユニットテストを保存するためにtest/[appname]/ディレクトリでスケルトンを作成します。問題はデフォルトのテンプレートを修正したとたんに、スタブテストが合格しなくなることです('module $modulename'であるページのデフォルトタイトルを確認)。これらのファイルを削除し、私たち自身のテストケースを動作させます。

ユニットテストを追加する

13日目に、タグの操作に関連する2つの関数を使いTag.class.phpファイルを作成しました。私たちのTagライブラリのためにいくつかのユニットテストを追加します。

TagTest.phpファイルを作成します(すべてのテストケースファイルはSimple Testが見つけられるようにTestで終わらなければなりません):

<?php
 
require_once('Tag.class.php');
 
class TagTest extends UnitTestCase
{
  public function test_normalize()
  {
    $tests = array(
      'FOO'       => 'foo',
      '   foo'    => 'foo',
      'foo  '     => 'foo',
      ' foo '     => 'foo',
      'foo-bar'   => 'foobar',
    );
 
    foreach ($tests as $tag => $normalized_tag)
    {
      $this->assertEqual($normalized_tag, Tag::normalize($tag));
    }
  }
}
 
?>

私たちが実装する最初のテストケースはTag::normalize()メソッドに関係します。ユニットテストは1回に1つのケースをテストするようになっているので、私たちは期待されるテキストメソッドの結果を基本ケースに分解します。Tag::normalize()メソッドは引数の小文字バージョンを返します。戻り値の値の前後のスペースおよび特殊文字は削除されます。5つのテストケースは$test配列において定義され、テストするのに十分な量です。

それぞれの基本テストケースに対して->assertEqual()メソッドを呼び出すことで正規化された入力と期待される結果を比較します。これはユニットテストの核心です。失敗したら、テストスイートが実施されたときにテストケースが出力されます。合格したテストの数がそのまま追加されます。

'FOo-bar'で最後のテストを追加できますが、基本ケースをミックスします。このテストが失敗した場合、問題の正確な原因のはっきりしたアイディアを思いつかないでしょうし、もっと調査をする必要があります。基本ケースを使うことで、簡単に突き止められるエラーの保証をします。

note

assertメソッドの大規模なリストはSimple Testのドキュメントで見つかります。

ユニットテストを動作させる

symfonyコマンドによって1つのコマンドですべてのテストをすぐに実施できます(プロジェクトのrootディレクトリから呼び出す):

$ symfony test-functional frontend

このコマンドを呼び出すと、すべてのtest/functional/frontend/ディレクトリのテストが実行されます。現時点ではTagTest.phpセットだけです。これらのテストは合格しコマンドラインは次のように示します:

$ symfony test-functional frontend
Test suite in (test/frontend)
OK
Test cases run: 1/1, Passes: 5, Failures: 0, Exceptions: 0

note

symfonyコマンドで立ち上げたテストはSimple Testライブラリを含みません(unit_tester.phpreporter.phpは自動的に含まれます)。

反対の方法

ユニットテストの最大の利点はテスト駆動開発をしているときに経験します。この方法論では、関数を書く前にテストを書きます。

上記の例では、空のTag::Normalize()メソッドを書き、最初のテストケース('Foo'/'foo')を書き、テストスイートを動作させます。テストは失敗します。それからTag::normalize()メソッドにおいて、引数を小文字に変換して返すのに必要なコードを追加し、またテストを実行します。今度のテストは合格します。

空白文字のテストを追加し、実施し、失敗を確認し、空白を除去するコードを追加し、テストを再実行し、合格するのを確認します。それから、特殊文字にも同じことを行います。

テストを最初に書くことは実際に開発する前に関数がなにをすべきなのかについて焦点を当てることを手助けします。エクストリームプログラミングのような推奨される他の方法論と同じように、これもグッドプラクティスです。加えて、ユニットテストを書いていないと、後で書かないという事実を考慮に入れています。

最後のお勧めです。ここで説明したのと同じくらいユニットテストをシンプルに保つことです。テスト駆動方法論によるアプリケーションの開発では実際のコードとテストコードが同じぐらいで終わります。ですのでテストケースをデバッグするのに時間を費やしたくないことでしょう・・・

テストが失敗するとき

Tagオブジェクトの2番目のメソッドをチェックするテストを追加します。Tagオブジェクトは文字列で構成されるいくつかのタグをタグの配列に分割します。TagTestクラスに次のメソッドを追加します:

public function test_splitPhrase()
{
  $tests = array(
    'foo'              => array('foo'),
    'foo bar'          => array('foo', 'bar'),
    '  foo    bar  '   => array('foo', 'bar'),
    '"foo bar" askeet' => array('foo bar', 'askeet'),
    "'foo bar' askeet" => array('foo bar', 'askeet'),
  );
 
  foreach ($tests as $tag => $tags)
  {
    $this->assertEqual($tags, Tag::splitPhrase($tag));
  }
}

note

グッドプラクティスとして、テストを行うクラスからテストファイルを名づけ、これからテストしようとしているメソッドからテストケースを名づけることをお勧めします。test/ディレクトリにはすぐに多くのファイルが収納され、長期的には、テストを見つけるのが難しくなるからです。

テストを再実行しようとすると、失敗します:

$ symfony test-functional frontend
Test suite in (test/frontend)
1) Equal expectation fails as key list [0, 1] does not match key list [0, 1, 2] at line [35]
        in test_splitPhrase
        in TagTest
        in /home/production/askeet/test/functional/frontend/TagTest.php
FAILURES!!!
Test cases run: 1/1, Passes: 9, Failures: 1, Exceptions: 0

大丈夫です。test_splitPhraseのテストケースの1つが失敗しました。どれなのかを見つけるために、テストが合格したときに一度除去する必要があります。今回、シングルクォートの処理をテストするとき、最後のテストです。現在のTag::splitPhrase()メソッドはこの文字を適切に変換しません。宿題の一部として、明日、訂正をする必要があります。

配列にあなたが多くの基本テストケースを積み上げすぎた場合、失敗を見つけるのは難しいことは明らかです。Simple Testはテストがどこで失敗したのかメソッド名を記載するので、長いテストケースをメソッドに分割にするのは常に望ましいです。

Webブラウザセッションをシミュレートする

Webアプリケーションは多かれ少なかれ関数のように動作するオブジェクトのすべてではありません。複雑なページリクエストのメカニズム、HTMLの出力結果とブラウザとの情報のやりとりはsymfony製のWebアプリケーションのための完全なユニットテストのセットを構築するまえに、より多くのことに直面します。

シンプルなWebアプリケーションのテストを実装するために3つの異なる方法を検査します。テストは最初の質問の詳細をリクエストしなければならないのと、回答のテキストが存在することを前提としています。このテストをaskeet/test/functional/frontend/ディレクトリのQuestionTest.phpファイルに設置します。

sfTestBrowserオブジェクト

symfonyはsfTestBrowserと呼ばれるオブジェクトを用意しています。これはブラウザをシミュレートし、もっと重要なことは、Webサーバー無しで使えることです。フレームワーク内部にあることで、このオブジェクトがhttp転送レイヤーを完全に回避できます。このことはsfTestBrowserによってシミュレートされたブラウザは速く、サーバーを使用しないのでサーバーの設定から独立しています。

このオブジェクトでページをどのようにリクエストするのか見てみましょう:

$browser = new sfTestBrowser();
$browser->initialize();
$html = $browser->get('uri');
 
// $html上でいくつかのテストを行う
 
$browser->shutdown();

get()リクエストはパラメータ(内部のURIではなく)として、ルーティングされたURIを取得し、生のHTMLページ(文字列)を返します。UnitTestCaseオブジェクトのassert*()メソッドを使用して、このページについてあらゆる種類のテストを続行できます。

URLバーをブラウジングするものとして、メソッド呼び出しに引数を渡すことができます:

$html = $browser->get('/frontend_test.php/question/what-can-i-offer-to-my-stepmother');

特別なフロントコントローラ(frontend_test.php)を使う理由は次のセクションで説明されます。

sfTestBrowserはCookieをシミュレートします。単独のsfTestBrowserオブジェクトで、次から次へ複数のページをリクエストすることが可能で、フレームワークによって単独のセッションの一部とみなされます。加えて、sfTestBrowserは内部URLの代わりにルーティングされたURIを使用するという事実はルーティングエンジンをテストするのが可能であることを意味します。

Webテストを実装するために、test_QuestionShow()メソッドを次のように構築します:

<?php
 
class QuestionTest extends UnitTestCase
{
  public function test_QuestionShow()
  {
    $browser = new sfTestBrowser();
    $browser->initialize();
    $html = $browser->get('frontend_test.php/question/what-can-i-offer-to-my-step-mother');
    $this->assertWantedPattern('/My stepmother has everything a stepmother is usually offered/', $html);
    $browser->shutdown();
  }
}

すべてのWebユニットテストは新しいsfTestBrowserを必要とします。コードの一部分を->setUp()メソッドと->tearDown()メソッドに移動させた方がよりベターです:

<?php 
 
class QuestionTest extends UnitTestCase
{
  private $browser = null;
 
  public function setUp()
  {
    $this->browser = new sfTestBrowser();
    $this->browser->initialize();
  }
 
  public function tearDown()
  {
    $this->browser->shutdown();
  }
 
  public function test_QuestionShow()
  {
    $html = $this->browser->get('frontend_test.php/question/what-can-i-offer-to-my-step-mother');
    $this->assertWantedPattern('/My stepmother has everything a stepmother is usually offered/', $html);
  }
}

追加したすべての新しいtestメソッドはクリーンなsfTestBrowserオブジェクトを保有します。このチュートリアルの最初で触れた自動生成テストケースを思い出しているかもしれません。

WebTestCaseオブジェクト

Simple Testは、ナビゲーション、内容とCookieのチェックとフォームハンドリングのための機能を含むWebTestCaseクラスを搭載しています。このクラスを継承するテストによってhttp転送レイヤーによるブラウジングセッションをシミュレートできます。繰り返しますが、Simple Testのドキュメントでこのクラスの使い方が詳しく説明されています。

Webサーバーはすべてのリクエストの中心にあるので、WebTestCaseによって構築されたテストはsfTestBrwoserで構築されたものよりも遅いです。Webサーバーの構成を動作させることも必要です。しかしながら、WebTestCaseオブジェクトはassert*()メソッドに加えて数多くのナビゲーションメソッドが付属しています。これらのメソッドを使うことで、複雑なブラウジングセッションをシミュレートすることが可能です。WebTestCaseナビゲーションメソッドのサブセットは次の通りです:

- - -
get($url, $parameters) setField($name, $value) authenticate($name, $password)
post($url, $parameters) clickSubmit($label) restart()
back() clickImage($label, $x, $y) getCookie($name)
forward() clickLink($label, $index) ageCookies($interval)

私たちはWebTestCaseによって以前と同じテストケースを容易に実施できます。Webサーバーがリクエストするので、完全なURIを入力する必要があります:

require_once('simpletest/web_tester.php');
 
class QuestionTest extends WebTestCase
{ 
  public function test_QuestionShow()
  {
    $this->get('http://askeet/frontend_test.php/question/what-can-i-offer-to-my-step-mother');
    $this->assertWantedPattern('/My stepmother has everything a stepmother is usually offered/');
  }
}

このオブジェクトの追加メソッドは投稿フォームが処理される方法のテストを手助けしてくれます。たとえば、ログインプロセスのユニットテストです:

public function test_QuestionAdd()
{
  $this->get('http://askeet/frontend_dev.php/');
  $this->assertLink('sign in/register');
  $this->clickLink('sign in/register');
  $this->assertWantedPattern('/nickname:/');
  $this->setField('nickname', 'fabpot');
  $this->setField('password', 'symfony');
  $this->clickSubmit('sign in');
  $this->assertWantedPattern('/fabpot profile/');      
}

手動でフィールドに値を設定しやすくなります。POSTリクエストを行うことでシミュレートをしなければならない場合(->post($uri, $parameters)を呼び出すことで可能です)、多くの実装方法に依存しているので、テスト関数、アクションのターゲットとアクションのターゲットに書く必要があります。Simple Testによるフォームテストの詳しい情報はSimple Testのドキュメントの関連する章を読んでください。

Selenium

sfTestBrowserWebTestCaseテストの大きな欠点はJavaScriptをシミュレートできないことです。とても複雑なインタラクション、たとえばAJAXインタラクションのために、ユーザーが行うマウスとキーボードを正確に再現できることが必要です。通常は、これらのテストは手動で再現されますが、とても時間がかかり、失敗しがちです。

今回の解決方法はJavaScriptの世界からやってきます。Seleniumと呼ばれ、Selenium Recorder extension for Firefoxが採用するとより良いでしょう。Seleniumは現在のブラウザウィンドウを使用し通常のユーザーの世界と同じようにページのアクションのセットを実行します。

Seleniumはsymfonyにデフォルトで搭載されていません。インストールするには、web/ディレクトリで新しいselenium/ディレクトリを作成する必要があります。そして、そこでSeleniumアーカイブを解凍します。SeleniumがJavaScriptに依存するためで、たいていのブラウザの標準のセキュリティ設定ではアプリケーションが同じホストとポートで利用可能ではない限り、実行できません。

note

外部からアクセスできるので、本番用ホストにslenium/ディレクトリを転送しないように注意してください。

SeleniumテストはHTMLで書かれ、selenium/tests/ディレクトリに保存されます。たとえば、質問の詳細に関するシンプルなユニットテストを実行するには、testQuestion.htmlという名前のファイルを作成します:

<!DOCTYPE html PUBLIC "-//W3C//DTD HTML 4.01 Transitional//EN">
<html>
<head>
  <meta content="text/html; charset=UTF-8" http-equiv="content-type">
  <title>Question tests</title>
</head>
<body>
<table cellspacing="0">
<tbody>
  <tr><td colspan="3">First step</td></tr>
 
  <tr>
    <td>open</td>
    <td>/frontend_test.php/</td>
    <td>&nbsp;</td>
  </tr>
 
  <tr>
    <td>clickAndWait</td>
    <td>link=What can I offer to my step mother?</td>
    <td>&nbsp;</td>
  </tr>
 
  <tr>
    <td>assertTextPresent</td>
    <td>My stepmother has everything a stepmother is usually offered</td>
    <td>&nbsp;</td>
  </tr>
 
</tbody>
</table>
</body>
</html>

テストケースは3つのカラムのテーブルを含むHTMLドキュメントによって表現されます: コマンド、ターゲット、値です。しかしながら、すべてのコマンドは値を取得しません。このケースの場合、カラムを空白しておくか、テーブルをより見やすくするために&nbsp;を使います。

同じディレクトリに設置されたTestSuite.htmlファイルのテーブルに新しい行を挿入することで、グローバルテストスイートにこのテストを追加する必要もあります:

...
<tr><td><a href='./testQuestion.html'>My First Test</a></td></tr>
...

テストを実行するには、次のURLにブラウザでアクセスします。

http://askeet/selenium/index.html

すべてのテストを実施するためにボタンをクリックするよりも、'Main Test Suite'を選択して、指定したステップを再現されるのかブラウザで確認してください。1つのブラウザでテストを構築して、1つのリクエストでサイトが動作するのかテストします。

Seleniumにおけるテストスィート

note

Seleniumテストは実際のブラウザで実行できるので、ブラウザの不整合テストを可能にします。1つのブラウザでテストを構築し、1つのリクエストでサイトが動作するのかをテストします。

SeleniumテストがHTMLで書かれているという事実はSeleniumテストを書くことをやっかいにすることもあります。しかし、Firefox Selenium拡張機能のおかげで、記録されたセッションでテストを1回実行するために、1つのテストを作成するだけで済みます。記録セッションのナビゲーションにおいて、ブラウザのウィンドウ内で右クリックすること、およびポップアップメニューにあるAppend Selenium Commandの元で適切なチェックを選択することで、アサート型のテストを追加できます。

たとえば、次のSeleniumテストはAJAXの質問レーティングをチェックします。ユーザーの'fabpot'はログインし、これまでのところ興味のないページだけにアクセスするために質問の2番目のページを表示し、それから'interested?'リンクをクリックし、'?'を'!'に変更します。Firefoxの拡張機能によってすべて記録され、30秒もかかりません:

<html>
<head><title>New Test</title></head>
<body>
<table cellpadding="1" cellspacing="1" border="1">
<thead>
<tr><td rowspan="1" colspan="3">New Test</td></tr>
</thead><tbody>
<tr>
    <td>open</td>
    <td>/frontend_dev.php/</td>
    <td></td>
</tr>
<tr>
    <td>clickAndWait</td>
    <td>link=sign in/register</td>
    <td></td>
</tr>
<tr>
    <td>type</td>
    <td>//div/input[@value="" and @id="nickname" and @name="nickname"]</td>
    <td>fabpot</td>
</tr>
<tr>
    <td>type</td>
    <td>//div/input[@value="" and @id="password" and @name="password"]</td>
    <td>symfony</td>
</tr>
<tr>
    <td>clickAndWait</td>
    <td>//input[@type='submit' and @value='sign in']</td>
    <td></td>
</tr>
<tr>
    <td>clickAndWait</td>
    <td>link=2</td>
    <td></td>
</tr>
<tr>
    <td>click</td>
    <td>link=interested?</td>
    <td></td>
</tr>
<tr>
    <td>pause</td>
    <td>3000</td>
    <td></td>
</tr>
<tr>
    <td>verifyTextPresent</td>
    <td>interested!</td>
    <td></td>
</tr>
<tr>
    <td>clickAndWait</td>
    <td>link=sign out</td>
    <td></td>
</tr>
 
</tbody></table>
</body>
</html>

Seleniumテストを立ち上げる前に、テストデータを再初期化することを忘れないでください(batch/load_data.phpを呼び出します)。

note

Seleniumが動作しないので、AJAXリンクをクリックした後で手動で停止アクションを追加しなければなりませんでした。これがSeleniumでAJAXインタラクションをテストするための一般的なアドバイスです。

アプリケーションのテストスイートを構築するためにHTMLのテストファイルを保存できます。Firefoxの拡張機能は記録したSeleniumテストを実行することもできます。

環境についていくつかの説明

Webテストにはフロントコントローラを使わなければなりません。同様に特別な環境(すなわち設定)を使うことができます。symfonyはデフォルトですべてのアプリケーションのためのテスト環境を提供します。とりわけ、ユニットテストがあります。アプリケーションのconfig/ディレクトリでカスタム設定の一式を定義できます。デフォルトの設定パラメータは次の通りです(askeet/apps/frontend/config/settings.ymlから抜粋):

test:
  .settings:
    # E_ALL | E_STRICT & ~E_NOTICE = 2047
    error_reporting:        2047
    cache:                  off
    stats:                  off
    web_debug:              off

cachestatsweb_debugツールバーはオフに設定されています。しかしながら、まだコードの実行はログファイルにトレースされています(askeet/log/frontend_test.log)。特定のデータベースの接続設定を使用できます。たとえばテストの日付で他のデータベースを使用するためなどです。

上述で言及したすべての外部URIがfrontend_test.phpを表示するのはそういうわけです。testフロントコントローラを指定しなければなりません。さもなければ、デフォルトで本番用のindex.phpコントローラが使われます。そして、異なるデータベースを使用する、またはユニットテストのための個別のログを用意できません。

note

本番環境ではWebテストは起動しません。これらは開発ツールで、そういうものとして、ホストサーバーではなく開発者のコンピュータで動作しなければなりません。

それではまた明日

symfonyで構築されたPHPアプリケーションをユニットテストする完璧なソリューションは存在しません。今日のそれぞれの3つのソリューションは大きな利点を持ちますが、広範囲におよぶユニットテストの方法論があるとしたら、おそらくそれら3つすべてが必要になるでしょう。askeetに関しては、ユニットテストはSVNソースに少しづつ追加されます。定期的に確認するか、アプリケーションの堅牢性を強化するためにあなた自身のものを提案してください。

ユニットテストは回帰現象を避けるためにも使われます。メソッドをリファクタリングすることは以前現れなかった新たなバグを作り出します。本番環境においてアプリケーションの新しいリリースをデプロイする前にすべてのユニットテストを実行する理由です。このテストのことを回帰テストと呼ばれます。アプリケーションの開発を取り扱うときにおいて、もう少し話します。

明日は...、明日は明日の風が吹きます。今日のチュートリアルの質問がありましたら、askeetフォーラムで質問してください。