Writing Automated Tests in Drupal 8, Part 3: Unit tests

 

In the last post, we set up testing in our Drupal site and wrote a functional test for our module. Functional tests allow you to act as would a human tester; you can assume Drupal is installed and that there's a working user interface. Once you create the test class scaffold, writing tests is a matter of describing each step the tester would perform and what they expect to see on the page.

That elegance when writing functional tests is what makes them so attractive to me. Running functional tests, however, is a pain. By acting as a user, we need to install a clean copy of Drupal for each test class. This adds considerable overhead when executing the tests. 

Sometimes we don't need all of Drupal -- or any! -- to run a test. That's where *unit tests* come into play.

I like to think of unit testing as testing the tiniest things in your application, functions and classes. When writing a unit test, I like to imagine I've reached into my application with a tweezer, and plucked out some miniscule piece to scrutinize. Removed from its siblings, its application community, it stands context-free in its digital Petri dish.

And that's when we, giant-like, begin to poke it with a stick.

There's no Drupal in a unit test. No user interface. No Drupal API. Not even a connection to a database. Nothing. It's just you, and the class under test. Naturally, this seems to make unit tests a little pointless in respect to our module. If there's no Drupal, what is there left to even test? As it turns out, there's a lot but you need to think smaller.

Recently I was working on a module and realized I needed to create a new data type in code. Some programming languages allow you to do this at the language level, but not PHP. Instead, the predominate pattern is to create a static, final, class with one or more constants defined in it.

<?php


namespace Drupal\my_module;

/**
 * Provides status constants for my_module.
 */
final class MyStatus {

  const CRITICAL = 20;
  const RED = 15;
  const YELLOW = 10;
  const GREEN = 5;
  const UNKNOWN = 0;

}

Usually that's as far as many implementations go, but I also needed the ability to sort values in a particular order, and have a canonical text string identifier for things like drop downs. 

  /**
   * Get all the statuses as text constants keyed by numeric status.
   *
   * This method provides a canonical text version of the status, useful for
   * theme variables and other places so you can avoid a large switch statement.
   *
   * @return array
   *   An array of text constants keyed by status.
   */
  public static function getTextConstants() {
    return [
      static::CRITICAL  => 'my_critical_status',
      static::RED       => 'my_red_status',
      static::YELLOW    => 'my_yellow_status',
      static::GREEN     => 'my_green_status',
      static::UNKNOWN   => 'my_unknown_status',
    ];
  }

  /**
   * Get all the statuses keyed by text constant.
   *
   * @return array
   *   An array of statuses keyed by text constant.
   */
  public static function getAsArrayByConstants() {
    return [
      'my_critical_status'  => static::CRITICAL,
      'my_red_status'       => static::RED,
      'my_yellow_status'    => static::YELLOW,
      'my_green_status'     => static::GREEN,
      'my_unknown_status'   => static::UNKNOWN,
    ];
  }

Eventually, I added static methods to the class to juggle the different representations.

  /**
   * Gets the numeric status given the text constant.
   *
   * @param string $status_text
   *   A string containing a status text constant.
   *
   * @return bool|int
   *   The numeric status if found, FALSE otherwise.
   */
  public static function constantToNumeric($status_text) {
    $statuses = static::getAsArrayByConstants();

    return isset($statuses[$status_text]) ? $statuses[$status_text] : FALSE;
  }

  /**
   * Gets the status as a text constant given the numeric value.
   *
   * @param int $status
   *   The numeric status value.
   *
   * @return bool|string
   *   The status as a text constant if found, FALSE otherwise.
   */
  public static function numericToConstant($status) {
    $statuses = static::getTextConstants();

    return isset($statuses[$status]) ? $statuses[$status] : FALSE;
  }

Once I did that, it became a class I needed to test. 

Notice something? Not one bit of that class relied on Drupal. It would work just as well in Symfony, Laravel, or vanilla PHP. That independence will make it much easier for us to write our unit test.

Now that we know what to test, we can create our test class. In Drupal 8, unit tests are stored one directory over from functional tests.

