Unit Testing URL Routes in Yii Framework

Matt McCormick

2012/09/14

(This post refers to Yii Framework v.1.1.11)

Let’s say you have a bunch of custom URL routes setup in your configuration file for Yii. Since custom URL routes can quickly become complicated due to regular expression matching, it’s a good idea to have some tests in place to make sure that your URLs are getting sent to the correct controller and action.

Unfortunately, Yii is a little too tightly coupled for my taste. This makes it difficult to test something like this. Routes are one of the main parts of any web application out there and should be standard to write unit tests for them. This is one of the downfalls of the Yii framework and could be improved.

It takes a bit of digging through the Yii Framework code to figure out how to test URL routes. Let’s walk through and see how you can setup your own URL route tests.

In your configuration file, under the key ['components']['urlManager']['rules'] we’ll assume you have the following RESTful routes setup:

array('<controller>/view', 'pattern' => '<controller:\w+>/<id:\d+>', 'verb' => 'GET'),
array('<controller>/create', 'pattern' => '<controller:\w+>', 'verb' => 'POST'),
array('<controller>/update', 'pattern' => '<controller:\w+>/<id:\d+>', 'verb' => 'PUT'),
array('<controller>/delete', 'pattern' => '<controller:\w+>/<id:\d+>', 'verb' => 'DELETE')

This says that any GET request matching /word/ID, where “word” is the name of the controller and ID is a number, will be redirected to that controller’s view action. A PUT request to the same URL would be handled by the update action and a DELETE request would be handled by the delete action. A POST request to /word will be handled by the create action. There is no need for the ID because we are creating a new object.

Now that we have our routes setup, let’s move on to testing them. I’ll assume you have something like a UserController that contains these four actions.

Under the /protected/tests/unit/ directory, add a file named UserControllerTest.php which contains the following:

class UserControllerTest extends CDbTestCase
{
	public function testViewRouteResolves()
	{
	
	}
}

Note: This tutorial assumes you already have PHPUnit setup for testing your Yii application. Setting up your testing framework is outside the scope of this article. See the Yii tutorial if you need guidance.

The first problems you should be thinking about solving are:

  1. How do I tell Yii what URL I want to test?
  2. How do I get the controller and action from Yii that it will redirect to?

Let’s walk through the Yii initialization code. This will help you get familiar with what is going on behind the scenes.

You should have a type of bootstrap file which contains something along the following lines:

Yii::createWebApplication($config)->run();

This is the main call to run the application. $config refers to an array of configuration items that was mentioned earlier.

If you follow the code and open up the CApplication::run() method, you will see the following:

if($this->hasEventHandler('onBeginRequest'))
	$this->onBeginRequest(new CEvent($this));
$this->processRequest();
if($this->hasEventHandler('onEndRequest'))
	$this->onEndRequest(new CEvent($this));

We’re only concerned with the highlighted line. processRequest() is an abstract method which means it will be handled in the inherited class.

In CWebApplication::processRequest(), we see the following code:

public function processRequest()
{
	if(is_array($this->catchAllRequest) && isset($this->catchAllRequest[0]))
	{
		$route=$this->catchAllRequest[0];
		foreach(array_splice($this->catchAllRequest,1) as $name=>$value)
			$_GET[$name]=$value;
	}
	else
		$route=$this->getUrlManager()->parseUrl($this->getRequest());
	$this->runController($route);
}

We’re only concerned with the part after the else statement as we are not using a catch-all request. Line 10 says that, in order to get the route, Yii will use the UrlManager::parseUrl() method and pass in the Request object. So let’s take a look at that next:

public function parseUrl($request)
{
	if($this->getUrlFormat()===self::PATH_FORMAT)
	{
		$rawPathInfo=$request->getPathInfo();
		$pathInfo=$this->removeUrlSuffix($rawPathInfo,$this->urlSuffix);
		foreach($this->_rules as $i=>$rule)
		{
			if(is_array($rule))
				$this->_rules[$i]=$rule=Yii::createComponent($rule);
			if(($r=$rule->parseUrl($this,$request,$pathInfo,$rawPathInfo))!==false)
				return isset($_GET[$this->routeVar]) ? $_GET[$this->routeVar] : $r;
		}
		if($this->useStrictParsing)
			throw new CHttpException(404,Yii::t('yii','Unable to resolve the request "{route}".',
				array('{route}'=>$pathInfo)));
		else
			return $pathInfo;
	}
	else if(isset($_GET[$this->routeVar]))
		return $_GET[$this->routeVar];
	else if(isset($_POST[$this->routeVar]))
		return $_POST[$this->routeVar];
	else
		return '';
}

The highlighted lines are where the route is calculated. First, the path is figured out which comes from the Request object. For our purposes, it is important to take a look at it in order to figure out how we can tell Yii which URL we want to test.

CHttpRequest::getPathInfo()

We’re getting closer now! Line 5 indicates where Yii gets the request URI from. So let’s take a look at CHttpRequest::getRequestUri():

