Skip to main content

Advanced Testing of Drupal emails with Behat and TestingMailSystem

One of our recent projects had the following requirements:

Users receive points for creating/commenting on content
When they receive a certain points balance they are awarded some goodies in the mail
When they receive the achievement a notification email should be sent to the user and an admin

Setting this up with Userpoints, Userpoints Rules and Rules was fairly straightforward but as part of our Agile processes, the story isn't done until there is automated testing.

Read on to learn how we setup testing the email and their contents using Behat.

by lee.rowlands /

Drupal's Mail System

In Drupal 7 mail handling is done via the mail system. This is configurable via the mail_system variable. The default mail system is DefaultMailSystem found in system.mail.inc, however, Drupal also ships with a test mail handler in the form of TestingMailSystem. The test mail system just collects all sent email into the drupal_test_email_collector variable, allowing the email content to be examined for test purposes.

Testing email with Behat

As with any Behat test, start by defining the step-definitions. Don't worry if they don't exist, Behat will give you skeleton code to add to your FeatureContext class. So our feature looks as follows:

Feature: Passing points levels

  To encourage users to participate in the site
  As an registered user
  I can be awarded points levels after reaching a certain point

  Background:
    Given users:
      | name   | mail         | field_user_points_level |
      | _bob   | _bob@foo.com | bronze                  |
      | _boss  | _bos@foo.com | bronze                  |
    And user "_boss" has the "manage point levels" role
    And I am logged in as "_bob"
    And I edit my profile
    And I fill in the following:
      | First name         | Bob   |
      | Last name          | Smith |
    And I press "Save"
    And the test email system is enabled

  @api @points @pr
  Scenario: Silver points level
    And my points level should be "bronze"
    # 100 points for signing up + 898 + 2 for comment = 1000.
    Given I have just earned "898" points in "General" category
    Given I am viewing an "article" node with the title "Please comment on this..."
    When I enter "I have something to say" for "Comment"
    And I press "Save"
    Then the email to "_bob@foo.com" should contain "Hi Bob Smith"
    And the email should contain "Congratulations - you've just reached 1000 points"
    And the email to "_bos@foo.com" should contain "User _bob reached Silver points level"
    And my points level should be "silver"

So lets look at that in detail.

The background

On the site, users start with 'Bronze' points level and move to 'Silver' once they reach 1000 points. They receive 100 points for creating their account and 2 points per comment, so in the background we create two users and set their initial points level to Bronze. One of the users '_boss' has the 'manage points levels' role, this is used in the Rules configuration to send out the admin emails (this uses a custom-step definition left as an exercise to the reader). We then log-in as the '_bob' user, edit our profile and set the first and last name fields to Bob and Smith respectively. We do this as we use tokens to form the email body and want to test that the tokens are replaced with the real name. i.e. the email body starts with Hi Bob Smith and this is formed with tokens, we want to test that these tokens are replaced in the email body. Finally, we set the mail system to use the test system.

So the first thing to observe here is that we're using the built-in 'Given users' step definition. This is part of DrupalExtension. Also, we need access to the variable_set() function from our FeatureContext (to set the email system) so we need to tag our Scenario accordingly. In this case, our behat.yml has the @api tag configured to use the Drupal driver, so we tag the scenario accordingly.

There are some more custom definitions going on here, as well as the need for some Entity events. Firstly, by default the 'Given users' step definition doesn't handle converting field names (field_user_points_level) into the appropriate field structure , so we need to add an Entity event listener to our FeatureContext and tag it with the @beforeUserCreate annotation. This event listener (method) will give us the chance to edit the user object before it is saved. Think of this like a hook_user_pre_save callback, but using Event Listeners. In this event listener we just transform the flat field_user_points_level from the users table hash into the expected format like so:

/**
   * Hook into user creation to test `@beforeUserCreate`
   *
   * @beforeUserCreate
   */
  public function alterUserParameters(EntityEvent $event) {
    // Massage the user fields into required format.
    $account = $event->getEntity();
    $fields = array(
      'field_user_points_level',
    );
    foreach ($fields as $field) {
      if (isset($account->{$field})) {
        $account->{$field} = array(
          LANGUAGE_NONE => array(
            array('value' => $account->{$field}),
          ),
        );
      }
    }
  }

