FuelPHP
fuelphp1.8

fuelPHPでPHPUnitを使ったユニット・コントローラーテストをするには

More than 1 year has passed since last update.

fuelPHPでユニット・コントローラーテストしようと思ったら意外に情報がなかったのでまとめました。

前準備

PHPUnitのインストール

プロジェクトのcomposer.jsonのrequireにphpunitを追加します。

composer.json
    "require": {
        "php": ">=5.3.3",
        "composer/installers": "~1.0",
        "fuel/docs": "1.7.2",
        "fuel/core": "1.7.2",
        "fuel/auth": "1.7.2",
        "fuel/email": "1.7.2",
        "fuel/oil": "1.7.2",
        "fuel/orm": "1.7.2",
        "fuel/parser": "1.7.2",
        "fuelphp/upload": "2.0.1",
        "monolog/monolog": "1.5.*",
        "michelf/php-markdown": "1.4.0",
        "phpunit/phpunit": "3.7.*"  追加
    }

composer経由でPHPUnitをインストール

composerを実行してPHPUnitをインストールします。

$ php composer.phar install

oilコマンドから実行可能にしておきます。

fuel/app/config.oil.php
<?php
return array(
    'phpunit' => array(
        'binary_path' => 'fuel/vendor/phpunit/phpunit/composer/bin/phpunit',
    ),
);

ユニットテスト

コントローラーのテストを行う前に、ユニットテストをやってみます。

メンバーモデルにIDとパスワードプロパティが存在し、どちらのプロパティもrequiredなケースを考えます。

$ oil g scaffold member member_id:varchar[32] password:varchar[32]
$ oil refine migrate

生成されたmemberモデルを見てみます。

fuel/app/classes/model/member.php
use Orm\Model;

class Model_Member extends Model
{
    protected static $_properties = array(
        'id',
        'member_id',
        'password',
        'created_at',
        'updated_at',
    );

    protected static $_observers = array(
        'Orm\Observer_CreatedAt' => array(
            'events' => array('before_insert'),
            'mysql_timestamp' => false,
        ),
        'Orm\Observer_UpdatedAt' => array(
            'events' => array('before_save'),
            'mysql_timestamp' => false,
        ),
    );

    public static function validate($factory)
    {
        $val = Validation::forge($factory);
        $val->add_field('member_id', 'Member Id', 'required|max_length[32]');
        $val->add_field('password', 'Password', 'required|max_length[32]');

        return $val;
    }

}

member_idpasswordrequiredになっているのがわかります。

※ 実際にはパスワードは暗号化しないとダメですが、便宜的に平文とします。

この状態でmemberのモデルテストを書いてみます。

fuel/app/tests/model/member.php
<?php
/**
 * Model Post class tests
 * 
 * @group Model
 * @group Member
 */

class Test_Model_Member extends \TestCase
{
  public function test_create_member(){
    $count = count(Model_Member::find("all"));

    $member = Model_Member::forge(array(
      'member_id' => "member_id",
      'password' => 'password',
    ));

    $member->save();

    $update_count = count(Model_Member::find("all"));

    $this->assertEquals($count+1,$update_count);

  }
}

テストを実行します。

$ php oil test
Tests Running...This may take a few moments.
PHPUnit 3.7.37 by Sebastian Bergmann.

Configuration read from /home/unko/sample/fuel/core/phpunit.xml

...............................................................  63 / 376 ( 16%)
............................................................... 126 / 376 ( 33%)
............................................................... 189 / 376 ( 50%)
............................................................... 252 / 376 ( 67%)
............................................................... 315 / 376 ( 83%)
.............................................................

Time: 454 ms, Memory: 19.50Mb

OK (376 tests, 448 assertions)

通りました。

ここで、試しにmember_idnullを許容してみます。

magic migrationファイルを作ります。

fuelPHPのmagic migrationには、カラムの属性だけを変えるmigrationがないので、renamemagic migrationをベースにします。

$ oil  g migration rename_field_member_id_to_member_id_in_members
        Creating migration: /home/unko/sample/fuel/app/migrations/002_rename_field_member_id_to_member_id_in_members.php

null制約をはずす

fuel/app/migrations/002_rename_field_member_id_to_member_id_in_members.php
<?php

namespace Fuel\Migrations;

class Rename_field_member_id_to_member_id_in_members
{
    public function up()
    {
        \DBUtil::modify_fields('members', array(
          'member_id' => array('name' => 'member_id', 'type' => 'varchar', 'constraint' => 32, 'null' => true) //← nullを許容
        ));
    }

    public function down()
    {
    \DBUtil::modify_fields('members', array(
            'member_id' => array('name' => 'member_id', 'type' => 'varchar', 'constraint' => 32)
        ));
    }
}

