Unit Testing Custom Modules with PHP Unit

It's not as hard as you think!

The Guy up front:

Dustin LeBlanc

Senior Developer / CMS Team Leader

Singlebrook Technology

dustinleblanc on Github

dustin-leblanc on Drupal.org

Why Unit Test?

Guards against future code causing regressions

Provides self-documentation

Provides quality assurance for future collaborators

Reduces bugs

Provides stability when expanding and creating new features

Besides...

But...

Is my project a good candidate?

How much code?


/**
 * Implements hook_block_info().
 */
function hello_world_block_info() {
  $blocks = array();

  $blocks['hello_world'] = array(
    'info' => t('Hello world'),
  );

  return $blocks;
}

/**
 * Implements hook_block_view().
 */
function hello_world_block_view($delta = '') {
  $block = array();

  if ($delta == 'hello_world') {
    $block['subject'] = t('Hello world');
    $block['content'] = t('This is the block content.');
  }

  return $block;
}
						

Not so much but...

							
/**
 * Provides abililty to wrap the content of HTML messages in multiple langauges.
 */
class MailWrap {
  /**
   * Wraps HTML content to a specific line width for sending html emails via smtp.
   * @param string  $content HTML content of email body.
   * @param integer $width   Line length to enforce wrapping
   * @param object  $lang    Language of email.
   */
  public function wrapHtmlContent($content, $width = 75, $lang) {
   $cut = false;
   if (isset($lang) && ($lang == 'ja' || $lang == 'zh-hans')) {
     $cut = true;
   }
   $html_obj = new simple_html_dom();
   $html_obj->load($content);
   foreach ($html_obj->find('text') as $element ) {
     $element->innertext = $this->mbWordwrap($element->innertext, $width, "\n", $cut);
    }
    return $html_obj->save();
  }

  /**
  * Wraps any string to a given number of characters.
  *
  * This implementation is multi-byte aware and relies on {@link
  * http://www.php.net/manual/en/book.mbstring.php PHP's multibyte
  * string extension}.
  *
  * @see wordwrap()
  * @link https://api.drupal.org/api/drupal/core%21vendor%21zendframework%21zend-stdlib%21Zend%21Stdlib%21StringWrapper%21AbstractStringWrapper.php/function/AbstractStringWrapper%3A%3AwordWrap/8
  * @param string $string
  *   The input string.
  * @param int $width [optional]
  *   The number of characters at which $string will be
  *   wrapped. Defaults to 75.
  * @param string $break [optional]
  *   The line is broken using the optional break parameter. Defaults
  *   to "\n".
  * @param boolean $cut [optional]
  *   If the $cut is set to TRUE, the string is
  *   always wrapped at or before the specified $width. So if
  *   you have a word that is larger than the given $width, it
  *   is broken apart. Defaults to FALSE.
  * @return string
  *   Returns the given $string wrapped at the specified
  *   $width.
  */
  private function mbWordwrap($string, $width = 75, $break = "\n", $cut = false) {
    $string = (string) $string;
    if ($string === '') {
      return '';
    }

    $break = (string) $break;
    if ($break === '') {
      trigger_error('Break string cannot be empty', E_USER_ERROR);
    }

    $width = (int) $width;
    if ($width === 0 && $cut) {
      trigger_error('Cannot force cut when width is zero', E_USER_ERROR);
    }

    if (strlen($string) === mb_strlen($string)) {
      return wordwrap($string, $width, $break, $cut);
    }

    $stringWidth = mb_strlen($string);
    $breakWidth = mb_strlen($break);

    $result = '';
    $lastStart = $lastSpace = 0;

    for ($current = 0; $current < $stringWidth; $current++) {
      $char = mb_substr($string, $current, 1);

      $possibleBreak = $char;
      if ($breakWidth !== 1) {
        $possibleBreak = mb_substr($string, $current, $breakWidth);
      }

      if ($possibleBreak === $break) {
        $result .= mb_substr($string, $lastStart, $current - $lastStart + $breakWidth);
        $current += $breakWidth - 1;
        $lastStart = $lastSpace = $current + 1;
        continue;
      }

      if ($char === ' ') {
        if ($current - $lastStart >= $width) {
          $result .= mb_substr($string, $lastStart, $current - $lastStart) . $break;
          $lastStart = $current + 1;
        }

        $lastSpace = $current;
        continue;
      }

      if ($current - $lastStart >= $width && $cut && $lastStart >= $lastSpace) {
        $result .= mb_substr($string, $lastStart, $current - $lastStart) . $break;
        $lastStart = $lastSpace = $current;
        continue;
      }

      if ($current - $lastStart >= $width && $lastStart < $lastSpace) {
        $result .= mb_substr($string, $lastStart, $lastSpace - $lastStart) . $break;
        $lastStart = $lastSpace = $lastSpace + 1;
        continue;
      }
    }

    if ($lastStart !== $current) {
      $result .= mb_substr($string, $lastStart, $current - $lastStart);
    }

    return $result;
	}
}

							
						

aww yiss...

Hooks and core functions?

probably not...

Data manipulation, business critical logic, lots of custom stuff?

aww yiss...

But how?

What you'll need:

Composer

With Homebrew:

							
$ brew tap homebrew/homebrew-php
$ brew install composer
							
						

PHP 5 (I use 5.5 via homebrew)

Let's get started

Install our dependencies:

							
	$ composer init
	# fill out the stuff for your project
							
						