modules/custom/my_module
 ├── my_module.info.yml
 ├── my_module.module
 ├── src/
 └── tests/ 
      ├── modules/
      └── src/
           ├── Functional/
           └── Unit/

Classes are also named similarly. Since we're testing our MyStatus class, let's name the test MyStatusTest. To be consistent with PSR-4, we'll name the file after the class name.

modules/custom/my_module
 ├── my_module.info.yml
 ├── my_module.module
 ├── src/
 └── tests/ 
      ├── modules/
      └── src/
           ├── Functional/
           └── Unit/
               └── MyStatusTest.php

Next, we'll scaffold out the class.

<?php

namespace Drupal\Tests\my_module\Unit;

use Drupal\my_module\MyStatus;
use Drupal\Tests\UnitTestCase;

/**
 * Unit tests for the MyStatus utility class.
 *
 * @group my_module
 */
class MyStatusTest extends UnitTestCase {
}

Like how our SettingsPageTest from earlier in this series derived from BrowserTestBase, all unit tests in Drupal 8 derive from their own class: UnitTestCase. This class provides all the necessary behind the scenes implementation and key utility methods for us to run the test.

Our unit test also has a @group annotation. We added this to our functional test too, but didn't get around to using it in practice. The @group annotation is used to help us organize our tests. While the group can be anything, it's typically the module name for Drupal tests. This way, we only need to pass our group name to run tests and not individual class names.

PHPUnit has several annotations in addition to @group.

Like our functional test, our unit test may choose to implement a ::setup() method to do any additional configuration it requires prior to executing the tests. This particular unit test doesn't really need one, but let's add it for reference:

  /**
   * {@inheritdoc}
   */
  public function setUp() {
    // Nothing to do here.
    parent::setUp();
  }

With the class set up, we can now start writing some tests! Each unit test class can be thought of as a suite of tests, and each method with a name that starts in "test" is considered a test case. Let's add two test cases, one for each type juggling method of our class under test.

  /**
   * Tests the constant to numeric method.
   */
  public function testConstantToNumeric() {
  }

  /**
   * Tests the numeric to constant method.
   */
  public function testNumericToConstant() {
  }

So far so good. Now we just need to write the body of each of our test cases. In our first case, we want to test MyStatus::constantToNumeric(). It expects to be given a numeric value that corresponds to one of the class constants provided by MyStatus. Since there are five class constants, try to convert each to a text constant, comparing them to the expected value.

Just like BrowserTestBase, UnitTestCase provides several ::assert*() methods for us to instruct the test framework if something has passed or not. ::assertEquals() is perfect for us since we simply need to compare two values:

  public function testConstantToNumeric() {
    $this->assertEquals(MyStatus::UNKNOWN, MyStatus::constantToNumeric('my_unknown_status'));

    $this->assertEquals(MyStatus::GREEN, MyStatus::constantToNumeric('my_green_status'));

    $this->assertEquals(MyStatus::YELLOW, MyStatus::constantToNumeric('my_yellow_status'));

    $this->assertEquals(MyStatus::RED, MyStatus::constantToNumeric('my_red_status'));

    $this->assertEquals(MyStatus::CRITICAL, MyStatus::constantToNumeric('my_critical_status'));
  }

That's everything right? Well, no, not really. This covers all the cases where we give MyStatus::constantToNumeric() an expected value, but what happens when we give it an unexpected one? This is called negative testing. We only really need to pass one unexpected value, so let's do that:

  public function testConstantToNumeric() {
    $this->assertEquals(MyStatus::UNKNOWN, MyStatus::constantToNumeric('my_unknown_status'));

    $this->assertEquals(MyStatus::GREEN, MyStatus::constantToNumeric('my_green_status'));

    $this->assertEquals(MyStatus::YELLOW, MyStatus::constantToNumeric('my_yellow_status'));

    $this->assertEquals(MyStatus::RED, MyStatus::constantToNumeric('my_red_status'));

    $this->assertEquals(MyStatus::CRITICAL, MyStatus::constantToNumeric('my_critical_status'));

    $this->assertFalse(MyStatus::constantToNumeric('blargle_blargle_blah'));
  }