実行します。

$ oil r migrate
Performed migrations for app:default:
002_rename_field_member_id_to_member_id_in_members

この状態で、先ほどのtests/model/member.phpを修正し、member_idを設定しないでテストを実行します。

fuel/app/tests/model/member.php
class Test_Model_Member extends \TestCase
{
  public function test_create_member(){
    $count = count(Model_Member::find("all"));

    $member = Model_Member::forge(array(
      // 'member_id' => "member_id", ← member_idをコメントアウト
      'password' => 'password',
    ));

    $member->save();

    $update_count = count(Model_Member::find("all"));

    $this->assertEquals($count+1,$update_count);

  }
}

membersテーブルはmember_idnullを許容しますが、memberモデルのmember_idrequiredなのでバリデーションエラーになるはず。

$ php oil test
Tests Running...This may take a few moments.
PHPUnit 3.7.37 by Sebastian Bergmann.

Configuration read from /home/unko/sample/fuel/core/phpunit.xml

...............................................................  63 / 376 ( 16%)
............................................................... 126 / 376 ( 33%)
............................................................... 189 / 376 ( 50%)
............................................................... 252 / 376 ( 67%)
............................................................... 315 / 376 ( 83%)
.............................................................

Time: 440 ms, Memory: 19.75Mb

OK (376 tests, 448 assertions)

ファッ?!

テーブルを見てみると・・

mysql> select * from members;
+----+-----------+----------+------------+------------+
| id | member_id | password | created_at | updated_at |
+----+-----------+----------+------------+------------+
|  1 | member_id | password | 1405664980 | 1405664980 |
|  2 | member_id | password | 1405664990 | 1405664990 |
|  3 | member_id | password | 1405664998 | 1405664998 |
|  4 | NULL      | password | 1405665773 | 1405665773 |← ?!
+----+-----------+----------+------------+------------+

実はfuelPHPはモデルとバリデーションが紐づいていないため、このユニットテストではバリデーションがチェックできません。ORMの$_propertiesにバリデーションを追加し、Fieldset::add_modelしたモデルを使用することでモデルとバリデーション、フォームを紐づけることができますが、今回はscaffoldしたソースを使用しているため、モデルとバリデーションが紐づいていません。

memberのコントローラーの記述からもそのことがわかります。

fuel/app/classes/controller/member.php
    public function action_create()
    {
        if (Input::method() == 'POST')
        {
            $val = Model_Member::validate('create');

            if ($val->run()) //←ここでバリデーション実行
            {
                $member = Model_Member::forge(array(
                    'member_id' => Input::post('member_id'),
                    'password' => Input::post('password'),
                ));

                if ($member and $member->save()) //←モデル保存
                {
                    Session::set_flash('success', 'Added member #'.$member->id.'.');

                    Response::redirect('member');
                }

                else
                {
                    Session::set_flash('error', 'Could not save member.');
                }
            }
            else
            {
                Session::set_flash('error', $val->error());
            }
        }

        $this->template->title = "Members";
        $this->template->content = View::forge('member/create');

    }

ではfuelPHPでバリデーションの単体テストをするにはどうするか?

バリデーション専用のテストを別途用意する必要があります。

fuel/app/tests/model/member.php
 //テスト追加
  public function test_validation_member(){

    $val = Model_Member::validate('create');

    $member = array(
      'password' => 'password',
    );

    $res = $val->run($member);

    $this->assertEquals($res,false);
  }

テストを実行します。

$ php oil test
Tests Running...This may take a few moments.
PHPUnit 3.7.37 by Sebastian Bergmann.

Configuration read from /home/unko/sample/fuel/core/phpunit.xml

...............................................................  63 / 377 ( 16%)
............................................................... 126 / 377 ( 33%)
............................................................... 189 / 377 ( 50%)
............................................................... 252 / 377 ( 66%)
............................................................... 315 / 377 ( 83%)
..............................................................

Time: 443 ms, Memory: 19.75Mb

OK (377 tests, 449 assertions)

モデルのユニットテストにバリデーションを含めるのが正しいのかどうかはさておき、バリデーションのチェックとモデルのユニットテストが確認できました。

コントローラーテスト

次にコントローラーのアクションごとの機能テストを行いたいと思います。

前準備

fuelPHP界隈では有名な話らしいのですが、デフォルトのソースだと、リダイレクト時にexitされてしまってunitTestごと終了するのでテストがうまく動いてくれません。

ためしに存在しないidでviewアクションを呼び出すテストを書いてみます。

fuel/app/tests/controller/member.php
<?php
/**
 * Controller Post class tests
 * 
 * @group Controller
 * @group Member
 */

