Help us understand the problem. What is going on with this article?

FuelPHPのコントローラユニットテストで認証,Response::redirect(), Request::is_hmvc()を正しく機能させテストを動かす.Aspect Mock使用

More than 3 years have passed since last update.

FuelPHPのコントローラのユニットテストの書き方は
fuelPHPでPHPUnitを使ったユニット・コントローラーテストをするには
などに記事がありますが,
それに加えて

  • Authパッケージによる認証も含めたテスト
  • Request::is_hmvc()が正しく動かない問題の解決
  • Response::redirect()で exitしてしまう問題の解決(上記記事にもありますが,別解を)
  • テストだとAssetクラスのメソッドがうまく動かない問題の解決(上記記事にもありますが,これも別解を)

をするための例を書こうと思います.

今回はAspect Mockを使います.
Aspeck Mockを使ったFuelPHPのテストは
AspectMockでFuelPHPのアプリを100%テスト可能にする
に詳しい記事があります.

下準備

セットアップ

FuelPHPで新しいプロジェクトを作り,simpleauthをセットアップ.
また,

php oil g admin entity name:varchar[255]

で管理画面を作っておく.この管理画面のテストを例にして書いていく.
(認証ページが例として欲しかっただけであって,今回CRUD等は使わない)

PHPUnitのセットアップ

インストール

php composer.phar require phpunit/phpunit=4.6.*

設定ファイルとブートストラップをコピー

cp fuel/core/bootstrap_phpunit.php fuel/app/.
cp fuel/core/phpunit.xml fuel/app/.

fuel/app/phpunit.xmlを編集

fuel/app/phpunit.xml
- <phpunit colors="true" stopOnFailure="false" bootstrap="../core/bootstrap_phpunit.php">
+ <phpunit colors="true" stopOnFailure="false" bootstrap="../app/bootstrap_phpunit.php">

テストが走ることを確認

php oil test

Aspect Mock のセットアップ

http://blog.a-way-out.net/blog/2013/12/09/fuelphp-aspectmock/
を参考にAspect Mockをインストール.

php composer.phar require codeception/aspect-mock=*

上記サイトではbootstrap_phpunit.phpの書き換えが指示されているが,最新版のFuelPHPのbootstrap_phpunit.phpははじめからAspect Mock対応のよう

トラブル

Aspect Mockを入れたら

php oil test

で全てのテストケースに対して
Exception: Serialization of 'Closure' is not allowed
が出るようになってしまった.
https://github.com/Codeception/AspectMock/issues/1#issuecomment-21446411
に解決が載っていた.
fuel/app/phpunit.xmlを編集

fuel/app/phpunit.xml
- <phpunit colors="true" stopOnFailure="false" bootstrap="../app/bootstrap_phpunit.php">
+ <phpunit colors="true" stopOnFailure="false" bootstrap="../app/bootstrap_phpunit.php" backupGlobals="false">

ダミーデータを使ってAuthパッケージによる認証ページをテストする.

Controller_Admin::action_index()のテストをつくる.

fuel/app/tests/controller/admin.php
<?php

use AspectMock\Test as test;

/**
 * @group Controller
 */
class Test_Controller_Admin extends \TestCase
{
    public function test_action_index_logged_in()
    {
        $response = \Request::forge('admin/index')->execute()->response();
    }
}

これでテストを実行すると途中終了してしまう.
これは,ログアウト状態でadmin/indexにアクセスするとadmin/loginへリダイレクトされる処理になっていて,
Response::redirect()の中でexitしているから.
Response::redirect()のexitを正しくテストする方法は後述するが,
ここではログイン状態のアクセスをシミュレートして正しくテストが通るようにしてみる.

まず,Auth_Login_Simpleauthを継承したクラスでvalidate_user()とcreate_login_hash()をオーバーライド.
validate_user()はダミーのユーザーデータを生成するため.
create_login_hash()はDBへのアクセスがあるため,オーバーライドしてDBアクセス部分だけ消した.

