Click to See Complete Forum and Search --> : Testing Controllers in Zend Framework


groovepapa
04-25-2007, 11:55 AM
I posted this to my blog, but the code sections were ugly. I'm going to try posting here to see if it looks any nicer.

-------

I've been playing around with Zend Framework, and while it has some idiosyncrasies, I'm enjoying it for the most part. I wanted to add testing onto the zf-tutorial app (http://akrabat.com/zend-framework-tutorial/). For anyone to get anything out of this, they should go thru this "tutorial" (http://www.alexatnet.com/blog/2/2007/03/20/automatic-testing-of-mvc-applications-created-with-zend-framework) over at the Alex @ Net blog.

Alex's work is an awesome starting point. It was, however, a bit of a chore to add on the kind of Controller "unit"-testing I like to do - i.e. constraint each test's code to the controller's action's alone, so that's the task I'm going to explain.

Though I graciously tip my hat to Alex for the advice of setting the tests directory to match the application directory, and to include all the bootstrap code in the AllTests.php suite file, I didn't like the way that the controller test code had lots of FrontController setup required. Nor do I particularly like the idea of "unit" tests that jump thru routing, dispatching, and view and only assert against view results. IMHO, "unit" testing controllers should be succinct and test only the controller code - not the FrontController logic, and not the view - and should make assertions on the assigned view variables instead of the view output. As you'll see, I didn't actually cut out the FrontController logic, I simply hid that code from my tests in a way that lets me get straight at the specific controller I'm testing. There seems to be some tight coupling between Action controllers and the Front controller in Zend Framework(?) so it has to stay.

So, to get on with my changes:

I added a BaseControllerTestCase class right next to my AllTests file. Sorry, my directory layout is a bit different from Alex's ...

/tests/BaseControllerTestCase.php

class BaseControllerTestCase extends PHPUnit_Framework_TestCase{

public $request;
public $response;
public $controllerClass;
public $actionMethod;

public function setUp(){
$this->frontController = Zend_Controller_Front::getInstance();
$this->response = new Zend_Controller_Response_Http();
$this->frontController->returnResponse(true)->setResponse($this->response);
}

protected function initController($url){
$this->request = $this->makeRequest($url);
$this->frontController->setRequest($this->request);
$dispatcher = $this->frontController->getDispatcher();
$controllerClass = $dispatcher->getDefaultControllerClass($this->request);
$this->actionMethod = $dispatcher->getActionMethod($this->request);
return new $controllerClass($this->request, $this->response);
}

protected function makeRequest($uri){
return new Zend_Controller_Request_Http($uri);
}

}

Then, I changed my tests to extend from this class so I can use the initController method which hides all the FrontController junk from my test code.

/tests/application/controllers/IndexControllerTest.php

class IndexControllerTest extends BaseControllerTestCase{

public function testIndexAction(){
// initialize controller using BaseControllerTestCase::initController
$controller = $this->initController('http://localhost/index/index');
// need to localize $this->actionMethod to dynamically call it by name on controller?
$actionMethod = $this->actionMethod;
// execute controller's specific action method
$controller->$actionMethod();

// get view's vars and make assertions against them
$viewVars = $controller->view->getVars();
$this->assertEquals("My Albums",$viewVars['title']);
$albums = $viewVars['albums'];
$this->assertTrue($albums instanceof Zend_Db_Table_Rowset);
$this->assertEquals(2,$albums->count());
}

}

That's it. It might be a little crude, but it gets the job done and it makes my test more concise and to-the-point, IMO.

As I said, I couldn't seem to totally de-couple the controller from the FrontController process completely. But I don't think that's as serious a flaw as testing the View and Controller in the same test. I'll probably write a BaseViewTestCase class in the future. If I ever feel like testing views, that is. :)