class Test_Controller_Member extends \TestCase
{
  public function test_create_member(){
    $response = Request::forge('member/view')
      ->set_method('GET')
      ->execute(["id"=>100])
      ->response();
  }
}

実行します。

$ php oil test group=Controller
Tests Running...This may take a few moments.
PHPUnit 3.7.37 by Sebastian Bergmann.

Configuration read from /home/unko/sample/fuel/core/phpunit.xml

...[unko@localhost sample]$

テスト自体が途中で終了してしまいました。

これではテストにならないので、回避するためのフック用パッケージを作成する必要があります。

フックパッケージはこちらを参考にさせていただきました。
FuelPHP【チュートリアル】-- コントローラーのテストの方法 --

コントローラテストのみが使用するパッケージを作成する

oilコマンドを使ってパッケージのひな形を作成します。

$ oil g package my
        Creating file: /home/unko/sample/fuel/packages/my/classes/my.php
        Creating file: /home/unko/sample/fuel/packages/my/config/my.php
        Creating file: /home/unko/sample/fuel/packages/my/bootstrap.php

classes/my.phpを開き、redirectメソッドをオーバーライドします。

fuel/packages/classes/my.php
<?php

namespace My;

class MyException extends \FuelException {}

class Response extends \Fuel\Core\Response
{
    /**
     * Override Fuel\Core\Response redirect method
     *
     * @param   string  $url     The url
     * @param   string  $method  The redirect method
     * @param   int     $code    The redirect status code
     *
     * @return  void
     */
    public static function redirect($url = '', $method = 'location', $code = 302)
    {
        $response = new static;
        $response->set_status($code);

        if (strpos($url, '://') === false)
        {
            $url = $url !== '' ? \Uri::create($url) : \Uri::base();
        }

        strpos($url, '*') !== false and $url = \Uri::segment_replace($url);

        if ($method == 'location')
        {
            $response->set_header('Location', $url);
        }
        elseif ($method == 'refresh')
        {
            $response->set_header('Refresh', '0;url='.$url);
        }
        else
        {
            return;
        }

        if (\Fuel\Core\Fuel::$env != 'test')
        {
            $response->send(true);
            exit;
        }

        $response->send(true);
    }
}

合わせてbootstrap.phpも修正します。

fuel/packages/bootstrap.php
<?php

Autoloader::add_core_namespace('My');

Autoloader::add_classes(array(
    'My\\Response' => __DIR__ . '/classes/my.php',
    'My\\MyException' => __DIR__ . '/classes/my.php',

));

このパッケージはcoreの挙動をオーバーライドするので、本番環境には含めず、config.phpalways_loadには含めず、テスト環境のみで使用したいと思います。

PHPUnitのみmyパッケージを使用させるようにbootstrap_phpunit.phpをプロジェクト用にコピーし、それを修正します。

$ cp fuel/core/bootstrap_phpunit.php fuel/app/tests/
fuel/app/tests/bootstrap_phpunit.php
// 末尾に追加
Package::load('my');

phpunit.xmlもプロジェクト専用のものを用意します。

$ cp fuel/core/phpunit.xml  fuel/app/
fuel/app/phpunit.xml
<?xml version="1.0" encoding="UTF-8"?>

<phpunit colors="true" stopOnFailure="false" bootstrap="./tests/bootstrap_phpunit.php"> ← プロジェクト専用のbootstrap_phpunit.phpに変更
    <php>
        <server name="doc_root" value="fuel/public"/>
        <server name="app_path" value="fuel/app"/>
        <server name="core_path" value="fuel/core"/>
        <server name="package_path" value="fuel/packages"/>
        <server name="vendor_path" value="fuel/vendor"/>
        <server name="FUEL_ENV" value="test"/>
    </php>
    <testsuites>
        <testsuite name="core">
            <directory suffix=".php">../core/tests</directory>
        </testsuite>
        <testsuite name="packages">
            <directory suffix=".php">../packages/*/tests</directory>
        </testsuite>
        <testsuite name="app">
            <directory suffix=".php">../app/tests</directory>
        </testsuite>
        <testsuite name="modules">
            <directory suffix=".php">../app/modules/*/tests</directory>
        </testsuite>
    </testsuites>
</phpunit>

テストを再実行します。

$ php oil test group=Controller
Tests Running...This may take a few moments.
PHPUnit 3.7.37 by Sebastian Bergmann.

Configuration read from /home/unko/sample/fuel/core/phpunit.xml

....

Time: 253 ms, Memory: 16.00Mb

OK (3 tests, 0 assertions)

今度は正常に完了しました。

