View Source

<ac:macro ac:name="unmigrated-inline-wiki-markup"><ac:plain-text-body><![CDATA[{zone-template-instance:ZFPROP:Proposal Zone Template}
{composition-setup}

{zone-data:component-name}
Zend_Controller Testing Infrastructure
{zone-data}

{zone-data:proposer-list}
[~matthew]
Zend Liaison [~ralph]
{zone-data}

{zone-data:liaison}
TBD
{zone-data}

{zone-data:revision}
1.0 - 27 May 2008: Community Draft.
1.1 - 27 May 2008: Proposal draft for Zend review
{zone-data}

{zone-data:overview}
Since I originally undertook the MVC refactoring project in the fall of 2006, I've been touting the fact that you can test ZF MVC projects by utilizing the Request and Response objects; indeed, this is what I actually did to test the Front Controller and Dispatcher. However, there is not currently an easy way to do so; the default request and response objects make it difficult to easily and quickly setup tests, and the methods introduced into the front controller to make it testable are largely undocumented. The only resource developers currently have for this sort of testing is the unit tests themselves.

Testing against the response object is somewhat tedious; you have to be regex guru in many cases, and the testing itself includes many calls to the response object in order to grab the values to test against.

This proposal aims to simplify testing MVC applications by providing tools to allow assertions against the DOM structure of your response, specialized request/response objects that mimic the HTTP environment and also prevent spurious headers and content from being generated, and more.
{zone-data}

{zone-data:references}
* [PHPUnit|http://www.phpunit.de/]
* [dojo.query|http://api.dojotoolkit.org/jsdoc/dojo/HEAD/dojo.query]
* [Prototype's $$()|http://prototypejs.org/api/utility/dollar-dollar]
* [Working POC|http://framework.zend.com/svn/framework/standard/branches/user/matthew/mvcTesting]
{zone-data}

{zone-data:requirements}
The following application areas should be testable:
* Response status: HTTP response codes (assume 200, but several methods will set the response code in the response object)
* Response headers: presence of headers and content of headers
* Content:
** what nodes are present, selected by DOM id and/or CSS class selectors
** content of selected nodes, both straight string comparisons and by regex
{zone-data}

{zone-data:dependencies}
* Zend_Controller
* Zend_Exception
* PHPUnit
{zone-data}

{zone-data:operation}
In most cases, you don't need to go into terribly fine-grained detail, but you need to verify that particular elements or types of elements are present in the response, as well as potentially match small segments of the content. The response status is generally desirable, and often you need to test where an action may redirect.

When selecting nodes in XML or XHTML, the "gold standard" is represented by Prototype's $$() function, and Dojo Toolkit's dojo.query() method. Each of these utilize CSS selectors to qualify the nodes to select; CSS selectors allow us to specify specific node types, node ids, as well as the values contained in the 'class' attribute of nodes; additionally, it has a concept of inheritance, to indicate parent/child relations. This kind of specificity can allow a developer to check that _types_ of content are present, instead of the _specific_ content -- which may be variable, due to having unreliable sources (database, for instance).

Additionally, if a developer wishes to use straight XPath, they should be able to do so as well.

I'm proposing several items. First, a component for selecting against DOM artifacts:

* Zend_Dom_Query

This class is named to indicate first, its function, and second, an affinity for dojo.query() (since $$ is a name that's hard to translate to PHP). This component would be utilized to parse response body content. It would allow querying via CSS selectors or XPath.

Second, a test case for controllers that extends PHPUnit_Extensions_Database_TestCase:

* Zend_Test_PHPUnit_ControllerTestCase (extends PHPUnit_Extensions_Database_TestCase)

This test case would provide scaffolding for bootstrapping the MVC application being tested, methods for dispatching a request and retrieving the response, and a number of assertions for running against the request.

The assertions include:
* assertSelect() (assert that nodes specified by CSS selectors exist)
* assertNotSelect()
* assertSelectContentContains() (assert that nodes found by CSS selector contain the text specified)
* assertNotSelectContentContains()
* assertSelectContentRegex() (assert that nodes found by CSS selector match the pattern specified)
* assertNotSelectContentRegex()
* assertSelectCount() (assert that exactly the number of nodes found by CSS selector are found)
* assertNotSelectCount()
* assertSelectCountMin() (assert that at least the specified number of nodes are found by CSS selector)
* assertSelectCountMax() (assert that at most the specified number of nodes are found by CSS selector)
* assertXpath() (assert that nodes specified by XPaths exist)
* assertNotXpath()
* assertXpathContentContains() (assert that nodes found by XPath contain the text specified)
* assertNotXpathContentContains()
* assertXpathContentRegex() (assert that nodes found by XPath match the pattern specified)
* assertNotXpathContentRegex()
* assertXpathCount() (assert that exactly the number of nodes found by XPath are found)
* assertNotXpathCount()
* assertXpathCountMin() (assert that at least the specified number of nodes are found by XPath)
* assertXpathCountMax() (assert that at most the specified number of nodes are found by XPath)
* assertResponseCode() (assert that the given response code was given)
* assertNotResponseCode()
* assertHeader() (assert that a given header type was provided)
* assertNotHeader()
* assertHeaderContains() (assert that a given header type was provided and contains the text specified)
* assertNotHeaderContains()
* assertHeaderRegex() (assert that a given header type was provided and matches the pattern specified)
* assertNotHeaderRegex()
* assertRedirect() (assert that the response is a redirect)
* assertNotRedirect() (assert that the response is a redirect)
* assertRedirectTo() (assert that the response redirects to the given url)
* assertNotRedirectTo()
* assertRedirectRegex() (assert that the response redirects to a url matching the given pattern)
* assertNotRedirectRegex()
* assert(Not)AttributeExists($domNode, $attr)
* assert(Not)AttributeEquals($domNode, $attr, $value)
* assertDataSetInsert($table)
* assertDataSetDelete($table, $id)
* assertDataSetUpdated($table, $id)

Finally, custom request/response objects would also be created to facilitate testing (in particular, adding accessors to set request data), with corresponding interfaces for the functionality required by testing. This latter would faciliate custom request/response objects provided by the developer.

* Zend_Controller_Request_HttpTestCase
* Zend_Controller_Response_HttpTestCase

h4. bootstrap() Method

By default, checks if $bootstrapFile is set, and if so, includes that file.

Override the method to provide specific functionality.

If using a bootstrap file, the file should not dispatch. Additionally, users should be aware that custom request and response objects will be set in the front controller if none are set in the TestCase.

h4. reset() Method

Reset's object state of front controller, and clears request/response objects.

h4. setUp() Method

By default, calls reset() and bootstrap(). When overriding, should call parent::setUp().

h4. dispatch($uri) Method

Sets $uri in request object, and then dispatches front controller.

Sets returnResponse() in front controller to ensure response is returned.

h4. Sessions
May need to create a Zend_Session replacement that can be used within tests. Must be compatible with Zend_Session_Namespace. Will be an object store to allow you to check the contents of the session to see if data has been set.

h4. New Assertions

h5. assertSelect*() family

Basically, these operates similar to Prototype's $$() function; you pass them CSS selector notation, which allows for ids, classes, and inheritance. Based on the assertion type and arguments you pass, you assert different things:

* assertSelect('#foo') - foo id exists
* assertNotSelect('#foo') - foo id does not exist
* assertSelectContentContains('#foo', 'bar') - content of foo contains 'bar'
* assertNotSelectContentContains('#foo', 'bar') - content of foo does not contain 'bar'
* assertSelectContentRegex('#foo', '/bar/i') - regex match on content of foo
* assertNotSelectContentRegex('#foo', '/bar/i') - negative regex match on content of foo
* assertSelectCountMin('td#foo>tr', 3) - at least three rows in the foo table
* assertSelectCountMax('td#foo>tr', 3) - no more than three rows in the foo table

All assertions would accept an optional extra argument, a $message to return on failure.

h5. assertXpath*() family
All assertSelect* assertions would have corresponding assertXpath assertions that accept an XPath expression as an argument.

h5. assertResponse(), assertRedirect()

Tests various properties of the response:

* assertResponseCode(200) - 200 OK response code
* assertResponseCode(404) - assert page not found
* assertNotResponseCode(404) - assert NOT page not found
* assertResponseCode(500) - assert application error
* assertHeader('X-App-Id') - X-App-Id header in response
* assertNotHeader('X-App-Id') - X-App-Id header not in response
* assertHeaderContains('X-App-Id', 'bar') - X-App-Id header contains 'bar'
* assertHeaderRegex('X-App-Id', '/bar/i') - regex match on X-App-Id header
* assertRedirect() - response is a redirect
* assertNotRedirect() - response is not a redirect
* assertRedirectTo('/foo') - response redirects to /foo
* assertNotRedirectTo('/foo') - response does not redirect to /foo
* assertRedirectRegex('|^/foo|') - regex match on response redirect
* assertNotRedirectRegex('|^/foo|') - negative regex match on response redirect
{zone-data}

{zone-data:milestones}
* Milestone 1: \[DONE\] Submit proposal for review
* Milestone 2: Complete working tests and code for implementation
* Milestone 3: Documentation, use cases, and tutorials
{zone-data}

{zone-data:class-list}
* Zend_Controller_Request_HttpTestCase
* Zend_Controller_Response_HttpTestCase
* Zend_Dom_Exception
* Zend_Dom_Query
* Zend_Dom_Query_Css2Xpath
* Zend_Dom_Query_Result
* Zend_Test_PHPUnit_ControllerTestCase
* Zend_Test_PHPUnit_Constraint_Exception
* Zend_Test_PHPUnit_Constraint_DomQuery
* Zend_Test_PHPUnit_Constraint_Redirect
* Zend_Test_PHPUnit_Constraint_ResponseHeader
{zone-data}

{zone-data:use-cases}
||UC-01||
{code:php}
class FooControllerTest extends Zend_Test_PHPUnit_ControllerTestCase
{
public $bootstrap = dirname(__FILE__) . '/../bootstrap.php';

public function testArchivesRouteShouldContainNabble()
{
$this->dispatch('/archives');
$this->assertResponse(200);
$this->assertSelect('#nabble');
}

public function testNabbleInArchivesShouldContainScript()
{
$this->testArchivesRouteShouldContainNabble();
$this->assertSelectContains('#nabble', '<script');
}

public function testAddPersonShouldRedirect()
{
$this->getRequest()->setPost($this->newPersonData);
$this->dispatch('/person/add');
$this->assertRedirect();
}
}
{code}

{zone-data}

{zone-data:skeletons}
{deck:id=Class Skeletons1}
{card:label=Zend_Dom_Query}
{code:php}
class Zend_Dom_Query
{
/* Methods for setting document to query */
public function setDocument($document);
public function setDocumentHtml($document);
public function setDocumentXml($document);
public function getDocument();
public function getDocumentType();

/* Query a document */
public function query($query);
public function xpathQuery($query);
}
{code}
{card}

{card:label=Zend_Dom_Query_Css2Xpath}
{code:php}
class Zend_Dom_Query_Css2Xpath
{
/* Transform CSS selector to XPath */
public static function transform($path);
}
{code}
{card}

{card:label=Zend_Dom_Query_Result}
{code:php}
class Zend_Dom_Query_Result implements Iterator,Countable
{
/* Get CSS selector path, XPath path, and DOMDocument queried */
public function getCssQuery();
public function getXpathQuery();
public function getDocument();

/* Iterator, Countable interfaces */
public function rewind();
public function valid();
public function current();
public function key();
public function next();
public function countable();
}
{code}
{card}
{deck}

{deck:id=Class Skeletons2}
{card:label=Zend_Controller_Request_HttpTestCase}
{code:php}
class Zend_Controller_Request_HttpTestCase extends Zend_Controller_Request_Http
{
/* Accessors for setting environment state */
public function setQuery($spec, $value = null);
public function clearQuery();
public function setPost($spec, $value = null);
public function clearPost();
public function setRawBody($content);
public function getRawBody();
public function clearRawBody();
public function setCookie($key, $value);
public function setCookies(array $cookies);
public function clearCookies();
public function setMethod($method);
public function getMethod($method);
public function setHeader($key, $value);
public function setHeaders(array $headers);
public function getHeader($header, $default = null);
public function getHeaders();
public function clearHeaders();

/* Override functionality */
public function getRequestUri();
}
{code}
{card}

{card:label=Zend_Controller_Response_HttpTestCase}
{code:php}
class Zend_Controller_Response_HttpTestCase extends Zend_Controller_Response_Http
{
/* Override functionality */
public function sendHeaders();
public function canSendHeaders($throw = false);
public function outputBody();
public function sendResponse();
}
{code}
{card}
{deck}

{deck:id=Class Skeletons3}
{card:label=Zend_Test_PHPUnit_ControllerTestCase}
{code:php}
class Zend_Test_PHPUnit_ControllerTestCase extends PHPUnit_Framework_TestCase
{
/* Bootstrapping */
public function bootstrap();
public function reset();
public function dispatch($uri);
public function sendResponse();

/* Assertions */
public function assertSelect($pattern);
public function assertNotSelect($pattern);
public function assertSelectContentContains($pattern, $content);
public function assertNotSelectContentContains($pattern, $content);
public function assertSelectContentRegex($pattern, $regex);
public function assertNotSelectContentRegex($pattern, $regex);
public function assertSelectCount($pattern, $count);
public function assertNotSelectCount($pattern, $count);
public function assertSelectCountMin($pattern, $count);
public function assertSelectCountMax($pattern, $count);

public function assertXpath($pattern);
public function assertNotXpath($pattern);
public function assertXpathContentContains($pattern, $content);
public function assertNotXpathContentContains($pattern, $content);
public function assertXpathContentRegex($pattern, $regex);
public function assertNotXpathContentRegex($pattern, $regex);
public function assertXpathCount($pattern, $count);
public function assertNotXpathCount($pattern, $count);
public function assertXpathCountMin($pattern, $count);
public function assertXpathCountMax($pattern, $count);

public function assertResponseCode($code);
public function assertNotResponseCode($code);
public function assertHeader($header);
public function assertNotHeader($header);
public function assertHeaderContains($header, $content);
public function assertNotHeaderContains($header, $content);
public function assertHeaderRegex($header, $regex);
public function assertNotHeaderRegex($header, $regex);
public function assertRedirect();
public function assertNotRedirect();
public function assertRedirectTo($url);
public function assertNotRedirectTo($url);
public function assertRedirectRegex($regex);
public function assertNotRedirectRegex($regex);
}
{code}
{card}
{deck}
{zone-data}

{zone-template-instance}]]></ac:plain-text-body></ac:macro>