The next custom step definition is And I edit my profile, also left as an exercise to the reader (hint - there are tips on working with Mink in our earlier post on custom behat step definitions for Drupal). What we're interested in here is the last custom step definition And the test email system is enabled. If you're familiar with the variable system in Drupal you could probably guess how this looks; and it's something like this:

/**
   * @Given /^the test email system is enabled$/
   */
  public function theTestEmailSystemIsEnabled() {
    // Store the original system to restore after the scenario.
    $this->originalMailSystem = variable_get('mail_system', array('default-system' => 'DefaultMailSystem'));
    // Set the test system.
    variable_set('mail_system', array('default-system' => 'TestingMailSystem'));
    // Flush the email buffer, allowing us to reuse this step definition to clear existing mail.
    variable_set('drupal_test_email_collector', array());
  }

This just stores the original mail system as a property, sets up the test system and flushes the existing buffer (allowing us to reuse this definition to clear the buffer). An alternate approach would be to setup the test mail system in the beforeScenario method, so it was available for any scenario. However, in another scenario (not shown) we needed to be able to clear the buffer mid-test, so exposing it as a step definition made more sense.

The scenario

The first step here is to check that the points level is bronze before anything happens, this is a custom step definition left as an exercise to the reader and again, its pretty self explanatory (hint: the current user is stored as $this->user in the FeatureContext). The next step Given I have just earned "898" points in "General" category is another custom step definition from our project, targeting the user points api and again, its pretty trivial so we'll skip that as the focus here is on the email testing. The next two steps are built-in Drupal/Behat extension steps, one to create and article and another to complete the comment form (which should grant the needed two points to push the user into the next points level).

The next step is testing the email output - the definition looks like so:

/**
   * @Then /^the email to "([^"]*)" should contain "([^"]*)"$/
   */
  public function theEmailToShouldContain($to, $contents) {
    // We can't use variable_get() because $conf is only fetched once per
    // scenario.
    $variables = array_map('unserialize', db_query("SELECT name, value FROM {variable} WHERE name = 'drupal_test_email_collector'")->fetchAllKeyed());
    $this->activeEmail = FALSE;
    foreach ($variables['drupal_test_email_collector'] as $message) {
      if ($message['to'] == $to) {
        $this->activeEmail = $message;
        if (strpos($message['body'], $contents) !== FALSE ||
          strpos($message['subject'], $contents) !== FALSE) {
          return TRUE;
        }
        throw new \Exception('Did not find expected content in message body or subject.');
      }
    }
    throw new \Exception(sprintf('Did not find expected message to %s', $to));
  }

Basically, we fetch the sent emails collected by the test email system from the variable table. We can't use variable_get() here because $conf is loaded when the Drupal driver is first bootstrapped. Any subsequent changes to variables done through the UI (ie with step definitions that use the built-in Mink/Behat functionality) won't be reflected in $conf, so we need to fetch the variable directly from the database. 

We then loop the messages and find the one matching the given email address. Now, you may note that this only finds the first email sent to a given address, so that might not work in your situation. However, in another scenario (not shown) we were able to use the 'And the test email system is enabled' step definition as appropriate to flush the buffer and ensure there was only ever one email for the given address.

We then store the found message as the active email, in a local property - saving us the need to loop again if we have to perform additional checks on the email content. Finally, we check for the presence of the given text in the subject or body.

If no email is found, or the body or subject don't match the expected content, we throw an Exception which causes the test to fail.

Our final custom step definition is as follows:

/**
   * @Given /^the email should contain "([^"]*)"$/
   */
  public function theEmailShouldContain($contents) {
    if (!$this->activeEmail) {
      throw new \Exception('No active email');
    }
    $message = $this->activeEmail;
    if (strpos($message['body'], $contents) !== FALSE ||
      strpos($message['subject'], $contents) !== FALSE) {
      return TRUE;
    }
    throw new \Exception('Did not find expected content in message body or subject.');
  }

This definition just re-uses the existing active email found in the previous step to search for additional content. Basically, it saves the extra looping/database query as we've already found the email we're interested in.

Finally

Now the last thing to do; be sure to reset the mail system in the afterScenario method.

So all in all, Behat is very flexible and can easily be adapted for your functional test requirements.

Would you like to see more posts on Behat? Leave us a comment with some suggested topics.