リダイレクトとは直接関係ありませんが、テストから呼び出されるViewはなぜかAssetクラスが動いてくれないので、パス解決のエラー表示を無効にしておきます。

fuel/core/config/asset.php
    'fail_silently' => true, ← doc_rootが正しく指定されていればこの処理は不要

phpunit.xmlのdoc_rootが誤っていたため発生してました。doc_rootを正しくfuel/publicに設定していればこの箇所は不要です。

viewアクションのコントローラーテストを行う

リダイレクトしてもテストが終了しなくなったので、viewアクションを呼び出し、存在しているIDならば詳細画面を、そうでなければリダイレクトするテストを行います。

テストファイルを書き換えます。

fuel/app/tests/controller/member.php
  public function test_view_member(){
    $response = Request::forge('member/view')
      ->set_method('GET')
      ->execute(["id" => 1]) //← 存在するIDに
      ->response();

    $this->assertTag(array('tag' => 'span','class' => 'muted', 'content' =>'#1'),$response->body->__toString()); //← 存在する場合、表示されるべきHTMLタグのアサーション
  }  

assertTagメソッドを使い、正常系であれば表示されるはずのフラッシュメッセージがHTMLに含まれているかチェックします。

テストを実行します。

$ php oil test group=Controller
Tests Running...This may take a few moments.
PHPUnit 3.7.37 by Sebastian Bergmann.

Configuration read from /home/unko/sample/fuel/core/phpunit.xml

....

Time: 246 ms, Memory: 16.25Mb

OK (3 tests, 1 assertion)

成功しました。

次に、DBに登録されていないidでmemberのviewにアクセスした場合のテストを行います。

memberコントローラーは、リダイレクトが発生するとtemplateにコンテンツを設定せずに終了するため、assertTagsによるチェックができません。

なのでリダイレクトされたかどうかを判定するための専用プロパティをmy.phpに追加します。

fuel/packages/my/classes/my.php
<?php

namespace My;

class MyException extends \FuelException {}

class Response extends \Fuel\Core\Response
{
    /**
     * Override Fuel\Core\Response redirect method
     *
     * @param   string  $url     The url
     * @param   string  $method  The redirect method
     * @param   int     $code    The redirect status code
     *
     * @return  void
     */
    public static function redirect($url = '', $method = 'location', $code = 302)
    {
        $response = new static;
        $response->set_status($code);

        if (strpos($url, '://') === false)
        {
            $url = $url !== '' ? \Uri::create($url) : \Uri::base();
        }

        strpos($url, '*') !== false and $url = \Uri::segment_replace($url);

        if ($method == 'location')
        {
            $response->set_header('Location', $url);
        }
        elseif ($method == 'refresh')
        {
            $response->set_header('Refresh', '0;url='.$url);
        }
        else
        {
            return;
        }

        //テストではない場合はexitを実行
        if (\Fuel\Core\Fuel::$env != 'test')
        {
            $response->send(true);
            exit;
        }

    Response::$redirect_status = $code; //←ローカル変数の$codeをスタティックメンバに代入
    Response::$redirect_url = $url; //←ローカル変数の$urlをスタティックメンバに代入
        $response->send(true);
    }

    private static $redirect_status = null;// ← リダイレクト時のステータスコードを参照するためのメンバを追加
    private static $redirect_url =""; //← リダイレクト時のURLを参照するためのメンバを追加

    public static function get_redirect_status(){ //← リダイレクト時のステータスコードを参照するためのゲッター
      return Response::$redirect_status;
    }

    public static function get_redirect_url(){ //← リダイレクト時のURLを参照するためのゲッター
      return Response::$redirect_url;
    }
}

存在しないメンバIDを指定し、member_indexへリダイレクトするテストを追加します。

fuel/php/tests/controller/member.php
// テストを追加
  public function test_view_member_was_nothing(){
    $response = Request::forge('member/view')
      ->set_method('GET')
      ->execute(["id" => 999999])
      ->response();

    $this->assertEquals("302",Response::get_redirect_status()); //← 追加した専用メソッドでステータスコードをチェック
    $this->assertEquals("member",Response::get_redirect_url());//← 専用メソッドでリダイレクト先のURLをチェック
  }

テストを実行します。

$ oil t group=Controller
Tests Running...This may take a few moments.
PHPUnit 3.7.37 by Sebastian Bergmann.

Configuration read from /home/unko/sample/fuel/app/phpunit.xml

....

Time: 278 ms, Memory: 17.50Mb

OK (5 tests, 3 assertions)

成功しました。

ここまでで、存在するIDがあった場合はmember詳細を、なければインデックスにリダイレクトされるところまで、コントローラーのテストができたことになります。

isnertアクションのコントローラーテストを行う

