46.2. Zend_Test_PHPUnit

Zend_Test_PHPUnit は MVC アプリケーション向けのテストケースを用意します。 さまざまな責務に対応したテスト用のアサーションが含まれています。 実際に何ができるのかを知るには、 サンプルを見ていただくのが一番でしょう。

例 46.1. Application Login TestCase のサンプル

以下に示すのは UserController 用のシンプルなテストケースで、以下のような内容を検証します。

  • ログインフォームは、未認証のユーザに対しても表示されること。

  • ユーザがログインしたら、自分のプロファイルページにリダイレクトされること。 そしてプロファイルページには、関連する情報が表示されること。

この例は、いくつかの前提条件のもとに作成されています。 まず、起動時の設定のほとんどをプラグインに追い出しました。 これにより、環境設定が簡潔になったのおで テストケースの準備がしやすくなりました。 また、アプリケーションの起動処理が 1 行で書けるようになっています。 また、autoloading の設定を行うことで、 (コントローラやプラグインなどの) 適切なクラスをいちいち require することを考えなくてすむようにしています。

class UserControllerTest extends Zend_Test_PHPUnit_ControllerTestCase
{
    public function setUp()
    {
        $this->bootstrap = array($this, 'appBootstrap');
        parent::setUp();
    }

    public function appBootstrap()
    {
        $this->frontController
             ->registerPlugin(new Bugapp_Plugin_Initialize('development'));
    }

    public function testCallWithoutActionShouldPullFromIndexAction()
    {
        $this->dispatch('/user');
        $this->assertController('user');
        $this->assertAction('index');
    }

    public function testIndexActionShouldContainLoginForm()
    {
        $this->dispatch('/user');
        $this->assertAction('index');
        $this->assertQueryCount('form#loginForm', 1);
    }

    public function testValidLoginShouldGoToProfilePage()
    {
        $this->request->setMethod('POST')
              ->setPost(array(
                  'username' => 'foobar',
                  'password' => 'foobar'
              ));
        $this->dispatch('/user/login');
        $this->assertRedirectTo('/user/view');

        $this->resetRequest()
             ->resetResponse();

        $this->request->setMethod('GET')
             ->setPost(array());
        $this->dispatch('/user/view');
        $this->assertRoute('default');
        $this->assertModule('default');
        $this->assertController('user');
        $this->assertAction('view');
        $this->assertNotRedirect();
        $this->assertQuery('dl');
        $this->assertQueryContentContains('h2', 'User: foobar');
    }
}

        

この例は、もう少しシンプルに書くこともできます。 ここで示したアサーションのすべてが必須というわけではなく、 単に説明のためだけに用意しているものもあるからです。 アプリケーションのテストがいかにシンプルにできるのか、 この例でご理解いただけることでしょう。


46.2.1. テストケースの起動

Login サンプル で説明したように、すべての MVC テストケースは Zend_Test_PHPUnit_ControllerTestCase を継承しなければなりません。このクラスは PHPUnit_Framework_TestCase を継承しており、 PHPUnit が提供する仕組みやアサーションをすべて使用できます。 またそれに加えて、Zend Framework の MVC 実装に特化した scaffold 機能やアサーションもあります。

MVC アプリケーションをテストするには、まずそれを起動する必要があります。 いくつかの方法がありますが、どの方法になるかは public プロパティ $bootstrap で決まります。

まず、このプロパティでファイルを指定することができます。 そうすると、そのファイルはフロントコントローラをディスパッチせず、 単にフロントコントローラ (とアプリケーション固有の設定) を準備するだけの役割となります。

class UserControllerTest extends Zend_Test_PHPUnit_ControllerTestCase
{
    public $bootstrap = '/path/to/bootstrap/file.php'

    // ...
}

2 番目の方法として、アプリケーションを起動するための PHP コールバックを指定することができます。 この方法は Login サンプル で使用しています。使用するコールバックが関数や static メソッドである場合は、クラスレベルで設定することができます。

class UserControllerTest extends Zend_Test_PHPUnit_ControllerTestCase
{
    public $bootstrap = array('App', 'bootstrap');

    // ...
}

オブジェクトのインスタンスが必要な場合は、 setUp() メソッドを利用することを推奨します。

class UserControllerTest extends Zend_Test_PHPUnit_ControllerTestCase
{
    public function setUp()
    {
        // Bootstrap オブジェクトのインスタンスメソッド 'start' を使用します
        $bootstrap = new Bootstrap('test');
        $this->bootstrap = array($bootstrap, 'start');
        parent::setUp();
    }
}

