Zend Framework Testing with PHPUnit – Setup
Finally, I've started on the follow-up post on Zend Framework Unit Testing (link to previous post)! After a whooping four months. I've been really busy - perhaps with the wrong reason. Anyway, this post shall follow up on the previous post by continuing on the AllTests.php file that was created at the end of the last post.
Readers who are first time users of Zend Framework should find this and the last post helpful in getting your feet wet with unit testing with Zend Test. The focus of this post is on preparing the setup and teardown phases of the testing sequence.
Parent Test Class
One thing I decided straight up with using Zend Test is that I want a super-class of tests so that I can create code needed by all tests in this class. All the test classes then inherit from this super-class to get the code.
This class is placed in the same directory as the AllTests.php. Let's call this class Db_Test and place it in the file "Db_Test.php". This file will be used to setup and teardown the database for each test as we will discuss later.
Directory Structure
Following the example from the previous post, the parent test class is placed like so:
- application/
- library/
|- PHPUnit/ (here I assume you downloaded the PHPUnit files by hand)
|- Zend/
- public/
- tests/
|- AllTests.php
|- <strong>Db_Test.php</strong>
We will create another folder (named "default") inside the tests directory. This sub-directory will contain the tests for all the classes in the default module. If you have more than one module, then you can create more than one sub-directory and name them correspondingly.
Within each sub-directory of modules are where all the tests reside. My preference is for all the tests for controllers to reside in a "controllers" directory and tests for models to be placed in a "models" directory.
What about views? In my opinion, a view is always associated with an action of a controller. So if there is a need to test for the presence of a visual element, I would do it in the controller.
So my directory will eventually look like this:
- application/
- library/
|- PHPUnit/ (here I assume you downloaded the PHPUnit files by hand)
|- Zend/
- public/
- tests/
|- default/
|- controllers
|- models
|- AllTests.php
|- Db_Test.php
AllTests.php
This file begins by declaring the application environment like so:
define('APPLICATION_ENVIRONMENT', 'testing');
This sets the stage for your other functions in deciding whether to execute a certain code based on the development phase.
Similar to the bootstrap file, the application path is declared like this (change to your own configuration):
define('APPLICATION_PATH', realpath(dirname(dirname(__FILE__)) . '/application/'));
The necessary library files are included using the following statement:
set_include_path(APPLICATION_PATH . '/../library' . PATH_SEPARATOR . get_include_path());
The bootstrap file is also included. Note that the bootstrap file is executed AFTER this AllTests.php file. This has some implications when working with the database later on.
require_once '../application/bootstrap.php';
After adding the library to the include path, we can then invoke the Zend_Loader to perform auto-loading of classes.
require_once 'Zend/Loader.php';
Zend_Loader::registerAutoload();
Here is the interesting part - we explicitly include the super class for all test classes to inherit from. Remember that this class is placed in the same folder as AllTests.php.
require_once 'Db_Test.php';
All including the parent class, we can then include the child classes for testing. In this case we are including the test suite for the default module's models. Note that this is another AllTests.php file - the reason why this structure is used is because there will be many tests within each module. We leave it to the AllTests.php file in each module's directory to make sure that the required tests are included.
require_once 'default/models/AllTests.php';
After all the preparatory statements above, the AllTests class is finally defined.
class AllTests
{
public static function run()
{
PHPUnit_TextUI_TestRunner::run(self::suite(), array());
}
public static function suite()
{
$suite = new PHPUnit_Framework_TestSuite('Rojak Test');
$suite->addTest(DefaultModelsAllTests::suite());
return $suite;
}
}
AllTests::run();
The run()
function is the function that will be invoked
after you execute the file on the command line. This function does
nothing more than to start the application to perform testing. Note
that it uses the only other function defined in the class,
suite()
.
What suite()
does is to add the test classes into the
test suite. By doing so, all the classes in the suite that have
functions beginning with the word "test" will be
automatically invoked to perform the tests. It does this by first
creating an instance of the PHPUnit_Framework_TestSuite
class. Give any name as the parameter to the constructor. The
following line adds the test classes to the instance of the test
suite created by the first line. In this example, it is assumed
there is another class that has the static function
suite()
which returns a suite of test cases. I'll
explain more on this later or in another post.
Finally, the suite is returned by the function, which is in turn
used by the run()
function to invoke the tests.
Since the test is going to be run from the command line, the last
line of this file AllTests::run()
will start executing
the test program.
So here is the entire AllTests.php
file.
AllTests.php
/**
* Set the environment to be testing.
*/
define('APPLICATION_ENVIRONMENT', 'testing');
/**
* Sets the application path constant for file inclusion.
*/
define('APPLICATION_PATH', realpath(dirname(dirname(__FILE__)) . '/application/'));
/**
* Make the library/ sub-folder an include path.
*/
set_include_path(
APPLICATION_PATH . '/../library'
. PATH_SEPARATOR . get_include_path()
);
/**
* Include the bootstrap file for testing - this is the same file used when
* running the actual application.
*/
require_once '../application/bootstrap.php';
/**
* Enable Zend to autoload its classes.
*
* This is needed for Bay_Test to inherit from Zend_Test_PHPUnit_ControllerTestCase
*/
require_once 'Zend/Loader.php';
Zend_Loader::registerAutoload();
/**
* Required to perform database setup and teardown.
*/
require_once 'Db_Test.php';
/**
* Sets the include path for other test files.
*
* The AllTests.php files in the testing sub-folders should be included here.
*/
require_once 'default/models/AllTests.php';
/**
* The base class to invoke other test suites.
*/
class AllTests
{
/**
* Runs the test suites
* @return void
*/
public static function run()
{
PHPUnit_TextUI_TestRunner::run(self::suite(), array());
}
public static function suite()
{
$suite = new PHPUnit_Framework_TestSuite('Rojak Test');
$suite->addTest(DefaultModelsAllTests::suite());
return $suite;
}
}
AllTests::run();
Db_Test.php
This is the interesting class. With this class, we are able to prepare our database just prior to running the tests in a test suite and then destroy the database after running the test so that the next test suite can set up the database again with a pristine set of test data. This is something that I was unable to find after googling around.
Db_Test
class Db_Test extends Zend_Test_PHPUnit_ControllerTestCase
{
/**
* The bootstrap function to call to bootstrap the test cases
* @var array
*/
public $bootstrap = array('Bootstrap', 'run');
/**
* The file handle to the schema file.
* @var resource
*/
public static $setupHandle;
/**
* The file handle to the setup.sql file.
* @var resource
*/
public static $setupUsersHandle;
/**
* The file handle to the sample users file.
* @var resource
*/
public static $sampleHandle;
/**
* The file handle to the tear-down file, which only contains DROP statements.
* @var resource
*/
public static $teardownHandle;
/**
* A flag to indicate whether setup has been performed.
* @var false
*/
private static $setupDone = false;
/**
* A flag to indicate whether teardown has been performed.
* @var false
*/
private static $teardownDone = false;
/**
* Performs the database initialization logic.
*
* Runs the schema file (testing_schema_setup.sql), set-up file (setup.sql),
* and sample data file (sample.sql) to create the database necessary to
* run test cases.
* @return void
*/
public function setUp()
{
parent::setUp();
$db = Zend_Db_Table::getDefaultAdapter();
if (self::$setupDone && !self::$teardownDone) {
return;
} else {
//opens schema setup file
self::$setupHandle = fopen(
APPLICATION_PATH . '/config/sql/testing_schema_setup.sql', 'r');
self::$setupUsersHandle = fopen(
APPLICATION_PATH . '/config/sql/setup.sql', 'r');
//opens sample data file
self::$sampleHandle = fopen(
APPLICATION_PATH . '/config/sql/sample.sql', 'r');
if (self::$setupHandle) {
$statement = '';
//runs through each SQL CREATE statement to create the database for testing
while (!feof(self::$setupHandle)) {
//gets a line from the file and add to the statement
$buffer = fgets(self::$setupHandle);
//add to the statement if not a comment
if (strncmp($buffer, '--', 2)) {
$statement .= $buffer;
}
//if the end of one statement is reached,
if (!strncasecmp($buffer, ') ENGINE=INNODB', 15))
{ //run the create statement on the DB
$db->getConnection()->exec($statement);
$statement = ''; //clears the current statement
}
}
}
if (self::$setupUsersHandle) {
$statement = '';
while (!feof(self::$setupUsersHandle)) {
$buffer = fgets(self::$setupUsersHandle);
if (strncmp($buffer, '--', 2)) {
$statement .= $buffer;
}
}
$pdoConn = $db->getConnection()->query($statement);
$pdoConn->closeCursor();
}
if (self::$sampleHandle) {
$statement = '';
while (!feof(self::$sampleHandle)) {
$buffer = fgets(self::$sampleHandle);
if (strncmp($buffer, '--', 2)) {
$statement .= $buffer;
}
}
$pdoConn = $db->getConnection()->query($statement);
$pdoConn->closeCursor();
//the above is needed because it is the PDO object that is being used
}
self::$setupDone = true;
self::$teardownDone = false;
}
//run the ACL setup method in the bootstrap file
Bootstrap::setupAcl();
}
/**
* Closes the file handles and removes all tables from the database.
* @return void
*/
public function tearDown()
{
$db = Zend_Db_Table::getDefaultAdapter();
if APPLICATION_PATH . '/config/sql/testing_schema_teardown.sql', 'r');
if (self::$teardownHandle) {
$statement = '';
while (!feof(self::$teardownHandle)) {
$statement .= fgets(self::$teardownHandle);
}
$pdoConn = $db->getConnection()->query($statement);
$pdoConn->closeCursor();
}
fclose(self::$setupHandle);
fclose(self::$setupUsersHandle);
fclose(self::$sampleHandle);
fclose(self::$teardownHandle);
self::$teardownDone = true;
self::$setupDone = false;
parent::tearDown();
}
}
I'll try to explain each portion of the class definition above.
class Db_Test extends Zend_Test_PHPUnit_ControllerTestCase
This is the beginning of the class definition. Notice that it
inherits the Zend_Test_PHPUnit_ControllerTestCase
class. By
inheriting from this class, you can have the test suite perform
bootstrapping like so:
public $bootstrap = array('Bootstrap', 'run');
The statement above simply tells the Db_Test class that the run() function in the Bootstrap class should be run as the bootstrap function. (This assumes that you do indeed have such a class Bootstrap with the function run().)
public static $setupHandle;
public static $setupUsersHandle;
public static $sampleHandle;
public static $teardownHandle;
The above variables are just file handles to the SQL files that are needed to set up and remove the database for each test suite.
private static $setupDone = false;
private static $teardownDone = false;
The above two are just flags to help determine whether to run the SQL set up/tear down files. They are used in the, surprise, surprise, setUp() and tearDown() functions.
I'm going to explain the entire setUp() function in one breath. I really don't want to drag this post longer than it already is, so this is my feeble attempt to keep the post short.
public function setUp()
{
parent::setUp();
$db = Zend_Db_Table::getDefaultAdapter();
if (self::$setupDone && !self::$teardownDone) {
return;
} else {
//opens schema setup file
self::$setupHandle = fopen(
APPLICATION_PATH . '/config/sql/testing_schema_setup.sql', 'r');
self::$setupUsersHandle = fopen(
APPLICATION_PATH . '/config/sql/setup.sql', 'r');
//opens sample data file
self::$sampleHandle = fopen(
APPLICATION_PATH . '/config/sql/sample.sql', 'r');
if (self::$setupHandle) {
$statement = '';
//runs through each SQL CREATE statement to create the database for testing
while (!feof(self::$setupHandle)) {
//gets a line from the file and add to the statement
$buffer = fgets(self::$setupHandle);
//add to the statement if not a comment
if (strncmp($buffer, '--', 2)) {
$statement .= $buffer;
}
//if the end of one statement is reached,
if (!strncasecmp($buffer, ') ENGINE=INNODB', 15))
{ //run the create statement on the DB
$db->getConnection()->exec($statement);
$statement = ''; //clears the current statement
}
}
}
if (self::$setupUsersHandle) {
$statement = '';
while (!feof(self::$setupUsersHandle)) {
$buffer = fgets(self::$setupUsersHandle);
if (strncmp($buffer, '--', 2)) {
$statement .= $buffer;
}
}
$pdoConn = $db->getConnection()->query($statement);
$pdoConn->closeCursor();
}
if (self::$sampleHandle) {
$statement = '';
while (!feof(self::$sampleHandle)) {
$buffer = fgets(self::$sampleHandle);
if (strncmp($buffer, '--', 2)) {
$statement .= $buffer;
}
}
$pdoConn = $db->getConnection()->query($statement);
$pdoConn->closeCursor();
//the above is needed because it is the PDO object that is being used
}
self::$setupDone = true;
self::$teardownDone = false;
}
}
So, first of all the parent function is invoked so that the necessary set up logic is performed before our custom logic is implemented.
Then the database adapter is retrieved to enable the creation of database tables. At this point, the adapter is already initialised because the bootstrap function would have already been executed.
The two flags are checked to determine whether the database tables have been created or not, and whether the tables have been destroyed or not. This is necessary because without this, attempting to create the table and insert data into the tables will result in an error.
If the tables have not been created, the SQL files are opened and then the SQL statements are pieced together and executed.(The comments are skipped.) When all the three SQL files have been executed, the $setupDone flag is set to true and $teardownDone flag is set to false. This way, the next time a test case is executed, it will know not to attempt to create the database tables or insert the data.
The tearDown() function works in a similar fashion as the setUp() function.
public function tearDown()
{
$db = Zend_Db_Table::getDefaultAdapter();
if (self::$setupDone && !self::$teardownDone) {
//opens schema teardown file
self::$teardownHandle = fopen(
APPLICATION_PATH . '/config/sql/testing_schema_teardown.sql', 'r');
if (self::$teardownHandle) {
$statement = '';
while (!feof(self::$teardownHandle)) {
$statement .= fgets(self::$teardownHandle);
}
$pdoConn = $db->getConnection()->query($statement);
$pdoConn->closeCursor();
}
fclose(self::$setupHandle);
fclose(self::$setupUsersHandle);
fclose(self::$sampleHandle);
fclose(self::$teardownHandle);
self::$teardownDone = true;
self::$setupDone = false;
}
parent::tearDown();
}
The adapter is again retrieved. The flags are checked to ensure that the database tables have been set up, and then the SQL tear down file is read in and each statement is executed in turn to remove each table.
Here each of the file handle is closed to free up the resource.
The flags are set to indicate that the tear down operation has been done - this will avoid running the tear down script when no tables have not been created.
Finally, the parent's tearDown() method is invoked at the end to perform any other tearDown() method. Notice that this is reverse of the setUp() method where the parent method is invoked right at the start.
Conclusion
With the Db_Test class defined, all your other test classes can then inherit from this class. You are assured that before each test suite, the database is set up with the right set of test data that will enable you to match the test results with your expected results.
I hope the article has been clear - in trying to keep the length short I've omitted some details which is not essential to the post. If you have any questions, please post in the comments and I'll try my best to answer. No guarantees though. ;)
Have fun testing!