Every time we write a single line of code, it is possible that some use cases remain uncovered. Or we leave bugs in the component. Or we just end up introducing new bugs while trying to fix existing ones, or when we add new features. Anyway, the good news is that we are able to prevent these things with some well-written tests. In this article, I want to share how we can provide PHPUnit test coverage for a really simple component.

The example component

Assume that we need a function that can determine if a URL is external to our Drupal site or not (even for internal absolute URLs). We may use the UrlHelper::isExternal() and UrlHelper::externalIsLocal() methods to determine this.

Since we want to keep our code well-structured (and because global functions aren’t easily testable), we will provide this function as a method of a custom helper class (similar to the UrlHelper utility used here). Let’s create a module (external_link) with a ExternalLinkUrlHelper class and add a static method isExternal that performs the check above.

We may end up in a solution like this:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
<?php

namespace Drupal\external_link\Utility;

use Drupal\Component\Utility\UrlHelper;

/**
 * Helper class for external URLs.
 *
 * @ingroup utility
 */
class ExternalLinkUrlHelper {

  /**
   * Determines whether an Url is external to Drupal.
   *
   * @param \Drupal\Core\Url $url
   *   The Url to check.
   *
   * @return bool
   *   TRUE or FALSE, where TRUE indicates an external path.
   */
  public static function isExternalUrl($url) {
    if (!$url->isExternal()) {
      return FALSE;
    }

    $uri_string = $url->toUriString();
  
    if (UrlHelper::isExternal($uri_string)) {
      static $base_url;
      if (!isset($base_url)) {
        $base_url = \Drupal::service('router.request_context')->getCompleteBaseUrl();
      }
    
      return !UrlHelper::externalIsLocal($uri_string, $base_url);
    }
    
    return FALSE;
  }

}

We will use this component as an example unit that we want to cover with a test.

Create the test

To prove that the code above works as expected we can use a PHPUnit test: we won’t need a fully bootstrapped Drupal while running the test in this case.

Based on the documentation:

  • We should place our unit test class file to the [modulename]/tests/src/Unit directory.
  • It has to extend the Drupal\Tests\UnitTestCase class.
  • File name has to end with *Test.php.
  • The test functions have to be public and start with test.

Okay, it’s time to write the test!

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
<?php

namespace Drupal\Tests\external_link\Unit;

use Drupal\Core\Url;
use Drupal\external_link\Utility\ExternalLinkUrlHelper;
use Drupal\Tests\UnitTestCase;

/**
 * @coversDefaultClass \Drupal\external_link\Utility\ExternalLinkUrlHelper
 * @group link
 */
class ExternalLinkUrlHelperTest extends UnitTestCase {

  /**
   * Tests that the helper callback determines external urls properly.
   *
   * @covers ::isExternal
   *
   * @dataProvider providerTestUrls
   */
  public function testUrlUserInputs($url_input, $external) {
    $url = Url::fromUri($url_input);
    $this->assertSame($external, ExternalLinkUrlHelper::isExternalUrl($url));
  }

  /**
   * Data provider for testing external and internal URIs
   */
  public function providerTestUrls() {
    return [
      ['route:<nolink>', FALSE],
      ['route:<front>', FALSE],
      ['//example.com', FALSE],
      ['//example.com/', FALSE],
      ['//example.com/test', FALSE],
      ['//example.com/test/subpath/', FALSE],
      ['//example.company', TRUE],
      ['//example.company/', TRUE],
      ['//example.company/test', TRUE],
      ['//example.company/test/subpath/', TRUE],
      ['http://example.com', FALSE],
      ['http://example.com/', FALSE],
      ['http://example.com/test', FALSE],
      ['http://example.com/test/subpath/', FALSE],
      ['https://example.com', FALSE],
      ['https://example.com/', FALSE],
      ['https://example.com/test', FALSE],
      ['https://example.com/test/subpath/', FALSE],
      ['https://example.company', TRUE],
      ['https://example.company/', TRUE],
      ['https://example.company/test', TRUE],
      ['https://example.company/test/subpath/', TRUE],
      ['http://drupal.org/example.com', TRUE],
      ['http://drupal.org/example.com/', TRUE],
      ['http://drupal.org/example.company', TRUE],
      ['http://drupal.org/example.company/', TRUE],
      ['http://drupal.org//example.com', TRUE],
      ['http://drupal.org//example.com/', TRUE],
      ['http://drupal.org//example.company', TRUE],
      ['http://drupal.org//example.company/', TRUE],
    ];
  }

}

Assumption on the current host

We assumed that we’re running these test cases on a Drupal site that is reachable on the example.com url alias. But how are we able to make these kinds of assumptions in Drupal 8?

Well, if we try to run the test, we might end in an error for every test case except the first one:

1
\Drupal::$container is not initialized yet. \Drupal::setContainer() must be called with a real container.`

This happens because our helper function uses one of the methods of the router.request_context service of the Drupal container (and its getCompleteBaseUrl method should return the expected base url) – and we didn’t even create any service container in our test.

Well, let’s create a Drupal container with Drupal\Core\DependencyInjection\ContainerBuilder!

1
2
3
4
5
6
/**
 * {@inheritdoc}
 */
protected function setUp() {
  \Drupal::setContainer(new ContainerBuilder());
}

We get another exception now, but don’t worry! We’re closer to the solution:

1
2
3
Symfony\Component\DependencyInjection\Exception\ServiceNotFoundException:
   You have requested a non-existent service
   "router.request_context"

This happens because we instantiated basically an empty container in the test setup. We have to create (or mock) every service that is used during our test:

1
2
3
4
5
6
7
8
9
10
11
12
13
/**
 * {@inheritdoc}
 */
protected function setUp() {
  // The request context that is used during the unit test.
  $request_context = new RequestContext();
  $request_context->setCompleteBaseUrl('https://example.com');

  // Create and set the needed container.
  $container = new ContainerBuilder();
  $container->set('router.request_context', $request_context);
  \Drupal::setContainer($container);
}

Now our test is ready, and we can see that it provides the expected output – so we may be sure that our helper does what we expect (well, at least for the provided cases).

If we have to improve this helper method later, we might be confident that we won’t break the original function – the only possibility is that we might need to improve the test itself with other cases or with other mocks.

Mocking services

In this example we didn’t have to mock services, but I want to share how it is possible.

If we use the current request’s host somewhere (we will rely on that in my next blog post), we are able to use a dummy request as well with a mocked request stack service.

In that case, we have to ask the mocked service that if it’s asked for the current request (e.g. by the getCurrentRequest method), it has to return the dummy request. This is how it can be achieved:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
/**
 * {@inheritdoc}
 */
protected function setUp() {
  // The dummy request that will be returned by the mocked request stack
  // service.
  $request = Request::createFromGlobals();
  $request->headers->set('HOST', 'example.com');

  // The mock for the request stack.
  $request_stack = $this
    ->createMock('Symfony\Component\HttpFoundation\RequestStack');
  // Mocking our expectation: current request will return the dummy one.
  $request_stack->expects($this->any())
    ->method('getCurrentRequest')
    ->willReturn($request);

  // Create and set the needed container.
  $container = new ContainerBuilder();
  $container->set('request_stack', $request_stack);
  \Drupal::setContainer($container);
}

Sources: