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

FuelPHPのコントローラのユニットテストの書き方は
[fuelPHPでPHPUnitを使ったユニット・コントローラーテストをするには](http://qiita.com/masahikoofjoyto/items/206e6f8d5b0aa7126678)
などに記事がありますが，
それに加えて

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

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

今回はAspect Mockを使います．
Aspeck Mockを使ったFuelPHPのテストは
[AspectMockでFuelPHPのアプリを100％テスト可能にする](http://blog.a-way-out.net/blog/2013/12/09/fuelphp-aspectmock/)
に詳しい記事があります．

# 下準備
## セットアップ
FuelPHPで新しいプロジェクトを作り，simpleauthをセットアップ．
また，

```bash
php oil g admin entity name:varchar[255]
```

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

## PHPUnitのセットアップ

インストール

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

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

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

fuel/app/phpunit.xmlを編集

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


テストが走ることを確認

```bash
php oil test
```

## Aspect Mock のセットアップ
http://blog.a-way-out.net/blog/2013/12/09/fuelphp-aspectmock/
を参考にAspect Mockをインストール．

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

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

#### トラブル
Aspect Mockを入れたら

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

```diff: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: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アクセス部分だけ消した．

```php: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に追記．

```diff: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ログインドライバでダミーデータでログインできる．

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

```php: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`などがダミーデータを基に正しく振る舞うようになる．

> ```php
		$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を使ったユニット・コントローラーテストをするには](http://qiita.com/masahikoofjoyto/items/206e6f8d5b0aa7126678)
にこの問題への解決があるが，ここでは少し変えて例外を使った対処法を与える．

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

```php: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`にリダイレクトされるはず．

```bash
$ php oil test --group=Controller
Tests Running...This may take a few moments.
....
```
テストを実行してみると，やはり途中で終了してしまった．

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

```php: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();
	}
}
```

キモは
> ```php
		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: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();
	}
}
```

```diff: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()`に別のユーザータイプのダミーデータを追加する

```php: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()`を複数ユーザータイプに対応させる．

```php: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));
	}
}
```

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

```php: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()`がテストではうまく機能しない．
サンプルとなるテストケースは示さないが，

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

	\Request::reset_request(true);
}
```
とすることで正しく機能させることができる．
`Request::is_hmvc()`のオリジナルは

```php: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`が残ってしまい，
２つ目以降の`Request::is_hmvc()`が正しく動かなくなる．

# さらにまとめる（最終版）
コントローラテストのための基底テストケースクラス最終版．

```php: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が残ったままになって２度目以降の\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 に置いてあります．