parent::setUp(); に注目しましょう。 これは必須です。とうのも、Zend_Test_PHPUnit_Controller_TestCasesetUp() メソッドが残りの起動処理 (コールバックの呼び出しも含む) を実行するからです。

通常、setUp() メソッドは次のようにアプリケーションを起動します。 まずクリーンな環境を読み込んでリクエストの状態を初期化し、 プラグインやヘルパーをすべてリセットし、 フロントコントローラをリセットして リクエストオブジェクトとレスポンスオブジェクトを新しく作成します。 それが終わったら、$bootstrap で指定したファイルを読み込むか、 あるいは指定したコールバックを呼び出します。

テストの起動処理は、可能な限りそのアプリケーションの起動処理と同じになるようにしています。 しかし、いくつかの制約もあります。

  • リクエストオブジェクトやレスポンスオブジェクトに独自実装を用意しても、 それが使われることはありません。 Zend_Test_PHPUnit_Controller_TestCase は、 独自のリクエストオブジェクトとレスポンスオブジェクト (それぞれ Zend_Controller_Request_HttpTestCase および Zend_Controller_Response_HttpTestCase) を持っています。これらのオブジェクトには、 指定した方法でリクエスト環境を準備したり 指定した方法で人工的なレスポンスを返したりするメソッドが用意されています。

  • テストサーバに特定の設定を期待してはいけません。 言い換えると、テストの実行環境が特定のサーバ設定になっていることは保証されていないということです。 アプリケーション側から期待してもかまわないのは、 単にルータがリクエストをルーティングしてくれるということだけです。 サーバ固有のヘッダをリクエストオブジェクトに含めてはいけません。

アプリケーションが起動したら、 いよいよテストを作り始めることができます。

46.2.2. コントローラおよび MVC アプリケーションのテスト

起動用の設定を済ませたら、テストの開始です。 テストの方法は PHPUnit テストスイートによるものとほぼ同じですが、 ちょっとした違いがいくつかあります。

まず、テストケースの dispatch() メソッドを用いてテストの URL をディスパッチしなければなりません。

class IndexControllerTest extends Zend_Test_PHPUnit_Controller_TestCase
{
    // ...

    public function testHomePage()
    {
        $this->dispatch('/');
        // ...
    }
}

しかし、時にはこれ以外の情報 (GET 変数や POST 変数、 COOKIE 情報など) が必要になることもあります。 これらの情報をリクエストに含めることもできます。

class FooControllerTest extends Zend_Test_PHPUnit_Controller_TestCase
{
    // ...

    public function testBarActionShouldReceiveAllParameters()
    {
        // GET 変数を設定します
        $this->request->setQuery(array(
            'foo' => 'bar',
            'bar' => 'baz',
        ));

        // POST 変数を設定します
        $this->request->setPost(array(
            'baz'  => 'bat',
            'lame' => 'bogus',
        ));

        // クッキーの値を指定します
        $this->request->setCookie('user', 'matthew');
        // あるいは複数の値を指定します
        $this->request->setCookies(array(
            'timestamp' => time(),
            'host'      => 'foobar',
        ));

        // ヘッダを設定することもできます
        $this->request->setHeader('X-Requested-With', 'XmlHttpRequest');

        // リクエストメソッドを設定します
        $this->request->setMethod('POST');

        // ディスパッチします
        $this->dispatch('/foo/bar');

        // ...
    }
}

リクエストが準備できたので、次はアサーションを作成してみましょう。

46.2.3. アサーション

アサーションは、ユニットテストの肝となるものです。 この機能を使うことで、期待する結果と実際の結果が一致することを確かめるのです。 Zend_Test_PHPUnit_ControllerTestCase では数多くのアサーションを用意しており、 MVC アプリケーションやコントローラのテストをよりシンプルにできるようにしています。

46.2.3.1. CSS セレクタアサーション

CSS セレクタを使うと、 レスポンスの中身に何らかの結果が入っていることを簡単に検証することができます。 また、Javascript の UI や AJAX との統合も簡単に行えます。 大半の JS ツールキットは、 CSS セレクタ形式で DOM 要素を取得するための仕組みを持っています。 それと同じ構文で使用できるのです。

