Test-driven Drupal development

Drupal's implementation of SimpleTest-style testing is an amazing tool for developing complex applications. When code is complex enough, a simple change can break things in unexpected places, but with good test coverage, this breakage can quickly be discovered and fixed. The same goes for deploying Drupal sites. By default SimpleTest creates a parallel test database from the ground up, meaning that it installs a brand new Drupal site, rather than copy the instance of Drupal it is installed on. This is all well and good for module development, and maintaining a solid core, but when it comes to testing complex site configurations, it would be nice to be able to test that all the installed modules, configured in such and such a way, are all playing nicely together. Taking this one step further, for a really complex site, it becomes possible to write a set of acceptance tests before site configuration even starts. Knowing that these tests must eventually pass can significantly focus and drive the development work.

That's where overriding the DrupalWebTestCase setUp() function comes into play. SimpleTest will run the setUp() function as defined in the DrupalWebTestCase() class unless the current test case has an overriding setUp() function. Some existing tests append additional functionality during set up, but most usually call the parent method, which is responsible for installing that fresh instance of Drupal.
To copy the existing set up, the parent function can be overridden on a per-test-case basis:

  class MyModuleHelperTestCase extends DrupalWebTestCase {
 
    /**
     * Implementation of setUp().
     */
    function setUp() {
      // Here the existing Drupal instance will be cloned.
    }

By defining the function here, the parent method won't be called by SimpleTest. Also note that this helper test case can be called by additional test cases so that the cloning of the database doesn't need to be re-coded for each test case. In order to clone the existing set up, the database schema is needed. Once we have the schema, it is looped through, creating identical tables, and then populating them:

  /**
   * Implementation of setUp().
   */
  function setUp() {
    // Don't create test db via Drupal install method, instead copy existing db.
    global $db_prefix;
 
    // Store necessary current values before switching to prefixed database.
    $this->db_prefix_original = $db_prefix;
 
    // Get schema of existing database.
    $schemas = drupal_get_schema();
 
    // Generate prefixed database so tests don't interfere with live database.
    $this->db_prefix_test = $db_prefix = 'simpletest' . mt_rand(1000, 1000000);
 
    // Copy each table into new database.
    foreach ($schemas as $name => $schema) {
      $this->cloneTable($name, $schema);
    }
 
    // NOTE: Everything below here is the same as parent::setUp().
    // Rebuild caches.
    menu_rebuild();
    actions_synchronize();
    _drupal_flush_css_js();
    $this->refreshVariables();
 
    // Use temporary files directory with the same prefix as database.
    $this->original_file_directory = file_directory_path();
    variable_set('file_directory_path', file_directory_path() . '/' . $db_prefix);
    file_check_directory(file_directory_path(), TRUE); // Create the files directory.
  }

The key piece of code in there is the call to the function cloneTables(). This creates the identical table, with the testing prefix, and then copies all the data from the live database, to the test table. Note that due to some odd behavior in mysql (in that it disallows inserting of a 0 value into a primary key field), special handling is needed for the users table.

  /**
   * Mirror over an existing tables structure, and copy the data.
   *
   * @param $name
   *   Table name.
   * @param $schema
   *   A Schema API definition array.
   * @return
   *   Array of table creation results.
   */
  function cloneTable($name, $schema) {
    $return = array();
    db_create_table($return, $name, $schema);
 
    if ($name == 'users') {
      // UID = 0 confuses mysql. Special handling here, taken from system.install
      db_query("INSERT INTO %s SELECT uid +1, name, pass, mail, mode, sort, threshold, theme, signature, created, access, login, status, timezone, language, picture, init, data FROM %s", $this->db_prefix_test . $name, $this->db_prefix_original . $name);
      // Update uid
      db_query("UPDATE %s SET uid = uid - 1", $this->db_prefix_test . $name);
    }
    else {
      // Copy over data (brackets are /not/ used or this wouldn't work).
      db_query("INSERT INTO %s SELECT * FROM %s", array($this->db_prefix_test . $name, $this->db_prefix_original . $name));
    }
  }

That's it! At this point, we have a test database that is functionally identical to our existing database, so we can hack away at it with the built in testing browser, and make sure everything is behaving as expected. In order to not duplicate the above set up function for every test case we write, additional test cases extend the helper test case:

class MyModuleFooBarTestCase extends MyModuleHelperTestCase {
 
  ...
 
  function testSomePathBehavior() {
    $this->drupalGet('some/path');
    $this->assertRaw('<div id="my-expected-id">foobar</div>', t('Found foobar on some/path'));
    ...
  }
}

For more information on writing acceptance tests and unit tests for Drupal, check out the handbook pages.

Tagged as: Drupal, SimpleTest, Test-driven development

7 comments

Stefan Kudwien (not verified) wrote 1 year 40 weeks ago

The auto value during the

The auto value during the user table import could also be suppressed by issuing the following command (taken from here):

SET SQL_MODE="NO_AUTO_VALUE_ON_ZERO";

Except, of course, you absolutely do not want vendor-specific SQL in your code.

kscheirer (not verified) wrote 1 year 39 weeks ago

nice post

this sort of thing will become very valuable as testing moves beyond drupal-core and the rest of the community gets serious about it. thanks for the tip!

drewish (not verified) wrote 1 year 14 weeks ago

As a module?

This might be handy to distribute as a module. That way anyone could just extend the module's class rather than having to put this same code into each of their tests.

drewish (not verified) wrote 1 year 14 weeks ago

Hey thanks a lot for this

Hey thanks a lot for this blogpost it saved me a bunch of time but I had to make some changes to get this working with the current version of SimpleTest and also to get it working with db prefixes:

class MyModuleHelperTestCase extends DrupalWebTestCase {
  /**
   * Implementation of setUp().
   */
  function setUp() {
# BEGIN BLOCK FROM DrupalWebTestCase::setUp()
    global $db_prefix, $user;
 
    // Store necessary current values before switching to prefixed database.
    $this->originalPrefix = $db_prefix;
    $clean_url_original = variable_get('clean_url', 0);
# END BLOCK FROM DrupalWebTestCase::setUp()
 
    // Because the schema is static cached, we need to flush
    // it between each run. If we don't, then it will contain
    // stale data for the previous run's database prefix and all
    // calls to it will fail.
    $schemas = drupal_get_schema(NULL, TRUE);
 
# BEGIN BLOCK FROM DrupalWebTestCase::setUp()
    // Generate temporary prefixed database to ensure that tests have a clean starting point.
//    $db_prefix = Database::getConnection()->prefixTables('{simpletest' . mt_rand(1000, 1000000) . '}');
    $db_prefix = 'simpletest' . mt_rand(1000, 1000000);
 
# END BLOCK FROM DrupalWebTestCase::setUp()
 
    // Copy each table into new database.
    foreach ($schemas as $name => $schema) {
      $this->cloneTable($name, $schema);
    }
 
# BEGIN BLOCK FROM DrupalWebTestCase::setUp()
    // Because the schema is static cached, we need to flush
    // it between each run. If we don't, then it will contain
    // stale data for the previous run's database prefix and all
    // calls to it will fail.
    drupal_get_schema(NULL, TRUE);
 
    // Rebuild caches.
    actions_synchronize();
    _drupal_flush_css_js();
    $this->refreshVariables();
    $this->checkPermissions(array(), TRUE);
 
    // Log in with a clean $user.
    $this->originalUser = $user;
    session_save_session(FALSE);
    $user = user_load(array('uid' => 1));
 
    // Restore necessary variables.
    variable_set('install_profile', 'default');
    variable_set('install_task', 'profile-finished');
    variable_set('clean_url', $clean_url_original);
    variable_set('site_mail', 'simpletest@example.com');
 
    // Use temporary files directory with the same prefix as database.
    $this->originalFileDirectory = file_directory_path();
    variable_set('file_directory_path', file_directory_path() . '/' . $db_prefix);
    $directory = file_directory_path();
    file_check_directory($directory, FILE_CREATE_DIRECTORY); // Create the files directory.
    set_time_limit($this->timeLimit);
# END BLOCK FROM DrupalWebTestCase::setUp()
  }
 
  /**
   * Correctly prefix a table name.
   *
   * This code is based off of core's db_prefix_tables().
   *
   * @param $db_prefix
   *   Mixed array or string with prefixes.
   * @param $name
   *   String with the table name.
   * @return
   *   String with prefixed table name.
   */
  function prefixTable($db_prefix, $name) {
    if (is_array($db_prefix)) {
      if (array_key_exists($name, $db_prefix)) {
        return $db_prefix[$name] . $name;
      }
      elseif (array_key_exists('default', $db_prefix)) {
        return $db_prefix['default'] . $name;
      }
      return $name;
    }
    return $db_prefix . $name;
  }
 
  /**
   * Mirror over an existing tables structure, and copy the data.
   *
   * @param $name
   *   Table name.
   * @param $schema
   *   A Schema API definition array.
   * @return
   *   Array of table creation results.
   */
  function cloneTable($name, $schema) {
    global $db_prefix;
 
    $return = array();
    db_create_table($return, $name, $schema);
 
    // Do our own prefixing of the table names.
    $source = db_escape_table($this->prefixTable($this->originalPrefix, $name));
    $target = db_escape_table($this->prefixTable($db_prefix, $name));
 
    if ($name == 'users') {
      // UID = 0 confuses mysql. Special handling here, taken from system.install
      db_query("INSERT INTO $target (uid, name, pass, mail, mode, sort, threshold, theme, signature, created, access, login, status, timezone, language, picture, init, data)
        SELECT uid + 1, name, pass, mail, mode, sort, threshold, theme, signature, created, access, login, status, timezone, language, picture, init, data FROM $source");
      // Update uid
      db_query("UPDATE $target SET uid = uid - 1");
    }
    else {
      db_query("INSERT INTO $target SELECT * FROM $source");
    }
  }
}

Jonathan Hedstrom wrote 1 year 13 weeks ago

Re: release as a module

Releasing this as a module is a good idea. We're already using it internally as a module. I'll try to clean up the code a bit, and incorporate the changes so it runs with the latest version of simpletest, then hopefully release it next week.

Michael Schwern wrote 1 year 13 weeks ago

tearDown() needed.

@drewish Thanks for that update. I've been struggling getting it to work with SimpleTest 2.7. It needed one final addition to work for more than one test function. The module_list() gets reset by SimpleTest to the bootstrap configuration. That needs to be undone.

  function tearDown() {
    parent::tearDown();
 
    // simpletest's tearDown() rebuilds module_list() in bootstrap
    // mode.  This will cause things like drupal_get_schema() to
    // only think devel modules are loaded.
    // Put it back into run mode.
    module_list(TRUE, FALSE);
  }

Add your comment

The content of this field is kept private and will not be shown publicly.
  • Web page addresses and e-mail addresses turn into links automatically.
  • Allowed HTML tags: <a> <em> <strong> <cite> <code> <ul> <ol> <li> <dl> <dt> <dd>
  • Lines and paragraphs break automatically.
  • You can enable syntax highlighting of source code with the following tags: <code>, <blockcode>. Beside the tag style "<foo>" it is also possible to use "[foo]".

More information about formatting options