今度はコントローラー経由でmemberを新規登録し、誤った値が入力されたらエラーになるテストを行います。

member追加の正常系テストを記述します。

fuel/app/tests/controller/member.php
  // insertのテストを追加
  public function test_create_member(){
    $count = count(Model_Member::find("all"));

    $_POST["member_id"] = "test_user"; //← 入力値を$_POSTで直接指定
    $_POST["password"] = "password";

    $response = Request::forge('member/create')
      ->set_method('POST')
      ->execute()
      ->response();

    $this->assertTag(array('tag' => 'strong', 'content' =>'Success'),$response->body->__toString()); //← 正常終了時のフラッシュメッセージ
    $this->assertEquals($count+1,count(Model_Member::find("all"))); //← insertが実行され、レコードが1件追加になっていることを確認
  }

実行します。

$ oil t group=Controller
Tests Running...This may take a few moments.
PHPUnit 3.7.37 by Sebastian Bergmann.

Configuration read from /home/unko/sample/fuel/app/phpunit.xml

......

Time: 274 ms, Memory: 17.50Mb

OK (6 tests, 5 assertions)

正常に終了しました。

今度はパスワードを未入力とし、バリデーションエラーになるテストを実行します。

fuel/app/tests/controller/member.php
  // バリデーションエラーテストを追加
  public function test_create_member_error(){
    $count = count(Model_Member::find("all"));

    $_POST["member_id"] = "test_user";
    // わざとpasswordを指定しない

    $response = Request::forge('member/create')
      ->set_method('POST')
      ->execute()
      ->response();

    $this->assertTag(array('tag' => 'strong', 'content' =>'Error'),$response->body->__toString());
    $this->assertEquals($count,count(Model_Member::find("all")));
  }

実行します。

$ oil t group=Controller
Tests Running...This may take a few moments.
PHPUnit 3.7.37 by Sebastian Bergmann.

Configuration read from /home/unko/sample/fuel/app/phpunit.xml

......E

Time: 276 ms, Memory: 17.50Mb

There was 1 error:

1) Test_Controller_Member::test_create_member_error
DomainException: Form instance already exists, cannot be recreated. Use instance() instead of forge() to retrieve the existing instance.

/home/unko/sample/fuel/core/classes/validation.php:57
/home/unko/sample/fuel/app/classes/model/member.php:27
/home/unko/sample/fuel/app/classes/controller/member.php:32
/home/unko/sample/fuel/core/classes/request.php:444
/home/unko/sample/fuel/app/tests/controller/member.php:51

FAILURES!
Tests: 7, Assertions: 5, Errors: 1.

例外が発生してしまいました。メッセージを詳しく見てみます。

DomainException: Form instance already exists, cannot be recreated. Use instance() instead of forge() to retrieve the existing instance.

/home/unko/sample/fuel/core/classes/validation.php:57
/home/unko/sample/fuel/app/classes/model/member.php:27

静的呼び出しをしたつもりが、instanceを使用しろと言われるのもちょっとよくわからないんですが、fuelPHPではcorevalidation.phpと、それが依存しているfieldset.phpで遅延静的束縛を使用している個所があり、連続してvalidationrunするテストメソッドを実行すると、以前にnew staticしたクラスが初期化されず、例外を発生させているようです。

これを回避するために、遅延静的束縛されているプロパティを初期化するメソッドを追加します。

ところでnew staticって記述、ちょっと違和感があるんですがこれって普通なんですかね。

fuel/packages/my/classes/fieldset_ex.php
<?php

namespace My;

class Fieldset extends \Fuel\Core\Fieldset {
  //fieldsetの$_instancesを初期化するメソッド
  public static function reset(){
    parent::$_instances = array();
  }
}

myパッケージのbootstrap.phpfieldset_ex.phpを追加します。

fuel/packages/my/bootstrap.php
<?php

Autoloader::add_core_namespace('My');

Autoloader::add_classes(array(
    'My\\Response' => __DIR__ . '/classes/my.php',
    'My\\MyException' => __DIR__ . '/classes/my.php',
    'My\\Fieldset' => __DIR__ . '/classes/fieldset_ex.php', //← 追加
));

テストtest_create_member_errorRequest::execute()よりも前にFieldset::reset()を追加します。

fuel/app/tests/controller/member.php
  public function test_create_member_error(){

    Fieldset::reset(); //←追加

    $count = count(Model_Member::find("all"));

    $_POST["member_id"] = "test_user2";

    $response = Request::forge('member/create')
      ->set_method('POST')
      ->execute()
      ->response();

    $this->assertTag(array('tag' => 'strong', 'content' =>'Error'),$response->body->__toString());
    $this->assertEquals($count,count(Model_Member::find("all")));
  }

