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を編集
- <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を編集
- <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()
のテストをつくる.
<?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アクセス部分だけ消した.
<?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に追記.
// 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ログインドライバでダミーデータでログインできる.
テストケースを修正する.
<?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のリセットをして後片付け終了.
> ```php
protected function tearDown() {
\Auth::logout();
test::clean();
}
以上でSimpleauthの認証をエミュレートできた.
Response::redirect() の exit を回避する
Response::redirect()は内部でexitしており,このままテストすると途中終了してしまう.
fuelPHPでPHPUnitを使ったユニット・コントローラーテストをするには
にこの問題への解決があるが,ここでは少し変えて例外を使った対処法を与える.
ログアウト状態でadmin/index
にアクセスするテストケースを追加する.
<?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.
....
テストを実行してみると,やはり途中で終了してしまった.
テストケースを以下のように修正する.
<?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回避のコードを書いてきたが,
一つの基底クラスにまとめてしまうと使い勝手がよい.
```php: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();
}
}
<?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();
}
}
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()
に別のユーザータイプのダミーデータを追加する
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()
を複数ユーザータイプに対応させる.
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));
}
}
これらを使ってテストケースを修正&追加
<?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()
のオリジナルは
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()
が正しく動かなくなる.
さらにまとめる(最終版)
コントローラテストのための基底テストケースクラス最終版.
<?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.php
でAutoloader
をちゃんと設定しているので動くが,命名規則的によろしくない気がする.
テストでしか使わないのでfuel/app/classes/testcase
以下にまとめてしまいたかったのだが… - この記事で作成したコードは https://github.com/yuichiroTCY/fuel-controller-test に置いてあります.