17
22

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 5 years have passed since last update.

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

Last updated at Posted at 2015-07-22

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)など)が使えるようにしました.

以下コード:

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()を基にモジュール名を判定しており,これがセットされていないとモジュール名が入らないからです.
この部分をごまかすために,

$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');]

テストケース

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

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();	// 実行
	}
}
17
22
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
17
22

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?