Introduction
Have you ever experienced any of the following symptoms?
- You have changed code at one place in your module that broke things in another place and you spent way too long trying to track down the problem (or worse, didn't find it at all until your users started yelling?).
- You frequently (or not frequently enough ;)) waste dozens of minutes clicking around on all kinds of forms in order to test your module.
- Your module doesn't have enough users to act as a QA team, so basically you're it.
- You are paranoid to change anything in your code once it's working since there are ooky edge cases, and you can't ever remember what they all are.
- Thinking about developing your module gives you nausea and/or shortness of breath. ;)
If so, this article describes a cure: creating unit test coverage for your module using SimpleTest.
Revision Moderation is a module I wrote about a year ago to accomplish one "simple" task: leave the existing copy of a node there when new revisions are created, so that later revisions are not immediately visible until they're approved. People are depending on this module to protect their sites from vandalism, so it's vitally important that the module is working as expected at all times.
I was therefore experiencing all of the above, and finally got sick of it and decided to do something about it. Here's how I created unit test coverage for Revision Moderation module, and how you can do the same for your module. Note #1: If you have not yet read Robert's excellent article An Introduction to Unit Testing in Drupal, please do so! This article assumes you have the basics of that one down, have SimpleTest module installed, yadda yadda.
Note #2: These tests are against the 6.x version of the module. Some small things will be different for 5.x, such as permission names.
Note #3: The full .test file is available as an attachment below. Use it to follow along with the code snippets, or to copy/paste from liberally when creating unit tests for your own modules.
Sketching out some skeleton code
There are two components to SimpleTest-enabling a Drupal module: implementing hook_simpletest in your module so that SimpleTest can execute its tests, and creating one or more .test files which hold the tests themselves.
Here's the hook_simpletest I added to revision_moderation.module, which tells it to look in a sub-directory of revision_moderation called "tests" for any test files.
/**
* Implementation of hook_simpletest().
*/
function revision_moderation_simpletest() {
$dir = drupal_get_path('module', 'revision_moderation') .'/tests';
$tests = file_scan_directory($dir, '\.test$');
return array_keys($tests);
}
Then, in your module's tests directory create a .test file to hold some stub code for your tests:
// $Id$
/**
* Unit tests for Revision Moderation module.
*/
class RevisionModerationTest extends DrupalTestCase {
/**
* Drupal SimpleTest method: return metadata about the test.
*/
function get_info() {
return array(
'name' => t('Revision Moderation'),
'desc' => t('Executes test suite for Revision Moderation module.'),
'group' => t('Revision Moderation module'),
);
}
}
Now, when you go to Administer >> Site building >> SimpleTest unit testing (admin/build/simpletest), you should see a section for your module:
So what does your module do, anyway?
The first task is just to make a simple list of things that your module does. This list will translate directly to SimpleTest tests.
For Revision Moderation module, this list is relatively small:
- If a given content type has the "Revisions go into moderation" checkbox checked, the currently published version should stay published, while the user's changes should go into a new revision.
- If a user has created a revision, and that revision hasn't yet been published, the node edit form should fetch the unpublished revision for that particular user when populating the fields.
- If the "Exempt administrators from revision moderation" checkbox is enabled, then any users with "administer nodes" permissions should have their revisions published immediately.
Add some stub functions to your .test file at the bottom to act as place-holders for each of your tests. The naming convention dictates that they must start with lowercase "test" and then initial capitals to describe what will be tested within.
/**
* Ensure moderated revisions are not immediately published.
*/
function testModeration() {
// TODO: Put some code here.
}
/**
* On edit, ensure that in-moderation revision shows up in the form.
*/
function testModerationEdit() {
// TODO: Put some code here.
}
/**
* Ensure exemption option is working properly.
*/
function testModerationExemption() {
// TODO: Put some code here.
}
We'll start filling these out in more detail in a moment.
Setting the stage: Preparation tasks
Often times, there need to be certain things in place before testing can begin. For example, Revision Moderation module needs the following:
- The Revision Moderation module has to be enabled.
- We need a "moderated" content type with both the "Revisions go into moderation" and "Create new revision" options enabled.
- We also need an "unmoderated" content type *without* "Revisions go into moderation", but with "Create new revision."
- Finally, we need two different users: "normal" users whose revisions go into moderation, and "administrator" users who can bypass revision moderation if the option is set.
We can use the built-in SimpleTest function setUp()
to handle these kinds of preparation tasks. This function executes before each test is run. If your module requires setup tasks, copy and paste the following after your get_info()
function:
/**
* SimpleTest core method: code run before each and every test method.
*
* Optional. You only need this if you have setup tasks.
*/
function setUp() {
// TODO: Put your code here.
// Always call the setUp() function from the parent class.
parent::setUp();
}
There is also a sister function to setUp()
called tearDown()
which can handle undoing some of the tasks done by the tests. By using the SimpleTest API functions, however, most of this is handled for you. This function is executed after each test is run.
/**
* SimpleTest core method: code run after each and every test method.
*
* Optional. You only need this if you have setup tasks.
*/
function tearDown() {
// TODO: Put your code here.
// Always call the tearDown() function from the parent class.
parent::tearDown();
}
If there are certain values you're going to need to access from every test, you can create variables for them. For example, the tests for Revision Moderation module need access to four different values: the piece of moderated content, the piece of unmoderated content, the normal user, and the administrative user. I therefore added the following variables above the get_info()
function:
/**
* A global piece of moderated content.
*/
var $moderated_content;
/**
* A piece of unmoderated content.
*/
var $unmoderated_content;
/**
* A global basic user who is subject to moderation.
*/
var $basic_user;
/**
* A global administrative user who may bypass moderation.
*/
var $admin_user;
These will get populated a little bit later.
A Tour of SimpleTest Functions
Here's Revision Moderation module's setUp() function in bite-sized chunks, so you can get a sense of how to do similar things in your own modules.
Enabling/Disabling modules
The DrupalTestCase object comes with two methods to handle enabling or disabling modules: drupalModuleEnable()
and drupalModuleDisable()
, respectively. You enable modules if they're required in order to execute your tests, and you disable modules if they're known to cause conflicts.
For example, here's a line of code in setUp()
which enables the Revision Moderation module:
// Make sure that Revision Moderation module is enabled.
$this->drupalModuleEnable('revision_moderation');
An astute reader might point out that Drupal already has functions for enabling and disabling modules: module_enable()
and module_disable()
. So why not just use those? Because these special functions will keep track of the state of the modules before the tests ran and ensure that they're returned to that state when they complete.
Setting variables
Often you need to do things like set publishing options on a content type, or toggle on a setting from your module. Use the drupalVariableSet()
function.
Here are the lines of code to set Revision Moderation's content type options:
// Enable publishing options on the Page content type, our "moderated"
// example:
// - Published = TRUE
// - Create new revision = TRUE
// - New revisions in moderation = TRUE
$options = array(
'status',
'revision',
'revision_moderation',
);
$this->drupalVariableSet('node_options_page', $options);
// Enable publishing options on the Story content type, our
// "unmoderated" example:
// - Published = TRUE
// - Create new revision = TRUE
// - New revisions in moderation = FALSE
$options = array(
'status',
'revision',
);
$this->drupalVariableSet('node_options_story', $options);
Again, even though Drupal already has a variable_set()
function, using drupalVariableSet()
is better, because it will return the variables back to their original state when the tests are completed.
Working with Users, Roles, and Permissions
The method drupalCreateUserRolePerm()
creates a user with a random name and adds them to a role with a given set of permissions. When the tests are completed, users created with this function are automatically removed, along with their content.
Once you've created a user with drupalCreateUserRolePerm()
, you can then use drupalLoginUser()
to impersonate a user with that permission set. This function will only work with users created by drupalCreateUserRolePerm()
, because it needs access to the raw_pass
value in order to login.
Here's the chunk of code that creates the basic and administrative users for Revision Moderation module:
// Create a basic user, which is subject to moderation.
$permissions = array(
'access content',
'create page content',
'edit own page content',
'create story content',
'edit own story content',
);
$basic_user = $this->drupalCreateUserRolePerm($permissions);
// Create an admin user that can bypass revision moderation.
$permissions = array(
'access content',
'administer nodes',
);
$admin_user = $this->drupalCreateUserRolePerm($permissions);
// Assign users to their test suite-wide properties.
$this->basic_user = $basic_user;
$this->admin_user = $admin_user;
That last chunk assigns the user accounts to the variables that we created above, which are visible throughout the test suite, so that we can access them outside of the setUp()
function.
Creating content
The final thing we need for our setup stuff is to create a couple of nodes: one that's in revision moderation and one that's not.
// Login as basic user to perform initial content creation.
$this->drupalLoginUser($this->basic_user);
// Create a moderated piece of content.
$edit = array();
$edit['title'] = $this->randomName(32);
$edit['body'] = $this->randomName(32);
$this->drupalPostRequest('node/add/page', $edit, t('Save'));
$moderated = node_load(array('title' => $edit['title']));
// Create an unmoderated piece of content.
$edit = array();
$edit['title'] = $this->randomName(32);
$edit['body'] = $this->randomName(32);
$this->drupalPostRequest('node/add/story', $edit, t('Save'));
$unmoderated = node_load(array('title' => $edit['title']));
// Assign nodes to their test suite-wide properties.
$this->moderated_content = $moderated;
$this->unmoderated_content = $unmoderated;
// Logout as basic user.
$url = url('logout', array('absolute' => TRUE));
$this->get($url);
A couple salient points from this snippet of code:
- The nodes are created by the basic user, logged in using the
drupalLoginUser()
method. That way, moderation should kick in on the page content type. - The nodes' Title and Body are both set to some random 32-character string using the
randomName()
method. This is necessary, because the nodes need to be pulled back out later by title (since the node ID won't be known), and this way there's a pretty good chance of that title being unique. - The
drupalPostRequest()
function is used to submit the form at node/add/page and node/add/story with the given values. You can also add other things to the $edit array if need be in order to test your module. - Finally, in order to log the user out again, the
get()
method is used to retrieve the logout URL. If this isn't done, errors will pop up ifdrupalLoginUser()
is called twice in a row.
Note that there's currently a patch in the SimpleTest queue to add a nice drupalCreateNode() function instead of doing this manual process.
All right! Let's start testing this thing, already!
Testing your module involves re-using some of the above API functions, as well as a new type of function called an assertion. This is basically a check to ensure that a thing you want either does or does not exist. If everything goes according to plan, the test passes. If it doesn't, the test fails.
Each assertion function takes at least two arguments:
- First, one or more arguments that are things to compare; text that ought to not be there, numbers that ought to match, etc.
- Finally, the last argument is always an optional string to display if the test fails. It should provide some clue as to what the test was looking for when it failed.
Let's take a look at the full testModeration()
as an example.
/**
* Ensure moderated revisions are not immediately published.
*/
function testModeration() {
// Login as basic user.
$this->drupalLoginUser($this->basic_user);
// Edit moderated piece of content.
$node = $this->moderated_content;
$edit = array();
$edit['title'] = $this->randomName(32);
$edit['body'] = $this->randomName(32);
$this->drupalPostRequest("node/$node->nid/edit", $edit, t('Save'));
// Ensure that changes do NOT appear.
$this->assertWantedRaw(t('Your changes have been submitted for moderation.'), t('Moderation message not found'));
$url = url("node/n/$node->nid", array('absolute' => TRUE));
$contents = $this->get($url);
$this->assertNoText($edit['body'], t('Edited content found on moderated node.'));
// Edit the unmoderated piece of content.
$node = $this->unmoderated_content;
$edit = array();
$edit['title'] = $this->randomName(32);
$edit['body'] = $this->randomName(32);
$this->drupalPostRequest("node/$node->nid/edit", $edit, t('Save'));
// Ensure that changes DO appear.
$url = url("node/$node->nid", array('absolute' => TRUE));
$contents = $this->get($url);
$this->assertText($edit['body'], t('Edited content not found on unmoderated node.'));
}
There are a few new functions here worth looking at:
assertWantedRaw()
: After a POST request, you can use this function (and its sister functionassertNoUnwantedRaw()
) to check for the existence (or non-existence) of certain text. Useful when looking for text set bydrupal_set_message()
after submission of a form.assertText()
: Checks to see whether the given text appears within the current page. There's alsoassertNoText()
.
These functions are used throughout the test in order to ensure that things are working properly. assertWantedRaw()
is used to make sure that the 'Your changes have been submitted for moderation.'
message appears after the basic user submits the form. assertText()
and assertNoText()
are used to check for the existence/non-existence of the node body, depending on the state of moderation.
A full list of assertion functions may be found in the various chapters of the SimpleTest documentation.
Checking test results
Before committing anything, head to Administer >> Site building >> SimpleTest unit testing (admin/build/simpletest), check off "Select all tests in this group" for your module, and hit the "Begin" button. With luck, your output will look something like this:
You'll note that the number of tests executed will be way above the number that you did yourself. This is because each time you run one of the SimpleTest API functions, it does several assertX() functions itself.
If something goes wrong, you might see something like this:
If this happens (and it might; I've caught a couple core bugs during the creation of these tests :)), then scroll down the list until you find one outlined in red to figure out where things went awry. Sometimes it means your test needs to be updated in order to reflect changes upstream, and other times it means something legitimately broke. Congratulations, you just saved yourself the pain, embarrassment, and ridicule of a buggy check-in. ;) Fix up those bugs!
Once your tests are working properly, ideally you from now on do what is called test-driven development, where whenever you go to add a new feature/fix a bug in your module, you write your tests first, then fill in code until they pass. This both forces you to think through what it is you actually want the feature to entail, as well as ensures that you know when it's actually doing that thing. :) Once you get in the habit of doing this, it's amazing how freeing it can be -- suddenly your code feels invincible. ;)
Conclusion
Unit testing is smart, it saves you time, and it gives you peace of mind:
- Tests document what the heck it is your module's actually supposed to do.
- Tests document all the various edge cases that break can and have broken your module in the past.
- Tests help automate the extensive manual process that you'd normally have to go through to ensure things are working.
In this article we covered the following topics:
- Adding a
hook_simpletest()
to your module so it's picked up by SimpleTest. - Creating a .test file for your module to hold your tests.
- How the various SimpleTest API functions can be used to prepare your module for testing.
- What assertions are and how they can be used to check your code.
Happy testing!