テストを再実行します。

$ oil t group=Controller
Tests Running...This may take a few moments.
PHPUnit 3.7.37 by Sebastian Bergmann.

Configuration read from /home/unko/sample/fuel/app/phpunit.xml

......E

Time: 290 ms, Memory: 17.50Mb

There was 1 error:

1) Test_Controller_Member::test_create_member_error
Fuel\Core\Database_Exception: SQLSTATE[23000]: Integrity constraint violation: 1048 Column 'password' cannot be null with query: "INSERT INTO `members` (`member_id`, `password`, `created_at`, `updated_at`) VALUES ('test_user2', null, 1406185977, 1406185977)"

/home/unko/sample/fuel/core/classes/database/pdo/connection.php:270
/home/unko/sample/fuel/core/classes/database/pdo/connection.php:237
/home/unko/sample/fuel/core/classes/database/query.php:287
/home/unko/sample/fuel/packages/orm/classes/query.php:1424
/home/unko/sample/fuel/packages/orm/classes/model.php:1357
/home/unko/sample/fuel/packages/orm/classes/model.php:1301
/home/unko/sample/fuel/app/classes/controller/member.php:41
/home/unko/sample/fuel/core/classes/request.php:444
/home/unko/sample/fuel/app/tests/controller/member.php:53

Caused by
PDOException: SQLSTATE[23000]: Integrity constraint violation: 1048 Column 'password' cannot be null

/home/unko/sample/fuel/core/classes/database/pdo/connection.php:237
/home/unko/sample/fuel/core/classes/database/query.php:287
/home/unko/sample/fuel/packages/orm/classes/query.php:1424
/home/unko/sample/fuel/packages/orm/classes/model.php:1357
/home/unko/sample/fuel/packages/orm/classes/model.php:1301
/home/unko/sample/fuel/app/classes/controller/member.php:41
/home/unko/sample/fuel/core/classes/request.php:444
/home/unko/sample/fuel/app/tests/controller/member.php:53

FAILURES!
Tests: 7, Assertions: 5, Errors: 1.

今度はINSERTのNULL制約でエラーが発生しました。

fieldsetinstancesを初期化してもfieldsetが内部で使用している$inputも遅延静的束縛となっているため、$input$_POSTの内容にアンマッチが発生し、バリデーションを通過してSQLエラーになるようです。

これを解決するため、$inputをリセットするためのメソッドを追加します。

ただしfuelPHPのcore/Inputクラスは拡張できないので、Inputを継承したInputEXクラスを追加します。

fuel/packages/my/classes/input_ex.php
<?php

namespace My;

class InputEX extends \Fuel\Core\Input {
  public static function reset(){
    parent::$input = null;
  }
}

myパッケージのbootstrap.phpinput_ex.phpを追加します。

fuel/packages/my/bootstrap.php
<?php

Autoloader::add_core_namespace('My');

Autoloader::add_classes(array(
    'My\\Response' => __DIR__ . '/classes/my.php',
    'My\\MyException' => __DIR__ . '/classes/my.php',
    'My\\Fieldset' => __DIR__ . '/classes/fieldset_ex.php',
    'My\\InputEx' => __DIR__ . '/classes/input_ex.php', //← 追加

));

テストtest_create_member_errorInputEx::reset()を追加します。

fuel/app/tests/controller/member.php
  public function test_create_member_error(){

    InputEx::reset(); //← 追加
    Fieldset::reset();

    $count = count(Model_Member::find("all"));

    $_POST["member_id"] = "test_user2";

    $response = Request::forge('member/create')
      ->set_method('POST')
      ->execute()
      ->response();

    $this->assertTag(array('tag' => 'strong', 'content' =>'Error'),$response->body->__toString());
    $this->assertEquals($count,count(Model_Member::find("all")));
  }

テストを実行します。

$ oil t group=Controller
Tests Running...This may take a few moments.
PHPUnit 3.7.37 by Sebastian Bergmann.

Configuration read from /home/unko/sample/fuel/app/phpunit.xml

.......

Time: 278 ms, Memory: 17.75Mb

OK (7 tests, 7 assertions)

通りました!

この状態だとテスメソッドすべてにresetの呼び出しを記述する必要があり、あまりいけてないのでsetUp()メソッドで処理するように変更します。

fuel/app/tests/controller/member.php
  //setUpメソッドを追加
  protected function setUp(){
    Session::destroy(); //← ついでにセッションの初期化も追加
    Request::reset_request(true); //← ついでにリクエストデータの初期化も追加
    InputEx::reset();
    Fieldset::reset();
  }

これでインサート、バリデーション、リダイレクトのコントローラーテストができる環境が整いました!