The MyStatus::constantToNumeric() method returns FALSE when we pass it unaccepted input. Instead of ::assertEquals(), we use ::assertFalse().

Next, we need to write our other test case. It works much the same way but in reverse:

  public function testNumericToConstant() {
    $this->assertEquals(MyStatus::numericToConstant(MyStatus::UNKNOWN), 'my_unknown_status');

    $this->assertEquals(MyStatus::numericToConstant(MyStatus::GREEN), 'my_green_status');

    $this->assertEquals(MyStatus::numericToConstant(MyStatus::YELLOW), 'my_yellow_status');

    $this->assertEquals(MyStatus::numericToConstant(MyStatus::RED), 'my_red_status');

    $this->assertEquals(MyStatus::numericToConstant(MyStatus::CRITICAL), 'my_critical_status');

    $this->assertFalse(MyStatus::numericToConstant(-9999));
  }

That completes our unit test! Now we can run it just like we ran our functional test earlier.

$ ../vendor/bin/phpunit ../modules/my_module/tests/src/Unit/MyStatusTest.php 
PHPUnit 4.8.36 by Sebastian Bergmann and contributors.

Testing Drupal\Tests\my_module\Unit\MyStatusTest
..

Time: 226 ms, Memory: 8.00MB

OK (2 tests, 12 assertions)

Wonderful! Notice something? That unit test was really, really fast! It completed in less than a second! Speed is the biggest advantage of writing unit tests. Since we are testing very small pieces of functionality, we don't need to install or bootstrap Drupal at all. Even though they test very small parts of our module, their speed allows us to write a lot of them while not significantly adding to our test time.

Now we have two tests in our module, isn't there a way to run them all? Sure there is, thanks to our @group annotation:

$ ../vendor/bin/phpunit --group my_module
PHPUnit 4.8.36 by Sebastian Bergmann and contributors.

Testing
...

Time: 6.15 seconds, Memory: 14.00MB

OK (3 tests, 22 assertions)

PHPUnit is capable of generating code coverage reports that can guide you in ensuring that your module is covered as much as possible by tests. This is really useful for us as developers to ensure there are no "dark corners" in our modules. 

We can generate a coverage report by calling the phpunit executable as before, but specifying the one of the --coverage-* switches:

../vendor/bin/phpunit --coverage-html /some/output/directory/ --group my_module

The above generates a code coverage report in HTML format in the /some/output/directory/. Code coverage reports in HTML format are mini-static websites. You can view them with any browser. 

The problem with code coverage reports is that they can take a very, very long time to generate. The processing they perform is quite intense. For this reason, it's useful to remind ourselves in our unit tests just what of our class under test we are testing. This is what the @covers annotation is for.

  /**
   * Tests the constant to numeric method.
   *
   * @covers MyStatus::constantToNumeric
   */
  public function testConstantToNumeric() {
    // omitted
  }

Here we see one of the test case methods we wrote earlier. This time we added a docblock that not only describes the method, but includes the @covers annotation. The annotation doesn't affect the code coverage report (at least it didn't in my experience), but it is really useful for us as developers. Now we know exactly what the test case is for at a glance.

Sometimes, however, our class names or namespaces can get rather lengthy. Since our unit test class typically only tests one class itself, all of our @covers annotations would end up with the same class name. To mitigate this, we can add an annotation to the class definition itself, @coversDefaultClass:

<?php

namespace Drupal\Tests\my_module\Unit;

use Drupal\my_module\MyStatus;
use Drupal\Tests\UnitTestCase;

/**
 * Unit tests for the MyStatus utility class.
 *
 * @group my_module
 *
 * @coversDefaultClass \Drupal\my_module\MyStatus
 */
class MyStatusTest extends UnitTestCase {

  /**
   * {@inheritdoc}
   */
  public function setUp() {
    // Nothing to do here.
    parent::setUp();
  }

