FuelPHPのView, Presenterテスト - 自動URLチェックなど

FuelphpのViewテストを行うためにphpunit用のテストケースのベースクラスを書きました．

# 親クラス
[FuelPHPのコントローラユニットテストで認証，Response::redirect(), Request::is_hmvc()を正しく機能させテストを動かす．Aspect Mock使用](http://qiita.com/yuichiroTCY/items/a140fb0b5c4e67386ea3)で作成したコントローラ用のテストケース`ControllerTestcase`を継承しているので，そちらもご覧ください．
`ControllerTestcase::setup()`で行っている，Fieldsetインスタンスのクリア，Requestインスタンスクリア，Assetのファイル検索対応がViewテストにも必要だったので，継承して使っています．

# Viewテスト - URLチェック
controller/action構造の変更などに伴うリンク切れを検出したかったので，
AspectMockで`Html::anchor()`と`Uri::create()`をフックして
そのリンクが存在するか`Request::forge($uri)->execute()`してチェックしています．
そこでAspectMockを利用しています．

諸事情によりチェックしたくないURLがあるときは，

```php
$this->skip_uri_check('hoge/fuga');
```
することでそのチェックをスキップできるようにしました．
スキップするURLのチェックには`Route`クラスを使って，FuelPHP式のルーティング書式(`(:any)`など)が使えるようにしました．

以下コード：

```php:fuel/app/classes/testcase/view.php
<?php

use AspectMock\Test as test;

class ViewTestHttpNotFoundException extends \HttpNotFoundException {}

abstract class ViewTestCase extends \ControllerTestCase
{
	protected $_uris_to_skip;
	protected $_http_methods;

	protected function setup()
	{
		parent::setup();
		$this->_uris_to_skip = array();
		$this->_http_methods = array();

		// ファイルパスのプレフィックスやnamespaceをViewクラスの132行目で$active->get_paths()，Presenterクラスの48行目で\Request::active()->moduleで取得している．これをごまかす．
		$namespace = $this->get_namespace();
		$active_request = \Request::forge();
		$active_request->module = $namespace;
		$active_request->add_path(\Module::exists($namespace));
		\Request::active($active_request);

		// テストケースクラス名からuriを生成．あまり正確さにはこだわらない
		$uri = $this->generate_uri();
		// Request::main()をごまかしておく．
		test::double('Fuel\\Core\\Request', array(
			'main' => \Request::forge($uri),
		));

		// Viewファイル内のHtml::anchor, Uri::createをフックしてURLチェック
		$uri_checker = function($uri)
		{
			// "#", "."が含まれていたら，それ以降を削除
			$id_pos = strpos($uri, '#');
			if ($id_pos !== false)
			{
				$uri = substr($uri, 0, $id_pos);
			}
			if ( ! preg_match('/^(http:|https:)?\/\//', $uri))	// 外部URLでなければ
			{
				$dot_pos = strpos($uri, '.');
				if ($dot_pos !== false)
				{
					$uri = substr($uri, 0, $dot_pos);
				}
			}

			$request = \Request::forge($uri);
			foreach ($this->_uris_to_skip as $uri_to_skip) // Tips: $thisは暗黙的にクロージャに束縛される(php5.4~) http://d.hatena.ne.jp/do_aki/20110727/1311767728
			{
				$route = new \Route($uri_to_skip);
				if ($route->parse($request))
				{
					return;
				}
			}

			array_key_exists($uri, $this->_http_methods) and $request->set_method($this->_http_methods[$uri]);

			try
			{
				$this->emulate_request_execute($request);	// $request->execute()の代わり
			} catch (\HttpNotFoundException $e) {
				throw new ViewTestHttpNotFoundException($request->uri);
			}
		};
		test::double('Fuel\\Core\\Html', array(
			'anchor' => $uri_checker
		));
		test::double('Fuel\\Core\\Uri', array(
			'create' => $uri_checker
		));

		$this->double_controller_hybrid();
	}

	protected function skip_uri_check($uri)
	{
		if (is_array($uri))
		{
			foreach ($uri as $u)
			{
				$this->skip_uri_check($u);
			}
			return;
		}

		$this->_uris_to_skip[] = $uri;
	}

	protected function set_http_method($uri, $method)
	{
		$this->_http_methods[$uri] = $method;
	}

	private function get_namespace()
	{
		$class = get_class($this);
		$p = strrpos($class, '\\');
		if ($p !== false)
		{
			return substr($class, 0, $p);
		}

		return '';
	}

	// テストケースクラス名からuriを生成．あまり正確さにはこだわらない
	private function generate_uri()
	{
		$uri = str_replace(array('Test_View', 'Test_Presenter'), array('', ''), get_class($this));
		$uri = preg_replace('/_([A-Z])/', '/$1', $uri);
		$uri = strtolower(str_replace('//', '/', str_replace('\\', '/', $uri)));
		$uri_segments = explode('/', $uri);
		if (count($uri_segments) >= 2 and $uri_segments[0] == $uri_segments[1])
		{
			unset($uri_segments[1]);
		}
		$uri = implode('/', $uri_segments);

		return $uri;
	}

	// Request::execute()の代わりに，アクションメソッドの実行はせずに存在チェックだけ行う
	private function emulate_request_execute(Request $request)
	{
		// Request::$methodがprotectedなのでリフレクションで値を取得する
		$reflection_prop_method = new \ReflectionProperty(get_class($request), 'method');
		$reflection_prop_method->setAccessible(true);

		// Make the current request active
		\Request::active($request);

		if ( ! $request->route)
		{
			\Request::reset_request();
			throw new \HttpNotFoundException();
		}

		try
		{
			if ($request->route->callable !== null)
			{
				// Route exists
				return true;
			}
			else
			{
				$method_prefix = $reflection_prop_method->getValue($request).'_';
				$class = $request->controller;

				// If the class doesn't exist then 404
				if ( ! class_exists($class))
				{
					throw new \HttpNotFoundException();
				}

				// Load the controller using reflection
				$class = new \ReflectionClass($class);

				if ($class->isAbstract())
				{
					throw new \HttpNotFoundException();
				}

				// Create a new instance of the controller
				$request->controller_instance = $class->newInstance($request);

				$request->action = $request->action ?: ($class->hasProperty('default_action') ? $class->getProperty('default_action')->getValue($request->controller_instance) : 'index');
				$method = $method_prefix.$request->action;

				// Allow to do in controller routing if method router(action, params) exists
				if ($class->hasMethod('router'))
				{
					$method = 'router';
					$request->method_params = array($request->action, $request->method_params);
				}

				if ( ! $class->hasMethod($method))
				{
					// If they call user, go to $this->post_user();
					$method = strtolower(\Input::method()) . '_' . $request->action;
					// Fall back to action_ if no HTTP request method based method exists
					if ( ! $class->hasMethod($method))
					{
						$method = 'action_'.$request->action;
					}
				}

				if ($class->hasMethod($method))
				{
					$action = $class->getMethod($method);

					if ( ! $action->isPublic())
					{
						throw new \HttpNotFoundException();
					}

					if (count($request->method_params) < $action->getNumberOfRequiredParameters())
					{
						throw new \HttpNotFoundException();
					}

					// router()があればそちらへ処理を投げる．router()からのアクションメソッドの実行は防げないので，別途テストダブルで置き換えておく
					if ($method === 'router')
					{
						$action->invokeArgs($request->controller_instance, $request->method_params);
					}

					// 対象メソッドが存在
					\Request::reset_request();
					return true;
				}
				else
				{
					throw new \HttpNotFoundException();
				}
			}
		}
		catch (\Exception $e)
		{
			\Request::reset_request();

			throw $e;
		}
	}

	// Controller_Hybrid::router()を置き換えて，クラスとメソッドのチェックだけで，メソッドが呼ばれないようにする
	private function double_controller_hybrid()
	{
		test::double('Fuel\\Core\\Controller_Hybrid', array(
			'router' => function($resource, $arguments)
			{
				// if this is an ajax call
				if (\Input::is_ajax())
				{
					throw new Exception('Controller_Rest is not supported.');
				}

				// check if a input specific method exists
				$controller_method = strtolower(\Input::method()) . '_' . $resource;

				// fall back to action_ if no rest method is provided
				if ( ! method_exists(\Request::active()->controller_instance, $controller_method))
				{
					$controller_method = 'action_'.$resource;
				}

				// check if the action method exists
				if (method_exists(\Request::active()->controller_instance, $controller_method))
				{
					return \Response::forge('');
				}

				// if not, we got ourselfs a genuine 404!
				throw new \HttpNotFoundException();
			}
		));
	}
}

```

## その他ポイント
### module対応
module内のViewテストに使おうとすると，テストケースで`\View::forge()`しなければならないが，普通にやるとエラーになります．
例えば，Hogeモジュール内でfoo/barを呼ぶと，
`Fuel\Core\FuelException: The requested view could not be found: foo/bar`
となります．
これは，Viewクラス内で，viewファイルを探すときに`Request::active()`を基にモジュール名を判定しており，これがセットされていないとモジュール名が入らないからです．
この部分をごまかすために，

```php
$namespace = $this->get_namespace();
$active_request = \Request::forge();
$active_request->module = $namespace;
$active_request->add_path(\Module::exists($namespace));
\Request::active($active_request);
```

ということをしています．

注意しなければならないのは，

```php
$namespace = $this->get_namespace();
$active_request = \Request::forge($namespace);
\Request::active($active_request);
```
のようにしてはいけない，ということです．
`Request`クラスは指定されたURIからモジュール名などを読み取り，コンストラクタで`$module`のセットや`add_path()`などを行ってくれますが，この時コントローラクラスの存在チェックが入ります（正確には，モジュール名の取得を`Router::process()`に投げて，そこでコントローラクラスの存在チェックが行われます）．
なので，このようにしてしまうとViewが存在してもControllerがなければ正しく動かないことになり，ViewテストがControllerの存在に依存してしまいます．
そのため，自前でモジュール名（＝名前空間名）の取得と`$module`へのセット，`add_path()`呼び出しを行います．

### Request::main()も置き換え
また，`Request::main()`もテストダブルで置き換えて適当な`Request`オブジェクトが返るようにしています．
`Fieldset::build()`などは場合によっては`Request::main()`を参照するので，viewでFieldsetが使われても正しく動作するようにするためです．
（`Fieldset::build()`について詳しく書くと，`Fieldset::build()`の時にaction要素が指定されない場合`Uri::main()`を指定するが，`Uri::main()`が`Request::main()`を呼ぶようになっている）

### HttpNotFoundException対応
デバッグがしやすいように，`HttpNotFoundException`を一旦catchして，そのときのURIをメッセージにセットした例外を改めてスローしなおしています．
これでHttpNotFoundExceptionがスローされた時のURIがコンソールに表示されるようになります．

### Request::execute()のかわりにemulate_request_execute()を用意
`private function emulate_request_execute(Request $request)`です．
`Request::forge()->execute()`すると実際にそのアクションのメソッドが呼ばれてしまいますが，そのアクションでDBアクセスがあったりするとテストがDBに依存してしまうので，呼び出しは避けてメソッドの存在チェックだけに留めたいのです．
そのために，`Request::execute()`を呼ぶ代わりに`emulate_request_execute($request)`メソッドを用意して使っています．これは`Request::execute()`をコピペしてアクションメソッドの呼び出し部分と，その他不要な部分を削除しただけです．

### Controller_Hybrid::router()をフック
`private function double_controller_hybrid()`の部分です．
`emulate_request_execute()`は（本来の`Request::execute()`も），コントローラクラスに`router()`メソッドがあるとそれを呼び出してアクションメソッドの実行を丸投げします．
そこで，`Controller_Hybrid::router`をAspectMockでテストダブルで置き換え，アクションメソッドの存在チェックだけ行い，実行を避けるようにします．
`Controller_Hybrid::router()`は`Input::is_ajax()===true`のとき`Controller_Rest::router()`を呼ぶのでその処理もするべきかもしれませんが，今回は省いています．

### POST対応

`set_http_method()`を用意し，URLチェックの時，特にHTTPメソッドを指定したいURLは指定できるようにした．
例：

```php
$this->set_http_method('foo/bar', 'POST');]
```

# テストケース

テストケースはこんな感じ

```php:fuel/app/tests/vuew/hoge/fuga.php
<?php

/**
 * @group App
 * @group View
 */

class Test_View_Home_Index extends \ViewTestCase
{
	public function test()
	{
		// URLチェックをスキップする設定
		$this->skip_uri_check(array(
			'aaa/bbb/ccc',
		));
		
		$view = \View::forge('hoge/fuga');
		$view->foo = 'bar';	// 必要な変数のセット
		$view->render();	// 実行
	}
}
```