Need some help with your project? Contact me

Content Fixtures for Behat Testing in Drupal 7

Behat is a great testing framework for running behaviour driven testing (in BDD) against your site. The Drupal extention for Behat allows a tighter integration with Drupal that makes propping up tests for a Drupal site that much faster.

In this article we are going to look at a solution for having dummy content available on a Drupal 7 installation that allows Behat to run its tests against. We are not going to cover the basics of Behat or test driven development so I suggest you check out the resources I mentioned above and others for getting up to speed with those issues if you are not already.

The problem

Unlike testing frameworks like Simpletest, Behat runs your tests against the current site (Drupal environment in our case). You may use a CI tool that replicates your production site somewhere on another server and then runs the tests against that instance. However, in many cases you will need to have some dummy content configured in a specific way that validates the functionalities you've built for the site. This is especially true during development. Moreover, you need to also ensure that all cases of your tests can be tested (not just the ones that happen to be triggered by the content of your current production db).

A handy solution

One solution is to write scenarios or backgrounds that create said content and remove it after the tests. This is the approach of Simpletest-like integration tests. However, a better (and faster) way is to use the Migrate module and create migrations for the dummy content that just needs to be there. With a CSV source for your data, you can very quickly set up some node or other kind of migrations that you can run and rollback whenever you want.

The great part of this approach is that you can then leverage Behat hooks to run your migrations before the test suite starts and roll them back when it ends. Or at any step you want. This allows you to leave the site in a clean state after tests have run and you avoid having to manually run the migrations before and roll them back after.

A Fixture trait

Since you may have more than one method related to fixturizing your environment for the tests, you can define a Trait that you can use inside the FeatureContext class of your Behat testing setup. In there you can put all the related methods, such as the ones tagged with the @BeforeSuite and @AfterSuite Behat tags.

Let's see a quick example setup:

/**
 * @BeforeSuite
 */
public static function enableFixtureModules(\Behat\Testwork\Hook\Scope\BeforeSuiteScope $scope) {
  module_enable(array('migrate', 'my_example_migration_module'));
}

This method is run whenever the Behat testing suite is started and all it does is enable the relevant modules. The my_example_migration_module module contains our migrations.

It's counterpart that runs at the end of the suite would be this:

/**
 * @AfterSuite
 */
public static function disableFixtureModules(\Behat\Testwork\Hook\Scope\AfterSuiteScope $scope) {
  module_disable(array('migrate', 'my_example_migration_module'));
}

The method names are not important as Behat looks at the docblock for the tags that start with the @ sign.

Now we have Migrate and our migration module enabled while the test is running. Let's also set up the methods responsible for actually running the migrations.

/**
 * @BeforeFeature @fixtures
 */
public static function runAllMigrations(BeforeFeatureScope $scope) {
  $machine_names = self::getAllFixtureMigrations(true);
  foreach ($machine_names as $machine_name) {
    self::runMigration($machine_name);
  }
}

This method is called by Behat before each testing Feature that has the @fixtures tag on it. Its counterpart for when the Feature is done would be this:

/**
 * @AfterFeature @fixtures
 */
public static function revertAllMigrations(AfterFeatureScope $scope) {
  $machine_names = self::getAllFixtureMigrations();
  self::revertMigrations($machine_names);
}

Let's take a quick look at getAllFixtureMigrations():

protected static function getAllFixtureMigrations($register = false) {
  if (!module_exists('my_example_migration_module')) {
    return array();
  }

  module_load_include('inc', 'my_example_migration_module', 'my_example_migration_module.migrate');
  $migrations = my_example_migration_module_migrate_api();
  $machine_names = array();
  foreach ($migrations['migrations'] as $name => $migration) {
    $machine_names[] = $name;
  }

  if ($register) {
    migrate_static_registration($machine_names);
  }

  return $machine_names;
}

In this method we return all the migrations defined in our my_example_migration_module module. Depending on the $register parameter, we also statically register them so they can be run.

Now let's see what runMigration is all about:

protected static function runMigration($machine_name) {
  $migration = Migration::getInstance($machine_name);
  $dependencies = $migration->getHardDependencies();
  if ($dependencies) {
    foreach ($dependencies as $name) {
      self::runMigration($name);
    }
  }
  $migration->processImport();
}

This method simply loads a migration, runs the import on its dependencies and then on the original migration. This way we ensure that the migration has its dependencies already met.

Finally, let's see what the revertMigrations is all about:

protected static function revertMigrations($machine_names) {
  $dependencies = array();
  foreach ($machine_names as $machine_name) {
    $migration = Migration::getInstance($machine_name);
    $dependencies += $migration->getDependencies();
  }

  foreach ($dependencies as $dependency) {
    $dependencies[$dependency] = $dependency;
  }

  // First revert top level migrations (no dependencies)
  foreach ($machine_names as $machine_name) {
    if (in_array($machine_name, $dependencies)) {
      continue;
    }
    self::revertMigration($machine_name);
  }

  if ($dependencies) {
    self::revertMigrations($dependencies);
  }
}

This is is a bit more complex, but not really. First, it loads each migration and creates an array of migrations which are dependencies of others (these need to be reverted last). Then it reverts all the migrations which are not a dependency of any other migration. The method for actually reverting the migration is simple:

protected static function revertMigration($machine_name) {
  $migration = Migration::getInstance($machine_name);
  $migration->processRollback(array('force' => true));
}

Lastly, though, the revertMigrations() method runs itself again passing the array of migrations which were a dependency. This way they get re-processed and reverted in the right order (migrations that are not a dependency first) until no migrations are left.

And this is pretty much it. We have a rudimentary run-all-migrations-on-this-feature kind of content fixture solution.

You can take it way further though to increase performance and flexibility. You can group your migrations depending on whatever criteria you want, and create various tags that only import and rollback the right migrations (depending on the actual feature being run).

Hope this helps.

Comments

This is super cool and handy, though I have encountered an issue which I have yet to figure out how to resolve. What happens is an exception is thrown during the PHP shutdown process, killing the end of the test.

If a change was made to node controlled by moderation, and the node is published, then a shutdown function gets registered and it's passed a cloned copy of the node. This is problematic because what you have here deletes everything before the PHP shutdown process startes, and when moderation tries to re-load the node in the shutdown function (based on the cloned node's nid) it can't because the node has already been deleted, so it throws an exception when an operation is attempted on the falsely presumed loaded node object.

It's pretty obnoxious. In case anybody else is running into this issue, and you're running Workbench Moderation, have a look at this first to see if it's your issue.

Add new comment

You can post comments in Markdown and basic HTML tags.
For code blocks, wrap your code within '~~~'. For example:
~~~
$var = 'my variable';
~~~