fuel/classes/testcase/auth/login/simpleauth.php
<?php
class Auth_Login_Simpleauth extends \Auth\Auth_Login_Simpleauth
{
    /**
     * Check the user exists
     *
     * @return  bool
     */
    public function validate_user($username_or_email = '', $password = '')
    {
        switch ($username_or_email)
        {
            case 'admin':
                return array(
                    'id' => 1,
                    'username' => 'admin',
                    'email' => 'admin@example.com',
                    'group' => 100,
                );
            break;

            default:
                return false;
        }
    }


    /**
     * Creates a temporary hash that will validate the current login
     *
     * @return  string
     */
    public function create_login_hash()
    {
        if (empty($this->user))
        {
            throw new \SimpleUserUpdateException('User not logged in, can\'t create login hash.', 10);
        }

        $last_login = \Date::forge()->get_timestamp();
        $login_hash = sha1(\Config::get('simpleauth.login_hash_salt').$this->user['username'].$last_login);

        $this->user['login_hash'] = $login_hash;

        return $login_hash;
    }
}

このクラスをSimpleauthのログインドライバとして使うため設定.テストの時だけ読み込まれるよう,bootstrap_phpunit.phpに追記.

fuel/app/bootstrap_phpunit.php
// Boot the app
require_once APPPATH.'bootstrap.php';
+
+ Autoloader::add_classes(array(
+   'Auth_Login_Simpleauth' => APPPATH.'classes/testcase/auth/login/simpleauth.php',
+ ));

// Set test mode
Fuel::$is_test = true;

これで,Simpleauthログインドライバでダミーデータでログインできる.

テストケースを修正する.

fuel/tests/controller/admin.php
<?php

use AspectMock\Test as test;

/**
 * @group Controller
 */
class Test_Controller_Admin extends \TestCase
{
    protected function tearDown() {
        \Auth::logout();
        test::clean();
    }

    public function test_action_index_logged_in()
    {
        $admin_user = \Model_User::forge(array(
            'id' => 1,
            'username' => 'admin',
            'email' => 'admin@example.com',
            'group' => 100,
        ));
        test::double('Model_User', array('find_by_username' => $admin_user));

        \Auth::login('admin');

        $response = \Request::forge('admin/index')->execute()->response();
    }
}

ここで,\Auth::login('admin');がダミーデータによるログイン.
これでfuel/classes/testcase/auth/login/simpleauth.phpでオーバーライドしたAuth_Login_Simpleauth::validate_user()が最終的に呼ばれ,そこで生成しているダミーデータがログインドライバにログイン中ユーザーとしてセットされる.
こうしてしまえば後はAuth::check()Auth::member, Auth::has_accessなどがダミーデータを基に正しく振る舞うようになる.

        $admin_user = \Model_User::forge(array(
            'id' => 1,
            'username' => 'admin',
            'email' => 'admin@example.com',
            'group' => 100,
        ));
        test::double('Model_User', array('find_by_username' => $admin_user));

の部分はログインのエミュレートとは直接関係ないが,
Controller_Base
Model_User::find_by_username(Auth::get_screen_name()) が呼ばれているので,
ここでAuth_Login_Simpleauth::validate_user()で生成しているダミーデータと同じ物を返すように
AspeckMockで設定している.

テストケース終了時にログアウトとAspectMockのリセットをして後片付け終了.

    protected function tearDown() {
        \Auth::logout();
        test::clean();
    }

以上でSimpleauthの認証をエミュレートできた.

Response::redirect() の exit を回避する

Response::redirect()は内部でexitしており,このままテストすると途中終了してしまう.

fuelPHPでPHPUnitを使ったユニット・コントローラーテストをするには
にこの問題への解決があるが,ここでは少し変えて例外を使った対処法を与える.

ログアウト状態でadmin/indexにアクセスするテストケースを追加する.

fuel/tests/controller/admin.php
<?php

use AspectMock\Test as test;

/**
 * @group Controller
 */