groovepapa
04-29-2007, 01:29 AM
Okay, after working with it some more, I found out my previous solution sucks (doesn't even work on anything except index/index), so I changed it. I like this approach much better, but it's not totally optimal either.

I'm using a special IndexControllerForTest class to override the render and _redirect methods so as not to test any of the view logic that's normally wired up with the Controller. I only test the view *variables* assigned by the Controller.

I also completely ditched the FrontController, so I'm not testing any of the routing or dispatching logic that's usually wired into the controller. I initialize the specific IndexControllerForTest with some empty request and response objects (helper methods in BaseTestCase), and then set up either the GET or the POST with whatever params I need for the test.

I think this is a much cleaner, and more focused, solution.

Here's the new BaseTestCase:


class BaseControllerTestCase extends PHPUnit_Framework_TestCase{

public $request;
public $response;

protected function setUp(){
$this->response = $this->makeResponse();
$this->request = $this->makeRequest();
}

protected function makeRequest($url = null){
return new Zend_Controller_Request_Http($url);
}

protected function makeResponse(){
return new Zend_Controller_Response_Http();
}

protected function setUpPost(array $params = array()){
$_SERVER['REQUEST_METHOD'] = 'POST';
foreach($params as $key=>$value){
$_POST[$key] = $value;
}
}

protected function setUpGet(array $params = array()){
$_SERVER['REQUEST_METHOD'] = 'GET';
foreach($params as $key=>$value){
$_GET[$key] = $value;
}
}

}

And here's my expanded IndexControllerTest.php:


class IndexControllerForTest extends IndexController{
public $renderRan = false;
public $redirectRan = false;
public function render(){
$this->renderRan = true;
}
public function _redirect(){
$this->redirectRan = true;
}
}

class IndexControllerTest extends BaseControllerTestCase{

public function testIndexAction_AssignsVarsAndRenders(){
$indexController = new IndexControllerForTest($this->request,$this->response);
$this->setUpGet();

$indexController->indexAction();

$viewVars = $indexController->view->getVars();
$this->assertEquals("My Albums",$viewVars['title']);
$albums = $viewVars['albums'];
$this->assertTrue($albums instanceof Zend_Db_Table_Rowset);
$this->assertEquals(2,$albums->count());
$this->assertTrue($indexController->renderRan);
}

public function testAddAction_Get_AssignsFormVarsAndRenders(){
$indexController = new IndexControllerForTest($this->request,$this->response);
$this->setUpGet();

$indexController->addAction();

$viewVars = $indexController->view->getVars();
$this->assertEquals('Add New Album',$viewVars['title']);
$this->assertEquals('add',$viewVars['action']);
$this->assertEquals('Add',$viewVars['buttonText']);
$this->assertTrue($indexController->renderRan);
}

public function testAddAction_PostWithParams_RunsInsertAndRedirects(){
$indexController = new IndexControllerForTest($this->request,$this->response);
$this->setUpPost(array('artist'=>'phish','title'=>'hoist','add'=>'Add'));

$mockAlbum = $this->getMock('Album',array('insert'));
$mockAlbum->expects($this->once())
->method('insert')
->with(array('artist'=>'phish','title'=>'hoist'));

$indexController->setAlbum($mockAlbum);
$indexController->addAction();

$this->assertTrue($indexController->redirectRan);
}

public function testEditAction_GetWithId_AssignsFormVarsAndRenders(){
$indexController = new IndexControllerForTest($this->request,$this->response);
$this->setUpGet(array('id'=>310));

$indexController->editAction();

$viewVars = $indexController->view->getVars();
$this->assertEquals('Edit Album',$viewVars['title']);
$this->assertEquals('edit',$viewVars['action']);
$this->assertEquals('Update',$viewVars['buttonText']);
$album = $viewVars['album'];
$this->assertTrue($album instanceof Zend_Db_Table_Row);
$this->assertTrue($indexController->renderRan);
}

public function testEditAction_PostWithParams_RunsUpdateAndRedirects(){
$indexController = new IndexControllerForTest($this->request,$this->response);
$this->setUpPost(array('id'=>310,'artist'=>'phish','title'=>'hoist'));

$mockAlbum = $this->getMock('Album',array('update'));
$mockAlbum->expects($this->once())
->method('update')
->with(array('artist'=>'phish','title'=>'hoist'),'id = 310');
$indexController->setAlbum($mockAlbum);

$indexController->editAction();

$this->assertTrue($indexController->redirectRan);
}

}

Shrike
04-29-2007, 07:22 AM
Have you looked at web tests? I prefer to use those for testing views and controllers. I test models in isolation using standard test cases.

groovepapa
04-29-2007, 10:55 AM
I use selenium for integration tests, yeah. It's good for testing use cases and everyhing, but this is a class for *unit*-testing controllers. It tooks some time to work it out, and I think it's pretty nice if you want to test some off-web controllers.

Just posting it in case someone out there is looking for a starting point in testing controllers in ZF. But we've been doing TDD for a while now at work so I might have an over-test mentality. ;)

groovepapa
04-30-2007, 02:06 AM
Last note on this topic ... I wrote up a tutorial and posted it here:

http://tulsaphp.net/?q=node/40

Shrike
05-04-2007, 07:32 PM
I think perhaps I used the wrong terms. The web test portion of Simpletest extends the unit test framework, but in all likelihood is just like Selenium in that it's black box testing.

When i say 'standard test cases' I mean unit tests proper.

Nice work with the tutorial. I am keeping a close eye on the Zend Framework and will quite possibly pick it up once it hits that elusive 1.0.

groovepapa
05-04-2007, 10:12 PM
Ah, okay. I've not used SimpleTest before. I didn't know it had web tests as well. That's pretty slick. The testing bits I wrote were for PHPUnit, just because that's the one I've used in the past. I think the ZF uses it internally as well. I'm enjoying ZF as much as I've done with it so far, though.