Drupal 8 Unit Tests for Custom Forms
Tags: drupal, drupal8, unittest
Published: 2020-11-22 13:20:38

Recently, I was tasked to create a unit test for a custom Configuration Form that we created in Drupal 8. As I am fairly new to Drupal 8, I had to find online resources that can give more details on this. 

Although the official online Drupal website has pages dedicated to Drupal 8 testing, I still find it a bit lacking as it was not too beginner-friendly for me. I tried looking for other resources but couldn't find any that are simple and specific to my needs.

After a bit of trial-and-errors, I finally have a basic set-up for unit testing a form within a custom module. I have decided to document this set-up here in the hopes that it might be able to help another newbie like me.

## Set up PHPUnit

Drupal 8 uses PHPUnit to run its unit tests and it should already be available as a dependency when you first set up Drupal 8 via composer. A basic PHPUnit configuration can be obtained from copying the core's `phpunit.xml.dist` file (found in `web/core/phpunit.xml.dist`) into your root directory and renaming it to `phpunit.xml`. 

For my case, I only want to execute unit tests for my custom modules so I modified some lines in the configuration file. The configuration file that I came up with is as follows (check the comments for the details of the changes):

```xml
<?xml version="1.0" encoding="UTF-8"?>

<!-- Changed bootstrap to 'web/core/tests/bootstrap.php' as my Drupal core is located in web/core
-->
<phpunit bootstrap="web/core/tests/bootstrap.php" colors="true" beStrictAboutTestsThatDoNotTestAnything="true" beStrictAboutOutputDuringTests="true" beStrictAboutChangesToGlobalState="true" printerClass="\Drupal\Tests\Listeners\HtmlOutputPrinter">
  <php>
    <!-- Set error reporting to E_ALL. -->
    <ini name="error_reporting" value="32767"/>
    <!-- Do not limit the amount of memory tests take to run. -->
    <ini name="memory_limit" value="-1"/>
  </php>
  <testsuites>
    <!-- Add a testsuite for the custom module -->
    <!-- To execute, run `vendor/bin/phpunit --testsuite=module-name` -->
    <testsuite name="module-name">
      <!-- Tests should be placed inside the `tests` directory within the module -->      <directory>./web/modules/custom/module_name/tests/</directory>
    </testsuite>
  </testsuites>
  <listeners>
    <listener class="\Drupal\Tests\Listeners\DrupalListener">
    </listener>
    <!-- The Symfony deprecation listener has to come after the Drupal listener -->
    <listener class="Symfony\Bridge\PhpUnit\SymfonyTestsListener">
    </listener>
  </listeners>
  <!-- Filter for coverage reports. -->
  <filter>
    <whitelist>
      <!-- Extensions can have their own test directories, so exclude those. -->
      <directory>./web/modules/custom/module_name</directory>
      <exclude>
        <directory>./web/modules/custom/module_name/*/tests</directory>
      </exclude>
    </whitelist>
  </filter>
</phpunit>

```

## Creating a Unit Test

There are some conventions that must be followed when creating a unit test. Some of them are

- Unit tests for a custom modules must be in a `tests` subdirectory within the custom module directory
- Unit tests must be placed inside `src/Unit` (e.g., `modules/custom/module_name/tests/src/Unit/`)
- Class name of test must end in `Test` (e.g., `CustomModuleFormTest`
- Unit test must extend from `Drupal\Tests\UnitTestCase`
- Test methods must start with `test` (e.g., `testCalculateSum()`)
- A mocking library (Prophecy) is already available and can be used via `$this->prophesize`

The code below is the very simple unit test that I have created that uses the aforementioned conventions. You may check the embedded comments for the details.

```php
<?php
// modules/custom/module_name/tests/src/Unit/CustomModuleFormTest.php

// Namespace must follow Drupal\Tests\module_name\Unit
namespace Drupal\Tests\module_name\Unit;

use Drupal\Core\Config\ConfigFactoryInterface;
use Drupal\Core\Config\Config;
use Drupal\Core\Form\FormState;
use Drupal\Core\StringTranslation\TranslationInterface;
use Drupal\savings_calculator\Form\SavingsCalculatorSettingsForm;
use Drupal\Tests\UnitTestCase;

/**
 * Test class for CustomModuleForm
 * 
 * Must extend from UnitTestCase
 */
class CustomModuleFormTest extends UnitTestCase {

  /**
   * @var \Drupal\Core\StringTranslation\TranslationInterface
   */
  private $translationInterfaceMock;

  /**
   * @var \Drupal\Core\Config\ConfigFactoryInterface
   */
  private $configFactoryMock;

  /**
   * @var \Drupal\Core\Config\Config
   */
  private $configMock;

  /**
   * @var \Drupal\savings_calculator\Form
   */
  private $form;

  public function setUp() {
    // prophesize() is made available via extension from UnitTestCase
    // Call this method to create a mock based on a class
    $this->translationInterfaceMock = $this->prophesize(TranslationInterface::class);

    // Create mock to return config that will be used in the code under test
    $this->configMock = $this->prophesize(Config::class);
    // When the get method of the config is called with the parameter 'custom_property', 
    // the array ['label' => 'Discounts'] will be returned.
    $this->configMock->get('custom_property')->willReturn([
      'label' => 'Discounts'
    ]);

    $this->configFactoryMock = $this->prophesize(ConfigFactoryInterface::class);
    $this->configFactoryMock->getEditable('module_name.settings')->willReturn($this->configMock);

    // Instantiate the code under test
    $this->form = new CustomModuleForm($this->configFactoryMock->reveal());
    // Config Base Form has a call to $this->t() which references the TranslationService
    // Set the translation service mock so that the program won't throw an error
    $this->form->setStringTranslation($this->translationInterfaceMock->reveal());
  }

  // Test that the correct form ID is returned
  public function testFormId() {
    $this->assertEquals('module_name_settings_form', $this->form->getFormId());
  }

  // Test that the correct form fields are added
  public function testBuildForm() {
    // Arrange
    $form = [];
    $form_state = new FormState();

    // Act
    // Call the function being tested
    $retForm = $this->form->buildForm($form, $form_state);

    // Assert
    $this->assertEquals('module_theme', $retForm['#theme']);
    $this->assertArrayEquals(['#type' => 'submit'], $retForm['submit']);

    // The code under test retrieves the label from config
    // Check that the label returned by config is given 
    // to the title attribute of the custom field
    $this->assertArrayEquals(
      [
        '#type' => 'textfield',
        '#title' => 'Discounts'
      ],
      $retForm['custom']
    );
  }
}
```

## Running the Unit Test

To run the unit, simply execute `vendor/bin/phpunit --testsuite=module-name` 

## References
- https://www.drupal.org/docs/testing/phpunit-in-drupal/phpunit-file-structure-namespace-and-required-metadata
- https://www.drupal.org/docs/automated-testing/phpunit-in-drupal/running-phpunit-javascript-tests