class Test_Controller_Admin extends \TestCase
{
    protected function tearDown() {
        \Auth::logout();
        test::clean();
    }

    public function test_action_index_logged_in()
    {
        $admin_user = \Model_User::forge(array(
            'id' => 1,
            'username' => 'admin',
            'email' => 'admin@example.com',
            'group' => 100,
        ));
        test::double('Model_User', array('find_by_username' => $admin_user));
        \Auth::login('admin');
        $response = \Request::forge('admin/index')->execute()->response();
    }

    public function test_action_index_logged_out()
    {
        \Auth::logout();
        $response = \Request::forge('admin/index')->execute()->response();
    }
}

ログアウト状態でのadmin/indexへのアクセスは,上で述べたとおり,admin/loginにリダイレクトされるはず.

$ php oil test --group=Controller
Tests Running...This may take a few moments.
....

テストを実行してみると,やはり途中で終了してしまった.

テストケースを以下のように修正する.

fuel/app/tests/controller/admin.php
<?php

use AspectMock\Test as test;

class TestRedirectException extends \Exception
{}

/**
 * @group Controller
 */
class Test_Controller_Admin extends \TestCase
{
    protected function tearDown() {
        \Auth::logout();
        test::clean();
    }

    public function test_action_index_logged_in()
    {
        $admin_user = \Model_User::forge(array(
            'id' => 1,
            'username' => 'admin',
            'email' => 'admin@example.com',
            'group' => 100,
        ));
        test::double('Model_User', array('find_by_username' => $admin_user));
        \Auth::login('admin');
        $response = \Request::forge('admin/index')->execute()->response();
    }

    /**
     * @expectedException TestRedirectException
     * @expectedExceptionMessage admin/login:location:302
     */
    public function test_action_index_logged_out()
    {
        test::double('Fuel\Core\Response', array(
            'redirect' => function($url = '', $method = 'location', $code = 302){
                throw new TestRedirectException(sprintf('%s:%s:%s', $url, $method, $code));
            }
        ));
        \Auth::logout();
        $response = \Request::forge('admin/index')->execute()->response();
    }
}

キモは

        test::double('Fuel\Core\Response', array(
            'redirect' => function($url = '', $method = 'location', $code = 302){
                throw new TestRedirectException(sprintf('%s:%s:%s', $url, $method, $code));
            }
        ));

これで,Response::redirect()の呼び出しをAspectMockによって上書きしている.
Response::redirect()が呼ばれると,例外TestRedirectExceptionがスローされるようになっている.
TestRedirectExceptionのメッセージにはResponse::redirect()の引数$url,$method,$codeが入るので,
@expectedExceptionMessageアノテーションでリダイレクト先やステータスコードもチェックできる.

以上でResponse::redirect()を含むコントローラをテスト可能になった.

まとめる

以上,認証のエミュレートとResponse::redirect()のexit回避のコードを書いてきたが,
一つの基底クラスにまとめてしまうと使い勝手がよい.

fuel/app/classes/testcase/controller.php
<?php
use AspectMock\Test as test;

class TestRedirectException extends \Exception
{}

class TestCase_Controller extends \TestCase
{
    protected function setup()
    {
        test::double('Fuel\Core\Response', array(
            'redirect' => function($url = '', $method = 'location', $code = 302){
                throw new TestRedirectException(sprintf('%s:%s:%s', $url, $method, $code));
            }
        ));
    }

    protected function tearDown()
    {
        \Auth::logout();
        test::clean();
    }

    protected function emulate_logged_in()
    {
        $admin_user = \Model_User::forge(array(
            'id' => 1,
            'username' => 'admin',
            'email' => 'admin@example.com',
            'group' => 100,
        ));
        test::double('Model_User', array('find_by_username' => $admin_user));
        \Auth::login('admin');
    }

    protected function emulate_logged_out()
    {
        \Auth::logout();
    }
}
fuel/app/tests/controller/admin.php
<?php

/**
 * @group Controller
 */
