Bringing test driven development to Drupal 5
UPDATE: Since this post was written phpunit_setup.inc has been moved into the Drupal TDD project. phpunit_setup.inc has also been updated to not require the drupal6_backports.inc file. Read my post on the changes for the details.
Inspired by the work of Pat Shaughnessy in a series of great articles he wrote on Test Driven Development (TDD) using Drupal (1, 2, 3, 4, 5, 6, 7, and 8) I was motivated to take his code and use it on my current Drupal project.
Pat’s Solution
Following the pattern of TDD established by Ruby on Rails, Pat introduced the use of a command-line unit testing framework (PHPUnit) and then works the reader through using it to develop a simple Drupal module. Inevitably Pat reaches a point in his example where he must begin loading test data into the database if his code is to be tested properly.
He begins by experimenting with different techingues to stage and unstage data at the beginning of each unit test. Perhaps inevitably he then run into problems when tests fail or interact with each other in unanticipated ways. This is where he takes it to the next level.
First, he introduces a system to initialize a seperate test database at the start of each testing cycle. This ensures that the test database is unaffected by the production data and can’t accidentally corrupt a production website.
Second, he runs each unit test in it’s own database transaction. Transactions are a database tool which allow each database connection to work with it’s own unique snapshot of the database. Any changes made to the database only appear on that connection until the transaction is committed to the database. If the connection is closed the changes are never committed, and the connection has no permanent affect on the database.
Practically speaking transactions allow each test to load any data they require and make any changes to the database they need without needing to worry about cleaning up after them selves. When the test is over the database rolls back the tests changes automatically and efficiently leaving a clean slate for the next unit test.
Pat wraps all of these changes up in a file called phpunit_setup.inc which you just just have to include at the start of your tests. It expects that you’ve created a test database and a user who can access it, and entered the connection details into the list of database connection in Drupal. (See his original post for the details.)
Not only is this solution easier to maintain than complex database snapshots and loading scripts, but it’s more performant.
Implementing TDD in Drupal 5
Now let’s bring this great code of Pat’s back into Drupal 5. Let’s try running it like we would in Drupal 6 and see what happens:
$ phpunit TestDataExampleTest2 modules/tdd/TddTests.php Fatal error: Call to undefined function drupal_install_system() in /home/mark/Work/LitDistCo/litdistco-website/includes/phpunit_setup.inc on line 85
Hmm, that’s a problem. A little digging in the Drupal 6 API documentation reveals that the drupal_install_system() function is new in Drupal 6 and isn’t present in Drupal 5. As we’re not interested in waiting for the client to upgrade to Drupal 6, we’re going to backport it ourself!
Since we want to avoid modify Pat’s code as much as possible we’re going to create a new include file called drupal6_backports.inc. This will contain comparable versions of the Drupal 6 functions, which provide the functionality neccesary to run Pat’s code. Any of your Drupal 5 tests will need to include this file before you include phpunit_setup.inc. The code looks like this:
# Include the functions from Drupal 6 neccesary to run phpunit_setup.inc # REMOVE THIS INCLUDE ONCE YOU'VE UPGRADED TO DRUPAL 6 require_once('includes/drupal6_backports.inc');
Later on when the tests are running on Drupal 6 you can remove these includes.
To backport drupal_install_system() I began by looking at the the source code from the Drupal API documentation:
function drupal_install_system() { $system_path = dirname(drupal_get_filename('module', 'system', NULL)); require_once './'. $system_path .'/system.install'; module_invoke('system', 'install'); $system_versions = drupal_get_schema_versions('system'); $system_version = $system_versions ? max($system_versions) : SCHEMA_INSTALLED; db_query("INSERT INTO {system} (filename, name, type, owner, status, throttle, bootstrap, schema_version) VALUES('%s', '%s', '%s', '%s', %d, %d, %d, %d)", $system_path .'/system.module', 'system', 'module', '', 1, 0, 0, $system_version); // Now that we've installed things properly, bootstrap the full Drupal environment drupal_bootstrap(DRUPAL_BOOTSTRAP_FULL); module_rebuild_cache(); }
There don’t appear to be any new Drupal 6 functions in this code so I pasted it into drupal6_backports.inc as is and ran the unit tests again (remembering to add the line to include the backports mentioned above.)
$ phpunit TestDataExampleTest2 modules/tdd/TddTests.php Fatal error: Call to undefined function default_profile_tasks() in /home/mark/Work/LitDistCo/litdistco-website/includes/phpunit_setup.inc on line 88
Closer but it looks like we’re still missing something. This time it’s the default_profile_tasks() function. The code from the Drupal 6 API documentation looks like this:
function default_profile_tasks(&$task, $url) { // Insert default user-defined node types into the database. For a complete // list of available node type attributes, refer to the node type API // documentation at: http://api.drupal.org/api/HEAD/function/hook_node_info. $types = array( array( 'type' => 'page', 'name' => st('Page'), 'module' => 'node', 'description' => st("A <em>page</em>, similar in form to a <em>story</em>, is a simple method for creating and displaying information that rarely changes, such as an \"About us\" section of a website. By default, a <em>page</em> entry does not allow visitor comments and is not featured on the site's initial home page."), 'custom' => TRUE, 'modified' => TRUE, 'locked' => FALSE, 'help' => '', 'min_word_count' => '', ), array( 'type' => 'story', 'name' => st('Story'), 'module' => 'node', 'description' => st("A <em>story</em>, similar in form to a <em>page</em>, is ideal for creating and displaying content that informs or engages website visitors. Press releases, site announcements, and informal blog-like entries may all be created with a <em>story</em> entry. By default, a <em>story</em> entry is automatically featured on the site's initial home page, and provides the ability to post comments."), 'custom' => TRUE, 'modified' => TRUE, 'locked' => FALSE, 'help' => '', 'min_word_count' => '', ), ); foreach ($types as $type) { $type = (object) _node_type_set_defaults($type); node_type_save($type); } // Default page to not be promoted and have comments disabled. variable_set('node_options_page', array('status')); variable_set('comment_page', COMMENT_NODE_DISABLED); // Don't display date and author information for page nodes by default. $theme_settings = variable_get('theme_settings', array()); $theme_settings['toggle_node_info_page'] = FALSE; variable_set('theme_settings', $theme_settings); // Update the menu router information. menu_rebuild(); }
We can repeat the same process as last time, taking the code from Drupal 6 and inserting it into drupal6_backports.inc then running the script again.
$ phpunit TestDataExampleTest2 modules/tdd/TddTests.php Fatal error: Call to undefined function actions_synchronize() in /home/mark/Work/LitDistCo/litdistco-website/includes/phpunit_setup.inc on line 90
I actually had to repeat this process twice more. Once to add the actions_synchronize() function and again to add the _drupal_flush_css_js() function. Each of these functions don’t really make sense in the context of Drupal 5 since one invokes actions only associated with Drupal 6 modules, and the other refreshes the random strings addes to CSS and javascript files which also doesn’t happen in Drupal 5. This makes the code for both of them very easy to write:
function actions_synchronize() { # Just included for compatibility with newer Drupal version # Don't do anything return; } function _drupal_flush_css_js() { # Just included for compatability with newer Drupal version # Don't do anything return; }
By including drupal_6_backports.inc before phpunit_setup.inc at the top of the tests you can now execute them and they will begin to run. And run. AND RUN!!!
What’s going on here?
Stepping into the phpunit_setup.inc script I can see the test database connection being opened. It then calls this function:
function each_table($table_callback) { global $db_url; $url = parse_url($db_url['test']); $database = substr($url['path'], 1); $result = db_query("SELECT table_name FROM information_schema.tables WHERE table_schema = '$database'"); while ($table = db_result($result)) { $table_callback($table); } }
This function call the $table_callback() function once for each table in the database, passing the table name in as a parameter. If I inspect the value of $table however, I see that it’s simply passing the same $table name again and again. Worse the expression:
$table = db_result($result)
never evaulates to false, so the code never exits. So why is this happening?
It turns out the Drupal 6 and 7 redefine the db_result() function such that subsequent calls will step through the results of a query one row at a time. In Drupal 5 db_result() takes a second optional parameter indicating which row of the results to read. It’s defined like this:
function db_result($result, $row = 0)
So we’re not actually iterating through the results as expected but reading the first row of the result set again and again. As a compromise I’ve changed Pat’s original function to:
function each_table($table_callback) { global $db_url; $url = parse_url($db_url['test']); $database = substr($url['path'], 1); $result = db_query("SELECT table_name FROM information_schema.tables WHERE table_schema = '$database'"); while ($table = db_fetch_array($result)) { $table_callback($table['table_name']); } }
I’ve replaced the call to db_result() with one to db_fetch_array(). db_fetch_array() works the same in Drupal 6 as it does in Drupal 5 so this code will work fine in both versions.
Running it again we see that the tests run correctly:
$ phpunit TestDataExampleTest2 modules/tdd/TddTests.php PHPUnit 3.2.16 by Sebastian Bergmann. .. Time: 0 seconds OK (2 tests)
My patch has been submitted back to Pat so that he’ll hopefully integrate them back into his post however for the time being I’ve packaged my changes into phpunit_setup_drupal5.inc.
And that’s it! By following Pat’s instructions and including drupal_6_backports.inc and phpunit_setup_drupal5.inc in your tests you can perform full test driven development with test fixtures and an isolated test database in Drupal 5! Now go try it for yourself!
UPDATE: I should point out that if you’re going to test your own modules, you need to edit the profiles/default/default.profile and include your modules in the default_profile_modules() function on line 11.











Hi Mark,
THANKS for all of the kind comments in your article and for linking back to my blog. Greatly appreciated! I found some time today to try out your stuff: The first thing I did was to install Drupal 5 and download your “drupal6_backports.inc” and “phpunit_setup_drupal5.inc” files. I followed all of the steps in your article, and was able to reproduce the same problems you did, and then got it all to work by following your instructions… nice job! I’m definitely impressed that you got it all to work without making any changes at all to what I had originally written, except for using db_fetch_array instead of just db_result. One cool thing about what you’ve done is that your unit tests could help you someday when you do have to or want to upgrade from Drupal 5 to Drupal 6. You just remove drupal6_backports.inc, replace Drupal with the newer version and re-run your tests… if anything fails you know you have a problem to look into. If they pass then your application should continue to work. Normally upgrading Drupal can be a nightmare; with a test harness to help you it should be much easier.
As a sanity check I also compared the MySQL schema created by your code vs. a standard Drupal 5 install. If you look at the code in “install.php” in the root folder of Drupal 5, you can see how a Drupal 5 site is normally setup. What I found is that there are three extra tables created by phpunit_setup_drupal5.inc and drupal6_backports.inc that aren’t created by the standard install code in install.php: these tables are “locales_meta,” “locales_source” and “locales_target.” It turned out this difference was caused by phpunit_setup_drupal5.inc passing in “en” to drupal_install_modules(drupal_verify_profile(‘default’, ‘en’)), while during a standard Drupal 5 setup in English this locale value would be null. There were no other major differences between the standard Drupal 5 database and the test Drupal 5 database your code created. Cool!
The one suggestion I have to make things a bit safer and cleaner would be to just emulate what install.php in Drupal 5 does, calling Drupal 5 functions where possible, instead of moving some of the Drupal 6 code back into Drupal 5. For example, instead of calling drupal_install_system() from Drupal 6 and then drupal_install_modules(drupal_verify_profile(‘default’, ‘en’)), you could just call the Drupal 5 drupal_install_profile() function (see install.php around line 88 for where it’s called, and install.inc line 309 for the function).
Let me know what you think and then we can post the updated phpunit_setup.inc in a common place (github). Thanks again!
@pat
Thanks for comments. I appreciate your insight into the way that the drupal_install_system() and drupal_install_profile() functions operate. I agree that the database setup by phpunit_setup.inc should match the production database as closely as possible.
I’ll take a closer look at install.php and see if I can create a function to setup the environment correctly in both Drupal 5 and 6.