Test Driven Drupal

While working on the Content Staging Initiative, we were tasked with having very high test coverage on modules that we created. Neil Hastings (our technical lead) asked that we use Test Driven Development (TDD). I had tried to do TDD in Drupal before, using Drupal's web tests, and found it beyond hard. But I was excited for a new try. As we got started, it was much easier then I expected.

Erik Summerfield, Director of Engineering
#Drupal | Posted

While working on the Content Staging Initiative, we were tasked with having very high test coverage on modules that we created. Neil Hastings (our technical lead) asked that we use Test Driven Development (TDD). I had tried to do TDD in Drupal before, using Drupal's web tests, and found it beyond hard. But I was excited for a new try. As we got started, it was much easier then I expected. For a primer on how we setup the tests, see my previous post 3 Easy Steps for Drupal Unit Tests.

There has been lots of talk on the internets of late, that TDD does not mean unit tests, it means having test coverage. For the most part, that is fine with me, but I still see unit test as one of (if not the largest) part of doing TDD. In general, I am talking about the coding pattern of making a test, passing the test, and then refactoring.

  1.                                  .------------.
  2.                           .-------| edit Test  |
  3.                           |       '------------'
  4.                           v              ^
  5.  .------------.     .----------.         |
  6.  | write Test |---->| run Test |--Pass---'
  7. '------------'     '----------'                    .------------.
  8.                          |                  .------| Edit Code  |
  9.                         Fails               |      '------------'
  10.                          v                  v             ^
  11.                   .------------.      .----------.        |
  12.                   | write code |----->| run Test |--Fails-'
  13.                    '------------'      '----------'
  14.                                              |
  15.                                             Pass
  16.                                              v
  17.                                      .---------------.
  18.                                      | refactor code |
  19.                                      '---------------'

It is worth noting that we did not adhere to this process religiously on the CSI project. There were times when we wrote code, then tests, and there were even times when we went back over code and added tests way after the fact (there might even be a few methods without tests don't tell Neil). But even if we did not keep the faith 100% of the time, there were many benefits that we discovered. A few of the big ones that we hit on this project were:

  • Refractoring is so niceHaving tests made refactoring feel safe and secure, and really opened up what we could change
  • Collaboration was smooth as Miles DavisWe had different parts of the system in which we had to work together on, sometimes up to four devs at the same time. The tests not only ensured we did not brake each other's code, but it also let us communicate what we needed and expected from each other's code.
  • Detection of design issues earlyPushing tests upfront made issues with overall architecture visible before much time was spent coding them

There are lots more benefits for TDD, reviewing the TDD wikipedia entry is a good place to start reading more about TDD, but I want to move on to how we were able to overcome some of the challenges that Drupal throws at TDD.

Problem: The Database

One of the key elements of doing unit tests is not testing integration but just the functionality at hand. Accessing a database can make this very difficult. Drupal is very tied to the database, and I think this is what kills most TDD efforts in Drupal. Drupal offers a Drupal Web Test as well as a Drupal Unit Test. The Web test does a full DB creation and site install for each test. This makes rapid turn around TTD impossible, as each test can take more then 8 seconds (on some machines that can go up to as high as a minute). On the other hand, all the SPS module tests, 150 or so unit tests, take 8 secs to run. So as we started off, we looked hard at ways to use unit tests instead of web tests.

Problem: Drupal itself

OK, so we have solved the issue of not creating a DB for each test, but whenever I use a Drupal function (like drupal_get_form for example), if there is no DB, bad things happen. One big question when writing tests is what am I testing? If you are testing Drupal, and not part of your module, then you might be doing things wrong. Besides Drupal's core functions, the hook system in general can get us into lots of trouble along these lines. There are times when you need to interact with the rest of the system, but we want to test these with integration tests, not unit tests, we only want to test our code with unit tests.

Lucky for us we had a very object oriented architecture so it was easy for use to encapsulate our code. This made testing our object, in general, very easy (i.e. did not have to interact with Drupal :)). However, there were a few place that we were still tied in to the Drupal system. When we were creating forms, or using the hook system for registering plugins. We tried a few ways to encapsulate this work and settled on a general tool to use between our code and Drupal, a Drupal Object.

 

  1. $drupal = new /Drupal/sps/Drupal();
  2. $form = $drupal->
  3. drupal_get_form('baseball_form');
  4. $drupal->set('drupal_get_form', function($name) { return array(); });
  5. $form2 = $drupal->
  6. drupal_get_form('baseball_form');