class Test_Controller_Admin extends TestCase_Controller
{
    public function test_action_index_logged_in()
    {
        $this->emulate_logged_in();
        $response = \Request::forge('admin/index')->execute()->response();
    }

    /**
     * @expectedException TestRedirectException
     * @expectedExceptionMessage admin/login:location:302
     */
    public function test_action_index_logged_out()
    {
        $this->emulate_logged_out();
        $response = \Request::forge('admin/index')->execute()->response();
    }
}
fuel/app/bootstrap_phpunit.php
Autoloader::add_classes(array(
    'Auth_Login_Simpleauth' => APPPATH.'classes/testcase/auth/login/simpleauth.php',
+   'TestCase_Controller' => APPPATH.'classes/testcase/controller.php',
+   'TestRedirectException' => APPPATH.'classes/testcase/controller.php',
));

別のグループのユーザーを追加してみる

admin以外のユーザーでログインすると,/にリダイレクトされる.
これをテストしてみる.

Simpleauthのログインドライバのvalidate_user()に別のユーザータイプのダミーデータを追加する

fuel/app/classes/testcase/auth/login/simpleauth.php
    public function validate_user($username_or_email = '', $password = '')
    {
        switch ($username_or_email)
        {
            case 'admin':
                return array(
                    'id' => 1,
                    'username' => 'admin',
                    'email' => 'admin@example.com',
                    'group' => 100,
                );
            break;

            case 'user':
                return array(
                    'id' => 2,
                    'username' => 'user',
                    'email' => 'user@example.com',
                    'group' => 1,
                );
            break;

            default:
                return false;
        }
    }

emulate_logged_in()を複数ユーザータイプに対応させる.

fuel/app/classes/testcase/controller.php
    protected function emulate_logged_in($usertype = '')
    {
        switch ($usertype)
        {
            case 'admin':
                \Auth::login('admin');
                $user = \Model_User::forge(array(
                    'id' => 1,
                    'username' => 'admin',
                    'email' => 'admin@example.com',
                    'group' => 100,
                ));
            break;

            case 'user':
                \Auth::login('user');
                $user = \Model_User::forge(array(
                    'id' => 2,
                    'username' => 'user',
                    'email' => 'user@example.com',
                    'group' => 1,
                ));
            break;

            default:
                $user = null;
        }
        test::double('Model_User', array('find_by_username' => $user));
    }
}

これらを使ってテストケースを修正&追加

fuel/app/tests/controller/admin.php
<?php

/**
 * @group Controller
 */
class Test_Controller_Admin extends TestCase_Controller
{
    public function test_action_index_logged_in()
    {
        $this->emulate_logged_in('admin');
        $response = \Request::forge('admin/index')->execute()->response();
    }

    /**
     * @expectedException TestRedirectException
     * @expectedExceptionMessage /:location:302
     */
    public function test_action_index_logged_in_invalid_auth()
    {
        $this->emulate_logged_in('user');
        $response = \Request::forge('admin/index')->execute()->response();
    }

    /**
     * @expectedException TestRedirectException
     * @expectedExceptionMessage admin/login:location:302
     */
    public function test_action_index_logged_out()
    {
        $this->emulate_logged_out();
        $response = \Request::forge('admin/index')->execute()->response();
    }
}

Request::is_hmvc() を正しく機能させる.

Request::is_hmvc()がテストではうまく機能しない.
サンプルとなるテストケースは示さないが,

protected function setup() {
    test::double('Fuel\\Core\\Request', array(
        'is_hmvc' => function() {
            return \Request::active() !== \Request::main();
        }
    ));

    \Request::reset_request(true);
}

とすることで正しく機能させることができる.
Request::is_hmvc()のオリジナルは

fuel/core/classes/request.php
    public static function is_hmvc()
    {
        return ((\Fuel::$is_cli and static::main()) or static::active() !== static::main());
    }