Make sure to set phpunit as a dev-dependency

								
Would you like to define your dev dependencies interactively?
Search for a package []: phpunit/phpunit
Enter the version constraint to require (or leave blank to use the latest version) []:
Using version ~4.6 for phpunit/phpunit
								
							

Your resulting composer.json should be something like this:

								
{
  "name": "dustin/module",
  "description": "A cool module",
  "require-dev": {
    "phpunit/phpunit": "~4.6"
  },
  "authors": [
    {
      "name": "Dustin LeBlanc",
      "email": "dustin@singlebrook.com"
    }
  ]
}
								
							

Configure PHP Unit

							


  
    
      path/to/sites/all/modules/custom/
    
  
  
    
      ./vendor
    
  

							
						

Your config might be different.

Writing Testable Code

Write single use functions

							
/**
 * Implements hook_node_view()
 */
function example_node_view($node, $view_mode, $langcode) {
  // Should I pirate?
  if (!$node->nid % 2 == 0) {
    return;
  }
  // Get page text
  $wrapper = entity_metadata_wrapper('node', $node);
  $text = $wrapper->body->value();
  // Call to the pirate API
  $r = new HttpRequest('http://isithackday.com/arrpi.php', HttpRequest::METH_GET);
  $r->addQueryData(array('text' => $text));
  try {
    $r->send();
    if ($r->getResponseCode() == 200) {
      $pirate_text = $r->getResponseBody());
    }
  } catch (HttpException $ex) {
    drupal_set_message($ex);
  }
  // Replace node text
  $wrapper->body->set($pirate_text);
  // Return the node
  $node = $wrapper->value();
}
							
						

Does all the things!

Hooks are just the entry point

							
/**
 * Implements hook_view().
 */
function example_node_view($node, $view_mode, $langcode) {
  $pirator = new Pirator();
  if ($pirator->shouldYe($node)) {
    $node = $pirator->translate($node);
  }
}
							
						

Only concerned with evaluating condition and executing

Our rewritten module code...

							
class Pirator {
  /**
   * Determines whether a node should be pirated.
   * How bout every other node?
   * @return BOOL
   */
  public function shouldYe(stdClass $node) {
    if ($node->nid % 2 == 0) {
      return TRUE;
    }
    return FALSE;
  }

  public function translate(stdClass $node) {
    $wrapper = entity_metadata_wrapper('node', $node);
    $text = $this->getBody($wrapper);
    $translation = $this->getTranslation($text);
    $this->setContent($node, $translation);
    dpm($wrapper->value());
    return $wrapper->value();
  }

  /**
   * Call to the arrpi and get a translation
   *
   * @param string the text to translate
   */
  protected function getTranslation($text) {
    $data = array(
      'text' => $text,
    );
    $url = url('http://isithackday.com/arrpi.php', array('query' => $data));
    $r = drupal_http_request($url);

    if ($r->code == 200) {
      return $r->data;
    }
    else {
      drupal_set_message(t($r->error), 'error');
    }
  }

  /**
   * Get page text
   *
   * @param EntityValueWrapper $wrapper wrapped node
   *
   * @return string the node body text
   */
  protected function getBody(EntityDrupalWrapper $wrapper) {
    return $wrapper->body->value->value();
  }

  /**
   * Set translated
   *
   * @param EntityValueWrapper $wrapper wrapped node
   * @param string $translation [description]
   */
  protected function setContent(stdClass $node, $translation) {
    return $node->content['body'][0]['#markup'] = $translation;
  }
}
							
						

Each method does one job

Object-oriented setup is easier to load with php unit

Better, not perfect

Loading and calling Drupal functions is still painful

Not all of your methods will be easy to test

Writing Tests

Config changes

In composer.json we have to make a slight tweak:

							
{
  "name": "dustin/module",
  "description": "Example Module for Cornell Drupal Camp 2015 showcasing PHPUnit in Drupal module development",
  "require-dev": {
    "phpunit/phpunit": "~4.6",
  },
  "autoload": {
    "classmap": ["./path/to/custom/module/includes", "./includes/bootstrap.inc"]
  },
  "authors": [
    {
      "name": "Dustin LeBlanc",
      "email": "dustin@singlebrook.com"
    }
  ]
}
							
						

Note the addition of 'autoload'

Sample Test

							
/**
 * Provides testing for Pirator class.
 */
class PiratorTest extends PHPUnit_Framework_TestCase {
  protected $pirator;
  protected $node;

  /**
   * Pre-test setup
   */
  protected function setUp() {
    $this->pirator = new Pirator();
  }

  public function testShouldYe() {
    $node = new stdClass();
    $node->nid = 2;
    $this->assertTrue($this->pirator->shouldYe($node));
  }
}
						
						

Bootstrap Drupal

at the top of your test file...

															
/**
 * Setup environoment to have access to Drupal functions and installed modules.
 */
define('DRUPAL_ROOT', getcwd());
require_once DRUPAL_ROOT . '/includes/bootstrap.inc';
$_SERVER['REMOTE_ADDR'] = '127.0.0.1';
// Bootstrap Drupal.
drupal_bootstrap(DRUPAL_BOOTSTRAP_FULL);
							
						

Running tests

							$ ./vendor/bin/phpunit
						

boom

								
PHPUnit 4.6.4 by Sebastian Bergmann and contributors.

Configuration read from ...your config file...

.

Time: 251 ms, Memory: 16.00Mb

OK (1 test, 1 assertion)
								
							

Resources

Drupal 8 Now: PHPUnit tests in Drupal 7

PHP Unit Docs