If there is not a set value for a the property of the drupal object, then it will be called  simply: the Drupal method. If (as we see with form2) there was a set value, that function would be used instead. So in this case, $form2  would be an empty array. This allows us to not test Drupal, but instead just our code. There was a lot of discussion on whether this was the best method in our group, and I think there are some arguments against it, but it was a good general solution for our module that lets us separate out all integrations.

If your module is not object oriented, there are still lots of options. You can look to make your code more functional. Look for ways to remove integration elements from your own functionality.

function example_get_example_plugins() {
   $plugins = module_invoke_all('example_plugins');
   //do validation
   ...
   //add defaults
   ...
   return $plugins;
}[/php]

This function above can be hard to test (you would see this early if you had to think of a test for this) as you will want to send lots of different things through the validation. The problem is of course that the return of the method is not based on what goes in, but instead on what some other parts of the system return.

But if instead, we started by writing tests for the validation and then test for the added defaults,

class ExamplePluginUnitTest extends ExampleBaseUnitTest {
   ...
   public function testValidate() {
     $plugins = ...
     $this->assertEqual(
       example_get_example_validate($plugins),
       EXPECTEDARRAY,
       'Message for Valid test'
     );
     ...
     }
   ...
 }[/php]

then we have narrowed the focus of our functions to not only be testable, but we have stoped the need to test Drupal.

  1. function example_get_example_plugins() {
  2.    $plugins = module_invoke_all('example_plugins');
  3.    $plugins = example_get_example_validate($plugins);
  4.    $plugins = example_get_example_add_defaults($plugins);
  5.    return $plugins;
  6. }  
  7. function example_get_example_validate($plugins) {
  8.    //do validation
  9.    ...
  10.    return $plugins;
  11. }
  12. function example_get_example_add_defaults($plugins) {
  13.    //add defaults
  14.    ...
  15.    return $plugins;
  16. }

 

It is also worth noting that you have also just made your module more useful as you have added two very valuable functions. By adding the test as a user of your code, it has become clear how you might want to use your API.

Finally by making your code testable, you also make it more stable. Functional code is not only easier to test, it is also easier to understand, and maintain.

Problem: Over active API

Because of the flexibility of Drupal, I (and I think many others,) often want to over code. We want to add every hook anyone could ever use all over our code. This leads to a lot of testing of stuff with no good examples of how the code would be used. This makes for lot of test that do not have value, so just don't do it.  Add the hook for use cases in which you have use, and write a test for that use case. You will not only be documenting what your module does, but how you expect it to be used.

Some might argue that by dictating how it should be used, you are limiting its value. You are not dictating how it can be used- you are showing what expectations you have (and others should have). Because these are now testable, you have opened the door for others to add new expectations, all they need to do is add tests. This cycle of adding new expectations, is a great way to grow functionality organically instead of over building to start with.

One last thing on over-coding, it might not even be others that add expectations. On SPS we had a relatively complicated system that had many distinct parts that interacted with each other. By only coding what was needed, we were able to grow each part, with little changing of overall structure. While we did go back and do major refactors during the process, because we had set and testable expectations, these were by far the easiest refactors I have done in Drupal. By telling the world (through tests) what your code does you make it far easier for other to make it do more.

By the end of the project I was transformed into a "Unit tests are great, just not for Drupal" guy into a "Unit tests are great" guy.  I encourage others to just start putting tests in to modules as you add new functionality or fix bugs. For those bug finders out there, submit a test! Oh how happy I would be to get a test as an issue.

Erik Summerfield

Erik Summerfield

Director of Engineering