となっていて,テスト時は\Fuel::$is_cli = trueとなってしまうため.
AspectMockでこの条件式が無いものに書き換えている.
また,Request::reset_request(true);することで,Request::$mainをnullにしている.
これをしないと,前回のテストケースでRequest::forge()したときのRequest::$mainが残ってしまい,
2つ目以降のRequest::is_hmvc()が正しく動かなくなる.

さらにまとめる(最終版)

コントローラテストのための基底テストケースクラス最終版.

fuel/classes/testcase/controller.php
<?php
use AspectMock\Test as test;

class TestRedirectException extends \Exception
{}

class TestCase_Controller extends \TestCase
{
    protected function setup()
    {
        // Response::redirect()のexitを回避する.
        test::double('Fuel\Core\Response', array(
            'redirect' => function($url = '', $method = 'location', $code = 302){
                throw new TestRedirectException(sprintf('%s:%s:%s', $url, $method, $code));
            }
        ));

        // 本物のRequest::is_hmvc()は \Fuel::$is_cli and static::main() 条件があってcliから実行するtestだとtrueになってしまうので,その条件を外すために置き換える
        test::double('Fuel\\Core\\Request', array(
            'is_hmvc' => function() {
                return \Request::active() !== \Request::main();
            }
        ));

        // Requestをリセットしないと\Request::$mainが残ったままになって2度目以降の\Request::forge()からの\Request::is_hmvc()で問題になる
        \Request::reset_request(true);

        chdir('public');
    }

    protected function tearDown()
    {
        \Auth::logout();
        test::clean();
    }

    protected function emulate_logged_in($usertype = '')
    {
        switch ($usertype)
        {
            case 'admin':
                \Auth::login('admin');
                $user = \Model_User::forge(array(
                    'id' => 1,
                    'username' => 'admin',
                    'email' => 'admin@example.com',
                    'group' => 100,
                ));
            break;

            case 'user':
                \Auth::login('user');
                $user = \Model_User::forge(array(
                    'id' => 2,
                    'username' => 'user',
                    'email' => 'user@example.com',
                    'group' => 1,
                ));
            break;

            default:
                $user = null;
        }
        test::double('Model_User', array('find_by_username' => $user));
    }

    protected function emulate_logged_out()
    {
        \Auth::logout();
    }
}

今まで説明した
* 認証シミュレート
* Response::redirect()のexit回避
* Request::is_hmvc()の誤動作回避
をまとめた.

Asset対応

また,Asset::imgなどを正しく動作させるためにchdir('public')も追加されている.
Webページとして閲覧するときは最初に/public/index.phpへアクセスするため,Asset_Instance::find_file()内のis_fileがファイルを探す起点が/publicなのに対し,テスト時は/を起点にファイルを探そうとするため,chdir('public')しておかないとCould not find asset: ~~~とエラーになる.

その他

  • この例ではfuel/app/classes/testcase/auth以下にSimpleauthの拡張クラスを置いてしまっている. bootstrap_phpunit.phpAutoloaderをちゃんと設定しているので動くが,命名規則的によろしくない気がする. テストでしか使わないのでfuel/app/classes/testcase以下にまとめてしまいたかったのだが…
  • この記事で作成したコードは https://github.com/yuichiroTCY/fuel-controller-test に置いてあります.
Why not register and get more from Qiita?
  1. We will deliver articles that match you
    By following users and tags, you can catch up information on technical fields that you are interested in as a whole
  2. you can read useful information later efficiently
    By "stocking" the articles you like, you can search right away
Comments
No comments
Sign up for free and join this conversation.
If you already have a Qiita account
Why do not you register as a user and use Qiita more conveniently?
You need to log in to use this function. Qiita can be used more conveniently after logging in.
You seem to be reading articles frequently this month. Qiita can be used more conveniently after logging in.
  1. We will deliver articles that match you
    By following users and tags, you can catch up information on technical fields that you are interested in as a whole
  2. you can read useful information later efficiently
    By "stocking" the articles you like, you can search right away
ユーザーは見つかりませんでした