FuelphpのViewテストを行うためにphpunit用のテストケースのベースクラスを書きました.
親クラス
FuelPHPのコントローラユニットテストで認証,Response::redirect(), Request::is_hmvc()を正しく機能させテストを動かす.Aspect Mock使用で作成したコントローラ用のテストケースControllerTestcase
を継承しているので,そちらもご覧ください.
ControllerTestcase::setup()
で行っている,Fieldsetインスタンスのクリア,Requestインスタンスクリア,Assetのファイル検索対応がViewテストにも必要だったので,継承して使っています.
Viewテスト - URLチェック
controller/action構造の変更などに伴うリンク切れを検出したかったので,
AspectMockでHtml::anchor()
とUri::create()
をフックして
そのリンクが存在するかRequest::forge($uri)->execute()
してチェックしています.
そこでAspectMockを利用しています.
諸事情によりチェックしたくないURLがあるときは,
$this->skip_uri_check('hoge/fuga');
することでそのチェックをスキップできるようにしました.
スキップするURLのチェックにはRoute
クラスを使って,FuelPHP式のルーティング書式((:any)
など)が使えるようにしました.
以下コード:
<?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()
を基にモジュール名を判定しており,これがセットされていないとモジュール名が入らないからです.
この部分をごまかすために,
$namespace = $this->get_namespace();
$active_request = \Request::forge();
$active_request->module = $namespace;
$active_request->add_path(\Module::exists($namespace));
\Request::active($active_request);
ということをしています.
注意しなければならないのは,
$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は指定できるようにした.
例:
$this->set_http_method('foo/bar', 'POST');]
テストケース
テストケースはこんな感じ
<?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(); // 実行
}
}