editのコントローラーテストを行う

続いて既存のデータに対して更新のテストを行います。

既存データの更新テストを行うためには、データベースを既定の状態に設定する必要がありますが、fuelPHPおよびPHPUnitにはそのような機能がありません。

しかしながらFuelPHP での PHPUnit によるユニットテストでフィクスチャ機能を実装されている方がいますので、これを使わせてもらいます。

FuelPHP での PHPUnit によるユニットテストの手順に沿ってdbfixtタスクを作成します。

タスクを実行し、既存のテーブルからフィクスチャデータを生成します。

$ oil r dbfixt:generate members

成功するとfuel/app/tests/fixtureディレクトリにテーブル名_fixt.ymlというファイルが生成されます。

fuel/app/tests/fixture/members_fixt.yml
---
- 
  id: 1
  member_id: member_id
  password: password
  created_at: 1405664980
  updated_at: 1405664980
- 
  id: 2
  member_id: member_id
  password: password
  created_at: 1405664990
  updated_at: 1405664990
- 
  id: 3
  member_id: member_id
  password: password
  created_at: 1405664998
  updated_at: 1405664998

同様にテストファイル内の$tablesで指定されたテーブルのデータをymlの内容で更新するためクラスをfuel/app/classes/dbtestcase.phpに追加します。

毎テスト実行時にmembersテーブルをmembers_fixt.ymlの内容で行使するため、テストファイルを修正します。

fuel/app/tests/controller/member.php
<?php
/**
 * Controller Post class tests
 * 
 * @group Controller
 * @group Member
 */
class Test_Controller_Member extends \DbTestCase //← 継承元をDbTestCaseに変更
{

  protected $tables = ['members' => 'members']; //← fixture.ymlの内容で更新するテーブルを記述

  protected function setUp(){
    Request::reset_request(true);
    InputEx::reset();
    Fieldset::reset();
    parent::setUp(); //← DbTestCaseのsetUp()をコールするために追加
  }

これで毎回テストを実行する前にmembersテーブルのデータがmembers_fixt.ymlの内容でリセットされるようになります。

取得したデータを更新するテストと、更新に失敗するテストを作成してみます。

fuelPHPのORMモデルはオブジェクトをキャッシュするようなので、変更後のレコードはORMを使用せず、クエリビルダで取得しています。

fuel/app/tests/controller/member.php
  //更新テスト 正常系
  public function test_update_member(){

    $update_member_id = "test_user_updated";
    $id = 1;

    $_POST["member_id"] = $update_member_id;
    $_POST["password"] = "password";

    $response = Request::forge('member/edit')
      ->set_method('POST')
      ->execute(["id" => $id])
      ->response();

    $updated_user = DB::select()->from("members")->where("id",$id)->execute(); //← クエリビルダを使用

    $this->assertEquals($update_member_id,$updated_user[0]["member_id"]);
    $this->assertEquals("302",Response::get_redirect_status());

  }