public function getRequestUri()
{
	if($this->_requestUri===null)
	{
		if(isset($_SERVER['HTTP_X_REWRITE_URL'])) // IIS
			$this->_requestUri=$_SERVER['HTTP_X_REWRITE_URL'];
		else if(isset($_SERVER['REQUEST_URI']))
		{
			$this->_requestUri=$_SERVER['REQUEST_URI'];
			if(!empty($_SERVER['HTTP_HOST']))
			{
				if(strpos($this->_requestUri,$_SERVER['HTTP_HOST'])!==false)
					$this->_requestUri=preg_replace('/^\w+:\/\/[^\/]+/','',$this->_requestUri);
			}
			else
				$this->_requestUri=preg_replace('/^(http|https):\/\/[^\/]+/i','',$this->_requestUri);
		}
		else if(isset($_SERVER['ORIG_PATH_INFO']))  // IIS 5.0 CGI
		{
			$this->_requestUri=$_SERVER['ORIG_PATH_INFO'];
			if(!empty($_SERVER['QUERY_STRING']))
				$this->_requestUri.='?'.$_SERVER['QUERY_STRING'];
		}
		else
			throw new CException(Yii::t('yii','CHttpRequest is unable to determine the request URI.'));
	}

	return $this->_requestUri;
}

Finally we can see that if the $_SERVER['REQUEST_URI'] value is set, Yii will use that value as the URL route. ($_SERVER['HTTP_X_REWRITE_URL'] would also work as well.)

Going back to CHttpRequest::getPathInfo(), also see that I’ve highlighted the call to CHttpRequest::getScriptUrl(). This is important because we are running PHPUnit from the command line and not through an ordinary URL request. Looking at the code we see:

public function getScriptUrl()
{
	if($this->_scriptUrl===null)
	{
		$scriptName=basename($_SERVER['SCRIPT_FILENAME']);
		if(basename($_SERVER['SCRIPT_NAME'])===$scriptName)
			$this->_scriptUrl=$_SERVER['SCRIPT_NAME'];
		else if(basename($_SERVER['PHP_SELF'])===$scriptName)
			$this->_scriptUrl=$_SERVER['PHP_SELF'];
		else if(isset($_SERVER['ORIG_SCRIPT_NAME']) && basename($_SERVER['ORIG_SCRIPT_NAME'])===$scriptName)
			$this->_scriptUrl=$_SERVER['ORIG_SCRIPT_NAME'];
		else if(($pos=strpos($_SERVER['PHP_SELF'],'/'.$scriptName))!==false)
			$this->_scriptUrl=substr($_SERVER['SCRIPT_NAME'],0,$pos).'/'.$scriptName;
		else if(isset($_SERVER['DOCUMENT_ROOT']) && strpos($_SERVER['SCRIPT_FILENAME'],$_SERVER['DOCUMENT_ROOT'])===0)
			$this->_scriptUrl=str_replace('\\','/',str_replace($_SERVER['DOCUMENT_ROOT'],'',$_SERVER['SCRIPT_FILENAME']));
		else
			throw new CException(Yii::t('yii','CHttpRequest is unable to determine the entry script URL.'));
	}
	return $this->_scriptUrl;
}

It is important that we make a note of these variables because if we don’t set them like it was an actual URL request, Yii wouldnot be able to parse the route correctly. If you want to delve in deeper, you can try to figure out why we need to override these variables but I’ve already gone deep enough in this tutorial and this should be good enough to move on.

Jumping back to the CWebApplication::processRequest() method, we see that after the route has been determined, Yii runs the controller. Let’s take a look at our final Yii method, CWebApplication::runController()

We see that Yii calls createController() which returns an array where the controller is the first value, and the action is second. For our purpose, we don’t need to know how it does it but we’ll use this method to our advantage.

Now we have enough information about Yii that we can put our test together. Jumping back to our UserControllerTest class, we can add the following lines:

$_SERVER['SCRIPT_FILENAME'] = 'index.php';
$_SERVER['SCRIPT_NAME'] =  '/index.php';
$_SERVER['REQUEST_URI'] = 'user/1';

The first two lines fool Yii into thinking this is a normal request coming through the index.php file. With mod_rewrite turned on, every request is first funnelled through the index.php file to setup the Yii application.

Next, we set the $_SERVER['REQUEST_URI'] variable to the route we want to test because we know Yii will use this later on.

$route = Yii::app()->getUrlManager()->parseUrl(new CHttpRequest());

list($controller, $action) = Yii::app()->createController($route);

Next we use the CUrlManager::parseUrl() method to get the route. I’ve passed in a new CHttpRequest object because it doesn’t really matter in this case. You could also use Yii::app()->getRequest() as well. We also use the CWebApplication::createController() method just like Yii does to get an instance of the controller and the action. It’s important to note here that $controller will contain an object while $action contains a string of the action to be called.

Finally, we can add our assert calls.

$this->assertInstanceOf('UserController', $controller);
$this->assertEquals('view', $action);

All in all, this will assert that the route ‘user/1’ is handled by the view action from the UserController.

There you have it. You are now able to test your routes in Yii. In my codebase, I have refactored most of the code into an abstract class that acts as a parent to UserControllerTest.

One other point. In order to test the other request methods - POST, PUT and DELETE - you will need to set it explicitely. This is done by:

$_SERVER['REQUEST_METHOD'] = 'POST';

In the end, our full test looks like:

public function testViewRouteResolves()
{
	$_SERVER['SCRIPT_FILENAME'] = 'index.php';
	$_SERVER['SCRIPT_NAME'] =  '/index.php';
	$_SERVER['REQUEST_URI'] = 'user/1';

	$_SERVER['REQUEST_METHOD'] = 'GET';

	$route = Yii::app()->getUrlManager()->parseUrl(new CHttpRequest());

	list($controller, $action) = Yii::app()->createController($route);

	$this->assertInstanceOf('UserController', $controller);
	$this->assertEquals('view', $action);
}

It should be easy to use this code as a starting point to test your other actions.