Skip to main content

Do you need a Functional test for that?

Published on

Drupal has four PHPUnit test suites: Unit, Kernel, Functional, and FunctionalJavascript. Each test suite offers various levels of integration with the Drupal code base.

  • Unit tests are stateless
  • Kernel tests allow for minimal Drupal bootstrapping for stateful testing with the database
  • Functional tests perform a full Drupal installation and allow interacting with Drupal through a mocked browser
  • FunctionalJavascript tests perform a full Drupal installation and allow interacting with Drupal through WebDriver.

There are tradeoffs between each test suite. Unit tests require mocking of a lot of classes in exchange for speed and not needing a database connection. Kernel tests allow you to construct a minimal Drupal environment that allows interacting with the database. Functional tests allow for interacting with the actual site and testing as a user. FunctionalJavascript allows you to test JavaScript interactions and user interfaces.

Currently, all of the JSON:API integration tests use the Functional test suite. That means each test must install Drupal and then perform an HTTP request against the installed site, and then perform its assertions. This is a bit of a bottleneck, as the setup of a Functional test can easily take 30 seconds.

But, what if we could convert Functional tests to Kernel tests? Do we need a fully installed Drupal instance and an HTTP request to test how Drupal would return a response?

Processing a request programmatically in your tests

Many tests default to the Functional test suite because there are assertions to be made against the rendered output from a request. But, what if we could accomplish this without fully installing Drupal and executing a request from an emulated browser?

We can! The "secret" is actually in the index.php file for Drupal.

$kernel = new DrupalKernel('prod', $autoloader);

$request = Request::createFromGlobals();
$response = $kernel->handle($request);
$response->send();

$kernel->terminate($request, $response);

You create a request object and pass it to the application kernel to get a response object. The response object's contents will have all of the headers and body content – rendered HTML, or JSON, or whatever.

We can do the same thing in a Kernel test. The service container in Drupal has the application kernel set as a synthetic service. The DrupalKernel class sets itself as the kernel service, making it synthetic since it was manually assigned and not constructed within the service container.

    $container->set('kernel', $this);

    // Set the class loader which was registered as a synthetic service.
    $container->set('class_loader', $this->classLoader);
    return $container;

See the DrupalKernel::attachSynthetic method for more details.

So, in a Kernel test we would access the kernel and pass it a request object to receive a response we can perform assertions on!

  /**
   * Process a request.
   *
   * @param \Symfony\Component\HttpFoundation\Request $request
   *   The request.
   *
   * @return \Symfony\Component\HttpFoundation\Response
   *   The response.
   */
  protected function processRequest(Request $request): Response {
    return $this->container->get('kernel')->handle($request);
  }

I like having helper methods like this in my test to standardize and reduce copy and paste.

In the same fashion, I also have a method for helping setup my requests that I test. This lets me more easily set default headers. I have been using this pattern for testing JSON:API requests, so I have the method setting up the appropirate headers.

  /**
   * Creates a request object.
   *
   * @param string $uri
   *   The uri.
   * @param string $method
   *   The method.
   * @param array $document
   *   The document.
   *
   * @return \Symfony\Component\HttpFoundation\Request
   *   The request.
   *
   * @throws \Exception
   */
  protected function getMockedRequest(string $uri, string $method, array $document = []): Request {
    $request = Request::create($uri, $method, [], [], [], [], $document ? Json::encode($document) : NULL);
    if ($document !== []) {
      $request->headers->set('Content-Type', 'application/vnd.api+json');
    }
    $request->headers->set('Accept', 'application/vnd.api+json');
    return $request;
  }

Here's sample code from a test which performs a PATCH to a resource in JSON:API and performs assertions on the response data.

   $document['data'] = [
      'type' => 'order--default',
      'id' => self::TEST_ORDER_UUID,
      'attributes' => $test_document['attributes'] ?? [],
      'relationships' => $test_document['relationships'] ?? [],
      'meta' => $test_document['meta'] ?? [],
    ];

    $request = $this->getMockedRequest(
      'http://localhost/jsonapi/checkout/' . self::TEST_ORDER_UUID,
      'PATCH',
      $document
    );

    $response = $this->processRequest($request);
    $decoded_document = Json::decode($response->getContent());

The content in $decoded_document is an array of the JSON:API response.

Saving time and money

Tests that generally take 50 to 60 seconds as Functional tests can now execute in 10 to 15 seconds as Kernel tests.

This is extremely important as time is money. The Drupal Association pays approximately $3,000 to $4,000 a month in test runner infrastructure costs. It also saves your organization money. Bitbucket Pipelines cost $10 a month for an additional 1,000 build minutes a month. CircleCI works in credits, which is a mix of build minutes and resource usage.

Here is a real and recent example. In our feature issue to add Facet support to JSON:API Search API, I tried to use the Kernel test approach. We have a test with 9 testing data sets. With Kernel tests each test data set took about 15 seconds to process, making the test about 2.5 minutes long.

Due to some architecture design in class constructors within the Facets, it isn't feasible. The test had to remain a Functional test. Each test data set takes about 50 to 54 seconds, resulting in a total run of roughly 8.75 minutes.

That time quickly adds up.

Speeding up Drupal core's tests?

I am hoping to find time to work on evaluating this with the JSON:API tests in Drupal core. I don't have a total amount in the number of tests executed or JSON:API, but I know it is a lot. I am fairly certain this approach would greatly speed up Drupal core test runs.

Photo by Veri Ivanova on Unsplash

I'm available for one-on-one consulting calls – click here to book a meeting with me 🗓️