この機能は Zend_Dom_Query を用いて実装されており、'Query' アサーションに統合されています。 個々のアサーションの最初の引数に CSS セレクタを指定し、 アサーションの型に応じてオプション引数やエラーメッセージも指定します。 CSS セレクタの書き方の規則については、Zend_Dom_Query の操作方法の章 を参照ください。Query アサーションには次のようなものがあります。

  • assertQuery($path, $message = ''): 指定した CSS セレクタにマッチするひとつあるいは複数の DOM 要素が存在することを表明します。 $message を指定すると、 存在しなかった場合のメッセージの先頭にそれが追加されます。

  • assertQueryContentContains($path, $match, $message = ''): 指定した CSS セレクタにマッチするひとつあるいは複数の DOM 要素が存在し、そのすくなくともひとつに $match で指定した内容が含まれることを表明します。 $message を指定すると、 存在しなかった場合のメッセージの先頭にそれが追加されます。

  • assertQueryContentRegex($path, $pattern, $message = ''): 指定した CSS セレクタにマッチするひとつあるいは複数の DOM 要素が存在し、そのすくなくともひとつに正規表現 $pattern にマッチする内容が含まれることを表明します。 $message を指定すると、 存在しなかった場合のメッセージの先頭にそれが追加されます。

  • assertQueryCount($path, $count, $message = ''): 指定した CSS セレクタにマッチする DOM 要素が、ちょうど $count 個存在することを表明します。 $message を指定すると、 存在しなかった場合のメッセージの先頭にそれが追加されます。

  • assertQueryCountMin($path, $count, $message = ''): 指定した CSS セレクタにマッチする DOM 要素が、少なくとも $count 個以上存在することを表明します。 $message を指定すると、 存在しなかった場合のメッセージの先頭にそれが追加されます。 注意: $count に 1 を指定した場合は、単に assertQuery() を使うのと同じ意味となります。

  • assertQueryCountMax($path, $count, $message = ''): 指定した CSS セレクタにマッチする DOM 要素が、最大でも $count 個以下しか存在しないことを表明します。 $message を指定すると、 存在しなかった場合のメッセージの先頭にそれが追加されます。 注意: $count に 1 を指定した場合は、単に assertQuery() を使うのと同じ意味となります。

さらに、上であげたそれぞれに対する否定のアサーションを行う 'Not' 系のメソッドが存在します。 assertNotQuery()assertNotQueryContentContains()assertNotQueryContentRegex() そして assertNotQueryCount() です (min および max については対応するメソッドは存在しませんが、 それは自明なことだからです)。

46.2.3.2. XPath アサーション

CSS セレクタよりも XPath のほうが使いやすいという開発者もいることでしょう。 そこで、 Query アサーション のすべてのメソッドに対して、同等の動作をする XPath 版のメソッドを用意しています。

  • assertXpath($path, $message = '')

  • assertNotXpath($path, $message = '')

  • assertXpathContentContains($path, $match, $message = '')

  • assertNotXpathContentContains($path, $match, $message = '')

  • assertXpathContentRegex($path, $pattern, $message = '')

  • assertNotXpathContentRegex($path, $pattern, $message = '')

  • assertXpathCount($path, $count, $message = '')

  • assertNotXpathCount($path, $count, $message = '')

  • assertXpathCountMin($path, $count, $message = '')

  • assertNotXpathCountMax($path, $count, $message = '')

46.2.3.3. リダイレクトアサーション

アクションがリダイレクトを行うこともよくあります。 リダイレクト先をたどらなくても、 Zend_Test_PHPUnit_ControllerTestCase のさまざまなアサーションでそれをテストすることができます。

  • assertRedirect($message = ''): リダイレクトが発生することを表明します。

  • assertNotRedirect($message = ''): リダイレクトが発生しないことを表明します。

  • assertRedirectTo($url, $message = ''): リダイレクトが発生し、Location ヘッダの値が $url で指定したものであることを表明します。

  • assertNotRedirectTo($url, $message = ''): 「リダイレクトが発生しない」あるいは「リダイレクト先の Location ヘッダの値が $url で指定したものではない」 のいずれかであることを表明します。

  • assertRedirectRegex($pattern, $message = ''): リダイレクトが発生し、Location ヘッダの値が $pattern で指定した正規表現にマッチするものであることを表明します。

  • assertNotRedirectRegex($pattern, $message = ''): 「リダイレクトが発生しない」あるいは「リダイレクト先の Location ヘッダの値が $pattern で指定した正規表現にマッチしない」のいずれかであることを表明します。

46.2.3.4. レスポンスヘッダアサーション

リダイレクトヘッダのチェックだけでなく、 特定の HTTP のレスポンスコードやヘッダのチェックが必要になることもあります。 たとえば「アクションの結果のレスポンスが 404 か 500 のいずれかであること」 「JSON レスポンスに適切な Content-Type ヘッダが設定されていること」 などです。次のようなアサーションが使用できます。

  • assertResponseCode($code, $message = ''): 指定した HTTP レスポンスコードが返されることを表明します。

  • assertHeader($header, $message = ''): レスポンスに指定したヘッダが含まれることを表明します。

  • assertHeaderContains($header, $match, $message = ''): レスポンスに指定したヘッダが含まれ、 指定した文字列がその中に含まれることを表明します。

  • assertHeaderRegex($header, $pattern, $message = ''): レスポンスに指定したヘッダが含まれ、 その値が指定した正規表現にマッチすることを表明します。

さらに、上であげたそれぞれに対する否定のアサーションを行う 'Not' 系のメソッドが存在します。

46.2.3.5. リクエストアサーション

最後に実行されたアクションやコントローラ、 そしてモジュールについてのアサーションを行えると便利です。 さらに、どのルートにマッチしたのかを確認したいこともあるでしょう。 以下のアサーションが、その手助けとなります。

  • assertModule($module, $message = ''): 指定したモジュールが、 最後にディスパッチされたアクションで用いられたことを表明します。

  • assertController($controller, $message = ''): 指定したコントローラが、 最後にディスパッチされたアクションで選択されたことを表明します。

  • assertAction($action, $message = ''): 指定したアクションが、直近にディスパッチされたことを表明します。

  • assertRoute($route, $message = ''): 指定した名前のルートが、ルータでマッチしたことを表明します。

そして、それぞれについて否定を表す 'Not' 系のメソッドが存在します。

46.2.4. 例

テスト環境の設定方法とアサーションの作成方法を説明しましたが、 まだまだ戦いは続きます。それでは、 実際のテストシナリオをもとにテストの方法を確認していきましょう。

例 46.2. UserController のテスト

ウェブサイトの一般的なタスクである、 ユーザ認証とユーザ登録について考えてみましょう。 今回の例では UserController でこれらを処理することにします。 要件は次のとおりです。

  • ユーザがまだ認証を済ませていない場合は、 どんなアクションが指定されたかにかかわらず 常にコントローラのログインページにリダイレクトされる。

  • ログインフォームのページには、 ログインフォームと新規登録フォームの両方が表示される。

  • 間違った認証情報を入力すると、 ログインフォームに戻る。

  • 正しい認証情報を入力すると、 ユーザのプロファイルページにリダイレクトされる。

  • プロファイルページには、そのユーザのユーザ名が表示される。

  • 認証済みのユーザがログインフォームを訪れると、 そのユーザのプロファイルページにリダイレクトされる。

  • ログアウトしたら、ログインページにリダイレクトされる。

  • 無効なデータが渡された場合は、登録に失敗する。

もちろんこれら以外にも別のテストも必要でしょうが、 今のところはひとまずこれだけにしておきます。

今回のアプリケーションでは、プラグイン 'Initialize' を定義してそれを routeStartup() で実行します。 これによって起動処理をオブジェクト指向でカプセル化することができ、 コールバックを提供しやすくなります。 それではまず、このクラスの基本部分を見ていきましょう。

class Bugapp_Plugin_Initialize extends Zend_Controller_Plugin_Abstract
{
    /**
     * @var Zend_Config
     */
    protected static $_config;

    /**
     * @var string 現在の環境
     */
    protected $_env;

    /**
     * @var Zend_Controller_Front
     */
    protected $_front;

    /**
     * @var string アプリケーションのルートパス
     */
    protected $_root;

    /**
     * コンストラクタ
     *
     * 環境、ルートパス、設定を初期化します
     *
     * @param  string $env
     * @param  string|null $root
     * @return void
     */
    public function __construct($env, $root = null)
    {
        $this->_setEnv($env);
        if (null === $root) {
            $root = realpath(dirname(__FILE__) . '/../../../');
        }
        $this->_root = $root;

        $this->initPhpConfig();

        $this->_front = Zend_Controller_Front::getInstance();
    }

    /**
     * ルートの開始処理
     *
     * @return void
     */
    public function routeStartup(Zend_Controller_Request_Abstract $request)
    {
        $this->initDb();
        $this->initHelpers();
        $this->initView();
        $this->initPlugins();
        $this->initRoutes();
        $this->initControllers();
    }

    // この後にメソッド定義が続きます...
}

        

これで、起動用コールバックを次のように作れるようになります。

class UserControllerTest extends Zend_Test_PHPUnit_ControllerTestCase
{
    public function appBootstrap()
    {
        $controller = $this->getFrontController();
        $controller->registerPlugin(new Bugapp_Plugin_Initialize('development'));
    }

    public function setUp()
    {
        $this->bootstrap = array($this, 'appBootstrap');
        parent::setUp();
    }

    // ...
}

        

ここまでできたら、テストを書くことができます。 しかし、ユーザがログインした状態でのテストはどのように書けばいいでしょう? 簡単な方法は、アプリケーションのロジックを利用する方法です。 resetRequest() メソッドや resetResponse() メソッドを使ってちょっとした細工を行い、 別のリクエストをディスパッチさせます。

class UserControllerTest extends Zend_Test_PHPUnit_ControllerTestCase
{
    // ...

    public function loginUser($user, $password)
    {
        $this->request->setMethod('POST')
                      ->setPost(array(
                          'username' => $user,
                          'password' => $password,
                      ));
        $this->dispatch('/user/login');
        $this->assertRedirectTo('/user/view');

        $this->resetRequest()
             ->resetResponse();

        $this->request->setPost(array());
        
        // ...
    }

    // ...
}

        

ではテストを書いてみましょう。

class UserControllerTest extends Zend_Test_PHPUnit_ControllerTestCase
{
    // ...

    public function testCallWithoutActionShouldPullFromIndexAction()
    {
        $this->dispatch('/user');
        $this->assertController('user');
        $this->assertAction('index');
    }

    public function testLoginFormShouldContainLoginAndRegistrationForms()
    {
        $this->dispatch('/user');
        $this->assertQueryCount('form', 2);
    }

    public function testInvalidCredentialsShouldResultInRedisplayOfLoginForm()
    {
        $request = $this->getRequest();
        $request->setMethod('POST')
                ->setPost(array(
                    'username' => 'bogus',
                    'password' => 'reallyReallyBogus',
                ));
        $this->dispatch('/user/login');
        $this->assertNotRedirect();
        $this->assertQuery('form');
    }

    public function testValidLoginShouldRedirectToProfilePage()
    {
        $this->loginUser('foobar', 'foobar');
    }

    public function testAuthenticatedUserShouldHaveCustomizedProfilePage()
    {
        $this->loginUser('foobar', 'foobar');
        $this->request->setMethod('GET');
        $this->dispatch('/user/view');
        $this->assertNotRedirect();
        $this->assertQueryContentContains('h2', 'foobar');
    }

    public function 
        testAuthenticatedUsersShouldBeRedirectedToProfilePageWhenVisitingLoginPage()
    {
        $this->loginUser('foobar', 'foobar');
        $this->request->setMethod('GET');
        $this->dispatch('/user');
        $this->assertRedirectTo('/user/view');
    }

    public function testUserShouldRedirectToLoginPageOnLogout()
    {
        $this->loginUser('foobar', 'foobar');
        $this->request->setMethod('GET');
        $this->dispatch('/user/logout');
        $this->assertRedirectTo('/user');
    }

    public function testRegistrationShouldFailWithInvalidData()
    {
        $data = array(
            'username' => 'This will not work',
            'email'    => 'this is an invalid email',
            'password' => 'Th1s!s!nv@l1d',
            'passwordVerification' => 'wrong!',
        );
        $request = $this->getRequest();
        $request->setMethod('POST')
                ->setPost($data);
        $this->dispatch('/user/register');
        $this->assertNotRedirect();
        $this->assertQuery('form .errors');
    }
}

        

これらは簡潔なものであり、大半は実際の中身までは見ていないことに注意しましょう。 その代わりに、レスポンスコードやヘッダ、そして DOM ノードを見ています。 これにより、期待通りの構造になっているかどうかを検証できるようになり、 新たなコンテンツが追加されるたびにテストを実行しなおすことが避けられます。

ドキュメントの構造を使用してテストを行なっていることに注目しましょう。 たとえば最後のテストでは、"errors" というクラスが指定されているノードをフォームから探しました。 これにより、単にフォームの検証エラーが発生したかどうかだけを確認することができ、 どんなエラーが発生したのかという中身までは気にしなくてすむのです。

このアプリケーションでは、データベースを使うことがあるかもしれません。 そんな場合は、何らかの scaffold を使用してデータベースの初期状態を作成し、 テスト用の設定を行うという作業が各テストの最初に発生します。 PHPUnit にはそのための機能が既に用意されています。 PHPUnit のドキュメントを参照ください。 テスト時と実運用時には別のデータベースを使用することを推奨します。 また、特に (ファイルあるいはインメモリ形式の) SQLite を使うことを推奨します。どちらも別のサーバを必要とせず、 大半の SQL 構文を使用することができます。