  //更新テスト エラー系
  public function test_update_member_error(){

    $update_member_id = "test_user_updated";
    $id = 3;

    $_POST["member_id"] = $update_member_id;
    $_POST["password"] = "123456789012345678901234567890123";

    $response = Request::forge('member/edit')
      ->set_method('POST')
      ->execute(["id" => $id])
      ->response();

    $updated_user = DB::select()->from("members")->where("id",$id)->execute(); //← クエリビルダを使用

    $this->assertEquals("member_id",$updated_user[0]["member_id"]);
    $this->assertTag(array('tag' => 'strong', 'content' =>'Error'),$response->body->__toString());
    $this->assertTag(array('tag' => 'div', 'attributes' => array('class' =>'alert'), 'descendant' => array('tag' => 'p', 'content' => 'The field Password may not contain more than 32 characters.')),$response->body->__toString());
  }

実行します。

$ oil t group=Controller
Tests Running...This may take a few moments.
PHPUnit 3.7.37 by Sebastian Bergmann.

Configuration read from /home/unko/sample/fuel/app/phpunit.xml

..........

Time: 451 ms, Memory: 18.25Mb

OK (10 tests, 12 assertions)

通りました。

assertTagsの使用により、エラー文言('The field Password may not contain more than 32 characters.)が表示されていることも確認できています。

deleteのコントローラーテストを行う

最後にDeleteのテストを行います。

削除に成功するパターンと、失敗するパターンのテストを作成します。

fuel/app/tests/controller/member.php
  // テストを追加
  public function test_delete_member(){

    $count = count(Model_Member::find("all"));

    $id = 1;

    $response = Request::forge('member/delete')
      ->set_method('POST')
      ->execute(["id" => $id])
      ->response();

    $updated_user = DB::select()->from("members")->where("id",$id)->execute();

    $this->assertEquals(0, count($updated_user));
    $this->assertEquals("302",Response::get_redirect_status());
    $this->assertEquals($count - 1,count(Model_Member::find("all")));

  }

  public function test_delete_member_error(){

    $count = count(Model_Member::find("all"));

    $id = 100;

    $response = Request::forge('member/delete')
      ->set_method('POST')
      ->execute(["id" => $id])
      ->response();

    $this->assertEquals("302",Response::get_redirect_status());
    $this->assertEquals($count, count(Model_Member::find("all")));
  }

実行します。 

$ oil t group=Controller
Tests Running...This may take a few moments.
PHPUnit 3.7.37 by Sebastian Bergmann.

Configuration read from /home/unko/sample/fuel/app/phpunit.xml

............

Time: 484 ms, Memory: 18.50Mb

OK (12 tests, 17 assertions)

成功しました。

通して実行する

コントローラーとモデルのテストを通して実行してみます。

$ oil t
Tests Running...This may take a few moments.
PHPUnit 3.7.37 by Sebastian Bergmann.

Configuration read from /home/unko/sample/fuel/app/phpunit.xml

...............................................................  63 / 387 ( 16%)
............................................................... 126 / 387 ( 32%)
............................................................... 189 / 387 ( 48%)
............................................................... 252 / 387 ( 65%)
............................................................... 315 / 387 ( 81%)
............................................................... 378 / 387 ( 97%)
.........

Time: 674 ms, Memory: 21.25Mb

OK (387 tests, 466 assertions)

テストかくにん! よかった♡

これで、モデルとコントローラーのCRUDテストすべてをPHPUnitのみで実施できる環境が構築できました!

まとめ

FuelPHPでコントローラーのテストを行う方法がわからなかったのでまとめました。

やり方を調べる過程で、PHPのプロジェクトでJenkins+PhingでCIされている人のブログを結構目にしましたが、コントローラー(ファンクショナル)テストの自動化については情報がなかったように思えます。

StackOverflowで検索したら、それSeleniumでできるよみたいなコメントが結構あって、PHPer爆発したらいいのにと思いましたが、今回まとめた手順によりPHPUnitのみでコントローラーテストまで可能ということがわかりましたので、同様の悩みを持ってる人はぜひお試しください。

また別に違う方法でやっている、やっていたなど情報があればぜひ共有お願いします。

【追記】 FuelPHP 1.8.0.4 以降のテスト

FuelPHP1.9からcore/classes/requestの中身が刷新され、Request::set_post()Request::set_get()のセッターメソッドが追加される予定があるというディスカッションがありました。
https://fuelphp.com/forums/discussion/comment/21061

こちら1.9からのはずが、2017/06現在、oilで最新のFuelPHPを落としてくるとfuel/core/requestが刷新されており、1.8からRequest::set_post() Request::set_get()が使用できるようになっています。

set_get(), set_post()

set_get(),set_post()によって、あるコントローラーから別のコントローラー/アクションにGET・POSTを付与して呼び出すことが出来ます。

Request->forge('some/url')
   ->set_method('post')
   ->set_post('postvar', 'value')
   ->set_get(array('getvar1' => 'value', 'getvar2' => 'value'))
   ->execute();

テストへの影響

set_メソッドの登場により、$_POST, $_GETに直接値を入れることができなくなります。Requestクラスは内部でInputクラスを呼び出していますが、こちらも大きく刷新されており、上記の方法ではテストできなくなります。

新しいRequest, Inputクラスを使ったユニットテスト

FuelPHPのfuel/core/request のバージョンが1.8.0.4以上の場合、POSTのテストを下記のように記述する必要があります。

class Test_Model_Marea extends TestCase
{

    public function test1()
    {
        $_SERVER['REQUEST_METHOD'] = 'POST'; サーバ変数を追加
        $r = Request::forge('hoge/create')
            ->set_method('POST')
            ->set_post("id","fuga")
            ->set_post("password","xxxxx")
            ->execute()
            ->response();

        $this->assertEquals($r->status,200);
    }
}

しかしながら現在のバージョンではset_method('POST')と記述してもGETが渡ってしまうため、scaffoldで生成したコントローラーのテストが失敗してしまいます。
サーバ変数'REQUEST_METHOD'を追加することで対応可能となります。

また入力値の初期化メソッドを下記のように書き換えます。

fuel/packages/my/classes/input_ex.php
<?php

namespace My;

class InputEX extends \Fuel\Core\Input {
  public static function reset(){
    parent::$instance = null;
  }
}

【追記】
※コメントで指摘いただいた箇所を訂正しました。