  /**
   * Tests the constant to numeric method.
   *
   * @covers ::constantToNumeric
   */
  public function testConstantToNumeric() {
    $this->assertEquals(MyStatus::UNKNOWN, MyStatus::constantToNumeric('my_unknown_status'));

    $this->assertEquals(MyStatus::GREEN, MyStatus::constantToNumeric('my_green_status'));

    $this->assertEquals(MyStatus::YELLOW, MyStatus::constantToNumeric('my_yellow_status'));

    $this->assertEquals(MyStatus::RED, MyStatus::constantToNumeric('my_red_status'));

    $this->assertEquals(MyStatus::CRITICAL, MyStatus::constantToNumeric('my_critical_status'));

    $this->assertFalse(MyStatus::constantToNumeric('blargle_blargle_blah'));
  }

  /**
   * Tests the numeric to constant method.
   *
   * @covers ::numericToConstant
   */
  public function testNumericToConstant() {
    $this->assertEquals(MyStatus::numericToConstant(MyStatus::UNKNOWN), 'my_unknown_status');

    $this->assertEquals(MyStatus::numericToConstant(MyStatus::GREEN), 'my_green_status');

    $this->assertEquals(MyStatus::numericToConstant(MyStatus::YELLOW), 'my_yellow_status');

    $this->assertEquals(MyStatus::numericToConstant(MyStatus::RED), 'my_red_status');

    $this->assertEquals(MyStatus::numericToConstant(MyStatus::CRITICAL), 'my_critical_status');

    $this->assertFalse(MyStatus::numericToConstant(-9999));
  }
}

With the @coversDefaultClass annotation on the unit test class, we can omit the class name on every @covers annotation. 

The phpunit.xml configuration file by default lives in your site's core/ directory. While this works, one of my readers pointed out an important note. Today, it is best practice to build your Drupal site using Composer. If built with a pure composer build process, the core/ directory will get wiped out on every update of core -- or potentially every composer operation if your version of core has patches applied.

Instead of keeping the phpunit.xml file in the core/ directory, we can choose to move it elsewhere in our project where it will not be affected by Composer. Once moved, we can refer to it using the -c option:

../vendor/bin/phpunit -c /path/to/my/phpunit.xml --group my_module

In order for this to work, we also need to update our phpunit.xml. Normally, all the file paths in the file are relative to the core/ directory and begin with a period-slash (./). We need to change those to the full path of the Drupal core/ directory in all instances. So,

<file>./tests/TestSuites/UnitTestSuite.php</file>

becomes...

<file>/path/to/drupal/core/tests/TestSuites/UnitTestSuite.php</file>

In a few places the file path will be to the site's modules/ or sites/ directories and start with a double-period-slash. Replace those accordingly:

<directory>../modules</directory>
<directory>../sites</directory>

becomes...

<directory>/path/to/drupal/modules</directory>
<directory>/path/to/drupal/sites</directory>

Once that's done, you can use the -c option as expected. 

In this part, we went from testing the largest things in our module with functional tests, to the smallest things with unit tests. Unit tests are best for testing individual classes or methods that do not depend at all on Drupal. This may sound like a huge limitation, but it comes at the advantage of being amazingly fast to run. Other than that limitation, unit tests are surprisingly similar in structure to functional tests. We used the @covers and @coversDefaultClass annotations to remind ourselves which of our test's cases covers our class under test.

It's important to point out that while I make unit tests sound limited, this is only within the realm of Drupal. Unit tests can do some pretty amazing things including mocking and even simulating databases. A great book that helped me was the Grumpy Programmers PHPUnit Cookbook. This book is not only no-nonsense, but fun and informative. 

Functional tests are still my favorite, but they are slow and they can often be overkill. Unit tests are great for small, Drupal-free portions of our modules... Is there any middle ground in Drupal testing? Next time we'll see that, yes, there is, when we cover Kernel Tests.

This post was created with the support of my wonderful supporters on Patreon.

If you like this post, consider becoming a supporter at:

Thank you!!!